Repository: remi-dupre/opening-hours-rs (language: python)

Issue:
Hour only hours seem to not get picked up in multi-day intervals
Hey, thanks for the great library! 

There seem to an issue when opening hours are passed using only the hours (and no days) and querying the `.intervals` using a different day from `start` to `end`.

`OpeningHours("00:30-05:30").intervals(datetime(2024, 11, 25, 17, 30), datetime(2024, 11, 26, 9, 0))` doesn't return any open hours.
`OpeningHours("00:30-05:30").intervals(datetime(2024, 11, 26, 0, 0), datetime(2024, 11, 26, 9, 0))` does return the expected open hours.

However, this is not an issue if we prepend the string with `Mo-Su`. 

I assume we would want the output of `Mo-Su 00:30-05:30` to be the same as `00:30-05:30`. But perhaps that is wrong, and the current implementation / behaviour is intended ! 



# Reproducible example
## Version
```python
import pkg_resources
package_version = pkg_resources.get_distribution('opening-hours-py').version
package_version
Out: '0.8.3'
```

## Example
I assume we would want both of those output to be the same:

```python
from opening_hours import OpeningHours
from datetime import datetime, UTC
start = datetime(2024, 11, 25, 17, 30, tzinfo=UTC)
end = datetime(2024, 11, 26, 9, 0, tzinfo=UTC)
hours_that_works = "Mo-Su 00:30-05:30"
hours_that_dont = "00:30-05:30"
interval_fine = OpeningHours(hours_that_works).intervals(start.replace(tzinfo=None), end.replace(tzinfo=None))
interval_not_fine = OpeningHours(hours_that_dont).intervals(start.replace(tzinfo=None), end.replace(tzinfo=None))
print("######### Hours prepended with Mo-Su #########")
for i in interval_fine:
    print(i)
print("######### Hours only #########")
for i in interval_not_fine:
    print(i)

# Output:
    
######### Hours prepended with Mo-Su #########
(datetime.datetime(2024, 11, 25, 17, 30), datetime.datetime(2024, 11, 26, 0, 30), 'closed', [])
(datetime.datetime(2024, 11, 26, 0, 30), datetime.datetime(2024, 11, 26, 5, 30), 'open', [])
(datetime.datetime(2024, 11, 26, 5, 30), datetime.datetime(2024, 11, 26, 9, 0), 'closed', [])
######### Hours only #########
(datetime.datetime(2024, 11, 25, 17, 30), datetime.datetime(2024, 11, 26, 9, 0), 'closed', [])

```


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 960b275..b9ff08b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog
 
+## 0.9.1
+
+### Fixes
+
+- Fix [#56](https://github.com/remi-dupre/opening-hours-rs/issues/56):
+  expressions with no date filter (eg. `00:30-05:30`) may be considered as
+  always closed.
+
 ## 0.9.0
 
 ### General
@@ -22,7 +30,6 @@
 - The iterator returned by `OpeningHours.intervals` can be moved between
   threads.
 
-
 ## 0.8.3
 
 ### Fixes
@@ -30,21 +37,18 @@
 - Fix [#52](https://github.com/remi-dupre/opening-hours-rs/pull/52): intervals
   were stopping at midnight before the last day.
 
-
 ## 0.8.2
 
 ### Fixes
 
 - Python's Linux binary build were not uploading
 
-
 ## 0.8.1
 
 ### Fixes
 
 - Rust crate couldn't publish
 
-
 ## 0.8.0
 
 ### General
diff --git a/Cargo.lock b/Cargo.lock
index 79bcd4e..65d6f64 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -171,7 +171,7 @@ checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7"
 
 [[package]]
 name = "compact-calendar"
-version = "0.9.0"
+version = "0.9.1"
 dependencies = [
  "chrono",
 ]
@@ -498,7 +498,7 @@ checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9"
 
 [[package]]
 name = "opening-hours"
-version = "0.9.0"
+version = "0.9.1"
 dependencies = [
  "chrono",
  "compact-calendar",
@@ -510,7 +510,7 @@ dependencies = [
 
 [[package]]
 name = "opening-hours-py"
-version = "0.9.0"
+version = "0.9.1"
 dependencies = [
  "chrono",
  "opening-hours",
@@ -521,7 +521,7 @@ dependencies = [
 
 [[package]]
 name = "opening-hours-syntax"
-version = "0.9.0"
+version = "0.9.1"
 dependencies = [
  "chrono",
  "log",
diff --git a/Cargo.toml b/Cargo.toml
index d52be64..b86a2a6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "opening-hours"
-version = "0.9.0"
+version = "0.9.1"
 authors = ["Rémi Dupré <remi@dupre.io>"]
 license = "MIT OR Apache-2.0"
 readme = "README.md"
@@ -21,14 +21,14 @@ members = ["compact-calendar", "fuzz", "opening-hours-syntax", "python"]
 disable-test-timeouts = []
 
 [dependencies]
-compact-calendar = { path = "compact-calendar", version = "0.9.0" }
-opening-hours-syntax = { path = "opening-hours-syntax", version = "0.9.0" }
+compact-calendar = { path = "compact-calendar", version = "0.9.1" }
+opening-hours-syntax = { path = "opening-hours-syntax", version = "0.9.1" }
 chrono = "0.4"
 flate2 = "1.0"
 log = { version = "0.4", features = [ "kv" ] }
 
 [build-dependencies]
-compact-calendar = { path = "compact-calendar", version = "0.9.0" }
+compact-calendar = { path = "compact-calendar", version = "0.9.1" }
 chrono = "0.4"
 flate2 = "1.0"
 
diff --git a/compact-calendar/Cargo.toml b/compact-calendar/Cargo.toml
index 7368872..bb597f8 100644
--- a/compact-calendar/Cargo.toml
+++ b/compact-calendar/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "compact-calendar"
-version = "0.9.0"
+version = "0.9.1"
 authors = ["Rémi Dupré <remi@dupre.io>"]
 license = "MIT OR Apache-2.0"
 readme = "README.md"
diff --git a/opening-hours-syntax/Cargo.toml b/opening-hours-syntax/Cargo.toml
index 91ef774..85afe68 100644
--- a/opening-hours-syntax/Cargo.toml
+++ b/opening-hours-syntax/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "opening-hours-syntax"
-version = "0.9.0"
+version = "0.9.1"
 authors = ["Rémi Dupré <remi@dupre.io>"]
 license = "MIT OR Apache-2.0"
 readme = "README.md"
diff --git a/opening-hours-syntax/src/rules/day.rs b/opening-hours-syntax/src/rules/day.rs
index b29f9db..34a85a3 100644
--- a/opening-hours-syntax/src/rules/day.rs
+++ b/opening-hours-syntax/src/rules/day.rs
@@ -40,7 +40,8 @@ pub struct DaySelector {
 }
 
 impl DaySelector {
-    pub(crate) fn is_empty(&self) -> bool {
+    /// Return `true` if there is no date filter in this expression.
+    pub fn is_empty(&self) -> bool {
         self.year.is_empty()
             && self.monthday.is_empty()
             && self.week.is_empty()
diff --git a/pyproject.toml b/pyproject.toml
index 1300888..dd1f870 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "opening-hours-py"
-version = "0.9.0"
+version = "0.9.1"
 description = "A parser for the opening_hours fields from OpenStreetMap."
 authors = ["Rémi Dupré <remi@dupre.io>"]
 
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 57fd332..c00cea0 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "opening-hours-py"
-version = "0.9.0"
+version = "0.9.1"
 authors = ["Rémi Dupré <remi@dupre.io>"]
 license = "MIT OR Apache-2.0"
 readme = "README.md"
@@ -16,7 +16,7 @@ crate-type = ["cdylib"]
 
 [dependencies]
 chrono = "0.4"
-opening-hours = { path = "..", version = "0.9.0" }
-opening-hours-syntax = { path = "../opening-hours-syntax", version = "0.9.0" }
+opening-hours = { path = "..", version = "0.9.1" }
+opening-hours-syntax = { path = "../opening-hours-syntax", version = "0.9.1" }
 pyo3 = { version = "0.22", features = ["chrono", "extension-module", "generate-import-lib"] }
 pyo3-log = "0.11"
diff --git a/src/date_filter.rs b/src/date_filter.rs
index a390a8a..80dbd37 100644
--- a/src/date_filter.rs
+++ b/src/date_filter.rs
@@ -54,6 +54,11 @@ impl DateFilter for ds::DaySelector {
     }
 
     fn next_change_hint(&self, date: NaiveDate, ctx: &Context) -> Option<NaiveDate> {
+        // If there is no date filter, then all dates shall match
+        if self.is_empty() {
+            return date.succ_opt();
+        }
+
         *[
             self.year.next_change_hint(date, ctx),
             self.monthday.next_change_hint(date, ctx),
diff --git a/src/opening_hours.rs b/src/opening_hours.rs
index 73ae86b..9ce0fb4 100644
--- a/src/opening_hours.rs
+++ b/src/opening_hours.rs
@@ -12,7 +12,9 @@ use crate::context::Context;
 use crate::date_filter::DateFilter;
 use crate::error::ParserError;
 use crate::schedule::Schedule;
-use crate::time_filter::{time_selector_intervals_at, time_selector_intervals_at_next_day};
+use crate::time_filter::{
+    time_selector_intervals_at, time_selector_intervals_at_next_day, TimeFilter,
+};
 use crate::DateTimeRange;
 
 /// The upper bound of dates handled by specification
@@ -90,7 +92,13 @@ impl OpeningHours {
     fn next_change_hint(&self, date: NaiveDate) -> Option<NaiveDate> {
         (self.expr.rules)
             .iter()
-            .map(|rule| rule.day_selector.next_change_hint(date, &self.ctx))
+            .map(|rule| {
+                if rule.time_selector.is_immutable_full_day() && rule.day_selector.is_empty() {
+                    Some(DATE_LIMIT.date())
+                } else {
+                    rule.day_selector.next_change_hint(date, &self.ctx)
+                }
+            })
             .min()
             .flatten()
     }
diff --git a/src/time_filter.rs b/src/time_filter.rs
index 6e763cb..367baec 100644
--- a/src/time_filter.rs
+++ b/src/time_filter.rs
@@ -11,13 +11,11 @@ pub(crate) fn time_selector_intervals_at(
     time_selector: &ts::TimeSelector,
     date: NaiveDate,
 ) -> impl Iterator<Item = Range<ExtendedTime>> + '_ {
-    ranges_union(
-        time_selector_as_naive(time_selector, date).filter_map(|range| {
-            let dstart = ExtendedTime::new(0, 0).unwrap();
-            let dend = ExtendedTime::new(24, 0).unwrap();
-            range_intersection(range, dstart..dend)
-        }),
-    )
+    ranges_union(time_selector.as_naive(date).filter_map(|range| {
+        let dstart = ExtendedTime::new(0, 0).unwrap();
+        let dend = ExtendedTime::new(24, 0).unwrap();
+        range_intersection(range, dstart..dend)
+    }))
 }
 
 pub(crate) fn time_selector_intervals_at_next_day(
@@ -25,7 +23,8 @@ pub(crate) fn time_selector_intervals_at_next_day(
     date: NaiveDate,
 ) -> impl Iterator<Item = Range<ExtendedTime>> + '_ {
     ranges_union(
-        time_selector_as_naive(time_selector, date)
+        time_selector
+            .as_naive(date)
             .filter_map(|range| {
                 let dstart = ExtendedTime::new(24, 0).unwrap();
                 let dend = ExtendedTime::new(48, 0).unwrap();
@@ -39,27 +38,43 @@ pub(crate) fn time_selector_intervals_at_next_day(
     )
 }
 
-fn time_selector_as_naive(
-    time_selector: &ts::TimeSelector,
-    date: NaiveDate,
-) -> impl Iterator<Item = Range<ExtendedTime>> + '_ {
-    time_selector
-        .time
-        .iter()
-        .map(move |span| span.as_naive(date))
-}
-
 /// Trait used to project a time representation to its naive representation at
 /// a given date.
-pub(crate) trait AsNaive {
-    type Output;
-    fn as_naive(&self, date: NaiveDate) -> Self::Output;
+pub(crate) trait TimeFilter {
+    type Output<'a>
+    where
+        Self: 'a;
+
+    /// Check if this filter is always matching the full day.
+    fn is_immutable_full_day(&self) -> bool {
+        false
+    }
+
+    /// Project a time representation to its naive representation at a given date.
+    fn as_naive(&self, date: NaiveDate) -> Self::Output<'_>;
 }
 
-impl AsNaive for ts::TimeSpan {
-    type Output = Range<ExtendedTime>;
+impl TimeFilter for ts::TimeSelector {
+    type Output<'a> = NaiveTimeSelectorIterator<'a>;
 
-    fn as_naive(&self, date: NaiveDate) -> Self::Output {
+    fn is_immutable_full_day(&self) -> bool {
+        self.time.iter().all(|span| span.is_immutable_full_day())
+    }
+
+    fn as_naive(&self, date: NaiveDate) -> Self::Output<'_> {
+        NaiveTimeSelectorIterator { date, inner: self.time.iter() }
+    }
+}
+
+impl TimeFilter for ts::TimeSpan {
+    type Output<'a> = Range<ExtendedTime>;
+
+    fn is_immutable_full_day(&self) -> bool {
+        self.range.start == ts::Time::Fixed(ExtendedTime::new(0, 0).unwrap())
+            && self.range.end == ts::Time::Fixed(ExtendedTime::new(24, 0).unwrap())
+    }
+
+    fn as_naive(&self, date: NaiveDate) -> Self::Output<'_> {
         let start = self.range.start.as_naive(date);
         let end = self.range.end.as_naive(date);
 
@@ -78,10 +93,10 @@ impl AsNaive for ts::TimeSpan {
     }
 }
 
-impl AsNaive for ts::Time {
-    type Output = ExtendedTime;
+impl TimeFilter for ts::Time {
+    type Output<'a> = ExtendedTime;
 
-    fn as_naive(&self, date: NaiveDate) -> Self::Output {
+    fn as_naive(&self, date: NaiveDate) -> Self::Output<'_> {
         match self {
             ts::Time::Fixed(naive) => *naive,
             ts::Time::Variable(variable) => variable.as_naive(date),
@@ -89,10 +104,10 @@ impl AsNaive for ts::Time {
     }
 }
 
-impl AsNaive for ts::VariableTime {
-    type Output = ExtendedTime;
+impl TimeFilter for ts::VariableTime {
+    type Output<'a> = ExtendedTime;
 
-    fn as_naive(&self, date: NaiveDate) -> Self::Output {
+    fn as_naive(&self, date: NaiveDate) -> Self::Output<'_> {
         self.event
             .as_naive(date)
             .add_minutes(self.offset)
@@ -100,10 +115,10 @@ impl AsNaive for ts::VariableTime {
     }
 }
 
-impl AsNaive for ts::TimeEvent {
-    type Output = ExtendedTime;
+impl TimeFilter for ts::TimeEvent {
+    type Output<'a> = ExtendedTime;
 
-    fn as_naive(&self, _date: NaiveDate) -> Self::Output {
+    fn as_naive(&self, _date: NaiveDate) -> Self::Output<'_> {
         // TODO: real computation based on the day (and position/timezone?)
         match self {
             Self::Dawn => ExtendedTime::new(6, 0).unwrap(),
@@ -113,3 +128,18 @@ impl AsNaive for ts::TimeEvent {
         }
     }
 }
+
+/// Output type for [`TimeSelector::as_naive`].
+pub(crate) struct NaiveTimeSelectorIterator<'a> {
+    date: NaiveDate,
+    inner: std::slice::Iter<'a, ts::TimeSpan>,
+}
+
+impl<'a> Iterator for NaiveTimeSelectorIterator<'a> {
+    type Item = <ts::TimeSpan as TimeFilter>::Output<'a>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        let span = self.inner.next()?;
+        Some(span.as_naive(self.date))
+    }
+}


Test patch:
diff --git a/src/tests/issues.rs b/src/tests/issues.rs
index 056f0bb..55baf7c 100644
--- a/src/tests/issues.rs
+++ b/src/tests/issues.rs
@@ -79,3 +79,23 @@ fn gh52_no_interval_after_last_midnight() -> Result<(), Error> {
     assert_eq!(intervals.next(), None);
     Ok(())
 }
+
+/// https://github.com/remi-dupre/opening-hours-rs/issues/56
+#[test]
+fn gh56_only_close_when_no_day_filter() -> Result<(), Error> {
+    let oh: OpeningHours = dbg!("00:30-05:30".parse()?);
+    let date_start = datetime!("2024-11-25 17:30");
+    let date_end = datetime!("2024-11-26 09:00");
+    let mut intervals = oh.iter_range(date_start, date_end);
+
+    assert_eq!(
+        intervals.next(),
+        Some(DateTimeRange {
+            range: date_start..datetime!("2024-11-26 00:30"),
+            kind: RuleKind::Closed,
+            comments: Default::default(),
+        })
+    );
+
+    Ok(())
+}