Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 55 additions & 10 deletions src/undate/undate.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ def __eq__(self, other: object) -> bool:
# with this type
return NotImplemented

# if either date has an unknown year, then not equal
if not self.known_year or not other.known_year:
return False

# if both dates are fully known, then earliest/latest check
# is sufficient (and will work across calendars!)

Expand Down Expand Up @@ -330,6 +334,14 @@ def __eq__(self, other: object) -> bool:

def __lt__(self, other: object) -> bool:
other = self._comparison_type(other)
if other is NotImplemented:
# return NotImplemented to indicate comparison is not supported
# with this type
return NotImplemented

# if either date has a completely unknown year, then we can't compare
if self.unknown_year or other.unknown_year:
return False

# if this date ends before the other date starts,
# return true (this date is earlier, so it is less)
Expand Down Expand Up @@ -366,19 +378,38 @@ def __gt__(self, other: object) -> bool:
# define gt ourselves so we can support > comparison with datetime.date,
# but rely on existing less than implementation.
# strictly greater than must rule out equals

# if either date has a completely unknown year, then we can't compare
# NOTE: this means that gt and lt will both be false when comparing
# with a date with an unknown year...
if self.unknown_year or isinstance(other, Undate) and other.unknown_year:
return False

return not (self < other or self == other)

def __le__(self, other: object) -> bool:
# if either date has a completely unknown year, then we can't compare
if self.unknown_year or isinstance(other, Undate) and other.unknown_year:
return False

return self == other or self < other
Comment on lines 390 to 395
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix <= with unsupported types: avoid boolean ops on NotImplemented.

Same concern as gt; convert first and propagate NotImplemented.

Apply:

 def __le__(self, other: object) -> bool:
-        # if either date has a completely unknown year, then we can't compare
-        if self.unknown_year or isinstance(other, Undate) and other.unknown_year:
-            return False
-
-        return self == other or self < other
+        other_u = self._comparison_type(other)
+        if other_u is NotImplemented:
+            return NotImplemented
+        if self.unknown_year or other_u.unknown_year:
+            return False
+        return self == other_u or self < other_u
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def __le__(self, other: object) -> bool:
# if either date has a completely unknown year, then we can't compare
if self.unknown_year or isinstance(other, Undate) and other.unknown_year:
return False
return self == other or self < other
def __le__(self, other: object) -> bool:
other_u = self._comparison_type(other)
if other_u is NotImplemented:
return NotImplemented
if self.unknown_year or other_u.unknown_year:
return False
return self == other_u or self < other_u
🤖 Prompt for AI Agents
In src/undate/undate.py around lines 390 to 395, the __le__ implementation
currently performs boolean ops on other without converting it and may evaluate
expressions when other is NotImplemented; first attempt to convert other to an
Undate (call the same conversion helper used in __gt__/__lt__), and if that
conversion returns NotImplemented, return NotImplemented immediately; then
perform the unknown_year checks against the converted value and finally return
self == other or self < other — this prevents boolean ops involving
NotImplemented and mirrors the pattern used for other rich comparisons.


def __contains__(self, other: object) -> bool:
# if the two dates are strictly equal, don't consider
# either one as containing the other
other = self._comparison_type(other)
if other is NotImplemented:
# return NotImplemented to indicate comparison is not supported
# with this type
return NotImplemented

if self == other:
return False

# if either date has a completely unknown year, then we can't determine
if self.unknown_year or other.unknown_year:
return False

return all(
[
self.earliest <= other.earliest,
Expand Down Expand Up @@ -415,19 +446,30 @@ def to_undate(cls, other: object) -> "Undate":

@property
def known_year(self) -> bool:
"year is fully known"
return self.is_known("year")

@property
def unknown_year(self) -> bool:
"year is completely unknown"
return self.is_unknown("year")

def is_known(self, part: str) -> bool:
"""Check if a part of the date (year, month, day) is known.
"""Check if a part of the date (year, month, day) is fully known.
Returns False if unknown or only partially known."""
# TODO: should we use constants or enum for values?

# if we have an integer, then consider the date known
# if we have a string, then it is only partially known; return false
return isinstance(self.initial_values[part], int)

def is_unknown(self, part: str) -> bool:
"""Check if a part of the date (year, month, day) is completely unknown."""
return self.initial_values.get(part) is None

def is_partially_known(self, part: str) -> bool:
# TODO: should XX / XXXX really be considered partially known? other code seems to assume this, so we'll preserve the behavior
# TODO: should XX / XXXX really be considered partially known?
# other code seems to assume this, so we'll preserve the behavior
return isinstance(self.initial_values[part], str)
# and self.initial_values[part].replace(self.MISSING_DIGIT, "") != ""

Expand Down Expand Up @@ -531,8 +573,15 @@ def duration(self) -> Timedelta | UnDelta:
if self.precision == DatePrecision.DAY:
return ONE_DAY

possible_max_days = set()
# if year is known and no values are partially known,
# we can calculate a time delta based on earliest + latest
if self.known_year and not any(
[self.is_partially_known(part) for part in ["year", "month", "day"]]
):
# subtract earliest from latest and add a day to include start day in the count
return self.latest - self.earliest + ONE_DAY

possible_max_days = set()
# if precision is month and year is unknown,
# calculate month duration within a single year (not min/max)
if self.precision == DatePrecision.MONTH:
Expand All @@ -558,13 +607,9 @@ def duration(self) -> Timedelta | UnDelta:

# if there is more than one possible value for number of days
# due to range including lear year / non-leap year, return an uncertain delta
if possible_max_days:
if len(possible_max_days) > 1:
return UnDelta(*possible_max_days)
return Timedelta(possible_max_days.pop())

# otherwise, subtract earliest from latest and add a day to include start day in the count
return self.latest - self.earliest + ONE_DAY
if len(possible_max_days) > 1:
return UnDelta(*possible_max_days)
return Timedelta(possible_max_days.pop())

def _missing_digit_minmax(
self, value: str, min_val: int, max_val: int
Expand Down
6 changes: 4 additions & 2 deletions tests/test_converters/test_iso8601.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ def test_parse_singledate(self):
assert ISO8601DateFormat().parse("2002") == Undate(2002)
assert ISO8601DateFormat().parse("1991-05") == Undate(1991, 5)
assert ISO8601DateFormat().parse("1991-05-03") == Undate(1991, 5, 3)
# missing year but month/day known
assert ISO8601DateFormat().parse("--05-03") == Undate(month=5, day=3)
# missing year but month/day known; compare repr string
assert repr(ISO8601DateFormat().parse("--05-03")) == repr(
Undate(month=5, day=3)
)

def test_parse_singledate_unequal(self):
assert ISO8601DateFormat().parse("2002") != Undate(2003)
Expand Down
70 changes: 64 additions & 6 deletions tests/test_undate.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,12 @@ def test_year_property(self):
# unset year
assert Undate(month=12, day=31).year == "XXXX"

# NOTE: no longer supported to inistalize undate with no date information
# NOTE: no longer supported to initialize undate with no date information
# force method to hit conditional for date precision
# some_century = Undate()
# some_century.precision = DatePrecision.CENTURY
# assert some_century.year is None
some_century = Undate(year="X")
some_century.initial_values["year"] = None
some_century.precision = DatePrecision.CENTURY
assert some_century.year is None

def test_month_property(self):
# one, two digit month
Expand Down Expand Up @@ -233,7 +234,8 @@ def test_eq(self):
assert Undate(2022) == Undate(2022)
assert Undate(2022, 10) == Undate(2022, 10)
assert Undate(2022, 10, 1) == Undate(2022, 10, 1)
assert Undate(month=2, day=7) == Undate(month=2, day=7)
# dates without a known year cannot known to be equal
assert not Undate(month=2, day=7) == Undate(month=2, day=7)

# something we can't convert for comparison should return NotImplemented
assert Undate(2022).__eq__("not a date") == NotImplemented
Expand All @@ -259,6 +261,8 @@ def test_not_eq(self):
# partially unknown dates should NOT be considered equal
assert Undate("19XX") != Undate("19XX")
assert Undate(1980, "XX") != Undate(1980, "XX")
# same dates with unknown years should not be considered equal
assert Undate(month=2, day=7) != Undate(month=2, day=7)

testdata_lt_gt = [
# dates to test for gt/lt comparison: earlier date, later date
Expand Down Expand Up @@ -307,7 +311,23 @@ def test_lte(self, earlier, later):
assert earlier <= later
assert later >= earlier

def test_gt_lt_unknown_years(self):
# unknown years cannot be compared on either side...
year100 = Undate(100)
some_january = Undate(month=1)
assert not year100 < some_january
assert not year100 <= some_january
assert not year100 > some_january
assert not year100 >= some_january
assert not some_january < year100
assert not some_january <= year100
assert not some_january > year100
assert not some_january >= year100

def test_lt_notimplemented(self):
# unsupported type should bail out and return NotImplemented
assert Undate(2022).__lt__("foo") == NotImplemented

# how to compare mixed precision where dates overlap?
# if the second date falls *within* earliest/latest,
# then it is not clearly less; not implemented?
Expand Down Expand Up @@ -340,6 +360,9 @@ def test_lt_notimplemented(self):
def test_contains(self, date1, date2):
assert date1 in date2

# unsupported type should bail out and return NotImplemented
assert Undate(2022).__contains__("foo") == NotImplemented

testdata_not_contains = [
# dates not in range
(Undate(1980), Undate(2020)),
Expand All @@ -359,6 +382,9 @@ def test_contains(self, date1, date2):
(Undate(1980, "XX"), Undate(1980, "XX")),
# - partially unknown month to unknown month
(Undate(1801, "1X"), Undate(1801, "XX")),
# fully unknown year
(Undate(month=6, day=1), Undate(2022)),
(Undate(1950), Undate(day=31)),
]

@pytest.mark.parametrize("date1,date2", testdata_not_contains)
Expand Down Expand Up @@ -497,6 +523,7 @@ def test_partiallyknownyear_duration(self):
assert Undate("XXX", calendar="Hebrew").duration().days == UnInt(353, 385)

def test_known_year(self):
# known OR partially known
assert Undate(2022).known_year is True
assert Undate(month=2, day=5).known_year is False
# partially known year is not known
Expand All @@ -518,6 +545,34 @@ def test_is_known_day(self):
assert Undate(month=1, day="X5").is_known("day") is False
assert Undate(month=1, day="XX").is_known("day") is False

def test_unknown_year(self):
# fully unknown year
assert Undate(month=2, day=5).unknown_year is True
# known or partially known years = all false for unknown
assert Undate(2022).unknown_year is False
# partially known year is not unknown
assert Undate("19XX").unknown_year is False
# fully known string year should be known
assert Undate("1900").unknown_year is False

def test_is_unknown_month(self):
# fully unknown month
assert Undate(2022).is_unknown("month") is True
assert Undate(day=10).is_unknown("month") is True
assert Undate(2022, 2).is_unknown("month") is False
assert Undate(2022, "5").is_unknown("month") is False
assert Undate(2022, "1X").is_unknown("month") is False
assert Undate(2022, "XX").is_unknown("month") is False

def test_is_unknown_day(self):
# fully unknown day
assert Undate(1984).is_unknown("day") is True
assert Undate(month=5).is_unknown("day") is True
assert Undate(month=1, day=3).is_unknown("day") is False
assert Undate(month=1, day="5").is_unknown("day") is False
assert Undate(month=1, day="X5").is_unknown("day") is False
assert Undate(month=1, day="XX").is_unknown("day") is False

def test_parse(self):
assert Undate.parse("1984", "EDTF") == Undate(1984)
assert Undate.parse("1984-04", "EDTF") == Undate(1984, 4)
Expand All @@ -528,7 +583,10 @@ def test_parse(self):

assert Undate.parse("1984", "ISO8601") == Undate(1984)
assert Undate.parse("1984-04", "ISO8601") == Undate(1984, 4)
assert Undate.parse("--12-31", "ISO8601") == Undate(month=12, day=31)
# dates with unknown year are not equal; compare repr string
assert repr(Undate.parse("--12-31", "ISO8601")) == repr(
Undate(month=12, day=31)
)

# unsupported format
with pytest.raises(ValueError, match="Unsupported format"):
Expand Down