diff --git a/src/undate/undate.py b/src/undate/undate.py index e6561bf..96a202d 100644 --- a/src/undate/undate.py +++ b/src/undate/undate.py @@ -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!) @@ -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) @@ -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 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, @@ -415,10 +446,16 @@ 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? @@ -426,8 +463,13 @@ def is_known(self, part: str) -> bool: # 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, "") != "" @@ -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: @@ -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 diff --git a/tests/test_converters/test_iso8601.py b/tests/test_converters/test_iso8601.py index 519eeb2..3626b98 100644 --- a/tests/test_converters/test_iso8601.py +++ b/tests/test_converters/test_iso8601.py @@ -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) diff --git a/tests/test_undate.py b/tests/test_undate.py index 2cbaf7d..1fd4d47 100644 --- a/tests/test_undate.py +++ b/tests/test_undate.py @@ -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 @@ -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 @@ -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 @@ -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? @@ -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)), @@ -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) @@ -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 @@ -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) @@ -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"):