Skip to content

Commit 5b5746d

Browse files
committed
Add duration logic for uncertain years
1 parent f065bf4 commit 5b5746d

File tree

4 files changed

+80
-33
lines changed

4 files changed

+80
-33
lines changed

src/undate/converters/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ def max_day(self, year: int, month: int) -> int:
162162
"""maximum numeric day for the specified year and month in this calendar"""
163163
raise NotImplementedError
164164

165+
def days_in_year(self, year: int) -> int:
166+
"""number of days in the specified year in this calendar"""
167+
raise NotImplementedError
168+
165169
def to_gregorian(self, year, month, day) -> tuple[int, int, int]:
166170
"""Convert a date for this calendar specified by numeric year, month, and day,
167171
into the Gregorian equivalent date. Should return a tuple of year, month, day.

src/undate/converters/calendars/hebrew/converter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ def max_day(self, year: int, month: int) -> int:
4747
# NOTE: unreleased v2.4.1 of convertdate standardizes month_days to month_length
4848
return hebrew.month_days(year, month)
4949

50+
def days_in_year(self, year: int) -> int:
51+
"""the number of days in the specified year for this calendar"""
52+
return int(hebrew.year_days(year))
53+
5054
def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
5155
"""Convert a Hebrew date, specified by year, month, and day,
5256
to the Gregorian equivalent date. Returns a tuple of year, month, day.

src/undate/undate.py

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def calculate_earliest_latest(self, year, month, day):
126126
else:
127127
# use the configured min/max allowable years if we
128128
# don't have any other bounds
129+
# TODO: make calendar-specific? these are min/max for gregorian
129130
min_year = self.MIN_ALLOWABLE_YEAR
130131
max_year = self.MAX_ALLOWABLE_YEAR
131132

@@ -166,7 +167,7 @@ def calculate_earliest_latest(self, year, month, day):
166167
else:
167168
# if we have no day or partial day, calculate min / max
168169
min_day = 1 # is min day ever anything other than 1 ?
169-
rel_year = year if year and isinstance(year, int) else None
170+
rel_year = year if year and isinstance(year, int) else max_year
170171
# use month if it is an integer; otherwise use previusly determined
171172
# max month (which may not be 12 depending if partially unknown)
172173
rel_month = month if month and isinstance(month, int) else latest_month
@@ -473,27 +474,29 @@ def duration(self) -> Timedelta | UnDelta:
473474
if self.precision == DatePrecision.DAY:
474475
return ONE_DAY
475476

477+
# if year is unknown or partially unknown, month and year duration both need to calculate for
478+
# variant years (leap year, non-leap year), since length may vary
479+
possible_years = [self.earliest.year]
480+
if self.is_partially_known("year"):
481+
# if year is partially known (e.g. 191X), get all possible years in range
482+
# TODO: refactor into a function/property; combine/extract from missing digit min/max method
483+
possible_years = [
484+
int(str(self.year).replace(self.MISSING_DIGIT, str(digit)))
485+
for digit in range(0, 10)
486+
]
487+
# TODO: once this is working, make more efficient by only getting representative years from the calendar
488+
elif not self.known_year: # completely unknown year
489+
# TODO: should leap-year specific logic shift to the calendars,
490+
# since it works differently depending on the calendar?
491+
possible_years = [
492+
self.calendar_converter.LEAP_YEAR,
493+
self.calendar_converter.NON_LEAP_YEAR,
494+
]
495+
possible_max_days = None
496+
476497
# if precision is month and year is unknown,
477498
# calculate month duration within a single year (not min/max)
478499
if self.precision == DatePrecision.MONTH:
479-
latest = self.latest
480-
# if year is unknown, calculate month duration in
481-
# leap year and non-leap year, in case length varies
482-
if not self.known_year:
483-
# TODO: should leap-year specific logic shift to the calendars,
484-
# since it works differently depending on the calendar?
485-
possible_years = [
486-
self.calendar_converter.LEAP_YEAR,
487-
self.calendar_converter.NON_LEAP_YEAR,
488-
]
489-
# TODO: handle partially known years like 191X,
490-
# switch to representative years (depends on calendar)
491-
# (to be implemented as part of ambiguous year duration)
492-
else:
493-
# otherwise, get possible durations for all possible months
494-
# for a known year
495-
possible_years = [self.earliest.year]
496-
497500
# for every possible month and year, get max days for that month,
498501
possible_max_days = set()
499502
# appease mypy, which says month values could be None here;
@@ -506,23 +509,45 @@ def duration(self) -> Timedelta | UnDelta:
506509
self.calendar_converter.max_day(year, possible_month)
507510
)
508511

509-
# if there is more than one possible value for month length,
510-
# whether due to leap year / non-leap year or ambiguous month,
511-
# return an uncertain delta
512-
if len(possible_max_days) > 1:
513-
return UnDelta(*possible_max_days)
514-
515-
# otherwise, calculate timedelta normally based on maximum day
516-
max_day = list(possible_max_days)[0]
517-
latest = Date(self.earliest.year, self.earliest.month, max_day)
512+
# if precision is year but year is unknown, return an uncertain delta
513+
elif self.precision == DatePrecision.YEAR:
514+
possible_max_days = set()
515+
# this is currently hebrew-specific due to the way the start/end of year wraps for that calendar
516+
try:
517+
possible_max_days = set(
518+
[self.calendar_converter.days_in_year(y) for y in possible_years]
519+
)
520+
except NotImplementedError:
521+
pass
522+
523+
if not possible_max_days:
524+
for year in possible_years:
525+
# TODO: shift logic to calendars for parity with Hebrew?
526+
year_start = Date(
527+
*self.calendar_converter.to_gregorian(
528+
year, self.calendar_converter.min_month(), 1
529+
)
530+
)
531+
last_month = self.calendar_converter.max_month(year)
532+
year_end = Date(
533+
*self.calendar_converter.to_gregorian(
534+
year,
535+
last_month,
536+
self.calendar_converter.max_day(year, last_month),
537+
)
538+
)
518539

519-
return latest - self.earliest + ONE_DAY
540+
year_days = (year_end - year_start).days + 1
541+
possible_max_days.add(year_days)
520542

521-
# TODO: handle year precision + unknown/partially known year
522-
# (will be handled in separate branch)
543+
# if there is more than one possible value for number of days
544+
# due to range including lear year / non-leap year, return an uncertain delta
545+
if possible_max_days and len(possible_max_days) > 1:
546+
return UnDelta(*possible_max_days)
547+
else:
548+
return Timedelta(possible_max_days.pop())
523549

524-
# otherwise, calculate based on earliest/latest range
525-
# subtract earliest from latest and add a day to count start day
550+
# otherwise, subtract earliest from latest and add a day to include start day in the count
526551
return self.latest - self.earliest + ONE_DAY
527552

528553
def _missing_digit_minmax(

tests/test_undate.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,20 @@ def test_partiallyknown_duration(self):
438438
assert isinstance(feb_duration, UnDelta)
439439
assert feb_duration.days == UnInt(28, 29)
440440

441+
def test_partiallyknownyear_duration(self):
442+
assert Undate("190X").duration().days == UnInt(365, 366)
443+
assert Undate("XXXX").duration().days == UnInt(365, 366)
444+
# if possible years don't include any leap years, duration is not ambiguous
445+
assert Undate("19X1").duration().days == 365
446+
# year duration logic should work in other calendars
447+
# islamic
448+
assert Undate("108X", calendar="Islamic").duration().days == UnInt(354, 355)
449+
# NOTE: completely unknown years is not yet supported for other calendars, will cause an error
450+
# assert Undate("XXXX", calendar="Islamic").duration().days == UnInt(354, 355)
451+
#
452+
print(Undate("536X", calendar="Hebrew").duration())
453+
assert Undate("536X", calendar="Hebrew").duration().days == UnInt(353, 385)
454+
441455
def test_known_year(self):
442456
assert Undate(2022).known_year is True
443457
assert Undate(month=2, day=5).known_year is False

0 commit comments

Comments
 (0)