Skip to content

Commit 9efe7e3

Browse files
authored
Merge pull request #135 from dh-tech/bugfix/130-uncertain-years
Add duration logic for uncertain years
2 parents f065bf4 + 7170433 commit 9efe7e3

File tree

12 files changed

+410
-47
lines changed

12 files changed

+410
-47
lines changed

src/undate/converters/base.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
from functools import cache
4949
from typing import Dict, Type
5050

51+
from undate.date import Date
52+
5153
logger = logging.getLogger(__name__)
5254

5355

@@ -58,6 +60,10 @@ class BaseDateConverter:
5860
#: Converter name. Subclasses must define a unique name.
5961
name: str = "Base Converter"
6062

63+
# provisional...
64+
LEAP_YEAR = 0
65+
NON_LEAP_YEAR = 0
66+
6167
def parse(self, value: str):
6268
"""
6369
Parse a string and return an :class:`~undate.undate.Undate` or
@@ -142,6 +148,16 @@ class BaseCalendarConverter(BaseDateConverter):
142148
#: Converter name. Subclasses must define a unique name.
143149
name: str = "Base Calendar Converter"
144150

151+
#: arbitrary known non-leap year
152+
NON_LEAP_YEAR: int
153+
#: arbitrary known leap year
154+
LEAP_YEAR: int
155+
156+
# minimum year for this calendar, if there is one
157+
MIN_YEAR: None | int = None
158+
# maximum year for this calendar, if there is one
159+
MAX_YEAR: None | int = None
160+
145161
def min_month(self) -> int:
146162
"""Smallest numeric month for this calendar."""
147163
raise NotImplementedError
@@ -162,6 +178,27 @@ def max_day(self, year: int, month: int) -> int:
162178
"""maximum numeric day for the specified year and month in this calendar"""
163179
raise NotImplementedError
164180

181+
def days_in_year(self, year: int) -> int:
182+
"""Number of days in the specified year in this calendar. The default implementation
183+
uses min and max month and max day methods along with Gregorian conversion method
184+
to calculate the number of days in the specified year.
185+
"""
186+
year_start = Date(*self.to_gregorian(year, self.min_month(), 1))
187+
last_month = self.max_month(year)
188+
year_end = Date(
189+
*self.to_gregorian(year, last_month, self.max_day(year, last_month))
190+
)
191+
# add 1 because the difference doesn't include the end point
192+
return (year_end - year_start).days + 1
193+
194+
def representative_years(self, years: None | list[int] = None) -> list[int]:
195+
"""Returns a list of representative years within the specified list.
196+
Result should include one for each type of variant year for this
197+
calendar (e.g., leap year and non-leap year). If no years are specified,
198+
returns a list of representative years for the current calendar.
199+
"""
200+
raise NotImplementedError
201+
165202
def to_gregorian(self, year, month, day) -> tuple[int, int, int]:
166203
"""Convert a date for this calendar specified by numeric year, month, and day,
167204
into the Gregorian equivalent date. Should return a tuple of year, month, day.

src/undate/converters/calendars/gregorian.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from calendar import monthrange
1+
from calendar import monthrange, isleap
22

33
from undate.converters.base import BaseCalendarConverter
44

@@ -45,6 +45,33 @@ def max_day(self, year: int, month: int) -> int:
4545

4646
return max_day
4747

48+
def representative_years(self, years: None | list[int] = None) -> list[int]:
49+
"""Takes a list of years and returns a subset with one leap year and one non-leap year.
50+
If no years are specified, returns a known leap year and non-leap year.
51+
"""
52+
53+
# if years is unset or list is empty
54+
if not years:
55+
return [self.LEAP_YEAR, self.NON_LEAP_YEAR]
56+
57+
found_leap = False
58+
found_non_leap = False
59+
rep_years = []
60+
for year in years:
61+
if isleap(year):
62+
if not found_leap:
63+
found_leap = True
64+
rep_years.append(year)
65+
else:
66+
if not found_non_leap:
67+
found_non_leap = True
68+
rep_years.append(year)
69+
# stop as soon as we've found one example of each type of year
70+
if found_leap and found_non_leap:
71+
break
72+
73+
return rep_years
74+
4875
def to_gregorian(self, year, month, day) -> tuple[int, int, int]:
4976
"""Convert to Gregorian date. This returns the specified by year, month,
5077
and day unchanged, but is provided for consistency since all calendar

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ class HebrewDateConverter(BaseCalendarConverter):
2121
name: str = "Hebrew"
2222
calendar_name: str = "Anno Mundi"
2323

24+
#: arbitrary known non-leap year; 4816 is a non-leap year with 353 days (minimum possible)
25+
NON_LEAP_YEAR: int = 4816
26+
#: arbitrary known leap year; 4837 is a leap year with 385 days (maximum possible)
27+
LEAP_YEAR: int = 4837
28+
2429
def __init__(self):
2530
self.transformer = HebrewDateTransformer()
2631

@@ -47,6 +52,36 @@ def max_day(self, year: int, month: int) -> int:
4752
# NOTE: unreleased v2.4.1 of convertdate standardizes month_days to month_length
4853
return hebrew.month_days(year, month)
4954

55+
def days_in_year(self, year: int) -> int:
56+
"""the number of days in the specified year for this calendar"""
57+
return int(hebrew.year_days(year))
58+
59+
def representative_years(self, years: None | list[int] = None) -> list[int]:
60+
"""Takes a list of years and returns a subset with all possible variations in number of days.
61+
If no years are specified, returns ...
62+
"""
63+
64+
year_lengths = set()
65+
max_year_lengths = 6 # there are 6 different possible length years
66+
67+
# if years is unset or list is empty
68+
if not years:
69+
# NOTE: this does not cover all possible lengths, but should cover min/max
70+
return [self.LEAP_YEAR, self.NON_LEAP_YEAR]
71+
72+
rep_years = []
73+
for year in years:
74+
days = self.days_in_year(year)
75+
if days not in year_lengths:
76+
year_lengths.add(days)
77+
rep_years.append(year)
78+
79+
# stop if we find one example of each type of year
80+
if len(year_lengths) == max_year_lengths:
81+
break
82+
83+
return rep_years
84+
5085
def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
5186
"""Convert a Hebrew date, specified by year, month, and day,
5287
to the Gregorian equivalent date. Returns a tuple of year, month, day.

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ class IslamicDateConverter(BaseCalendarConverter):
2121
name: str = "Islamic"
2222
calendar_name: str = "Islamic"
2323

24+
#: arbitrary known non-leap year
25+
NON_LEAP_YEAR: int = 1457
26+
#: arbitrary known leap year
27+
LEAP_YEAR: int = 1458
28+
29+
# minimum year for islamic calendar is 1 AH, does not go negative
30+
MIN_YEAR: None | int = 1
31+
# convertdate gives a month 34 for numpy max year 2.5^16, so scale it back a bit
32+
MAX_YEAR = int(2.5e12)
33+
2434
def __init__(self):
2535
self.transformer = IslamicDateTransformer()
2636

@@ -36,10 +46,37 @@ def max_month(self, year: int) -> int:
3646
"""maximum numeric month for this calendar"""
3747
return 12
3848

49+
def representative_years(self, years: None | list[int] = None) -> list[int]:
50+
"""Takes a list of years and returns a subset with one leap year and one non-leap year.
51+
If no years are specified, returns a known leap year and non-leap year.
52+
"""
53+
54+
# if years is unset or list is empty
55+
if not years:
56+
return [self.LEAP_YEAR, self.NON_LEAP_YEAR]
57+
found_leap = False
58+
found_non_leap = False
59+
rep_years = []
60+
for year in years:
61+
if islamic.leap(year):
62+
if not found_leap:
63+
found_leap = True
64+
rep_years.append(year)
65+
else:
66+
if not found_non_leap:
67+
found_non_leap = True
68+
rep_years.append(year)
69+
# stop as soon as we've found one example of each type of year
70+
if found_leap and found_non_leap:
71+
break
72+
73+
return rep_years
74+
3975
def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
4076
"""Convert a Hijri date, specified by year, month, and day,
4177
to the Gregorian equivalent date. Returns a tuple of year, month, day.
4278
"""
79+
# NOTE: this results in weird numbers for months when year gets sufficiently high
4380
return islamic.to_gregorian(year, month, day)
4481

4582
def parse(self, value: str) -> Union[Undate, UndateInterval]:

src/undate/converters/calendars/seleucid.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
2222
logic with :attr:`SELEUCID_OFFSET`. Returns a tuple of year, month, day.
2323
"""
2424
return super().to_gregorian(year + self.SELEUCID_OFFSET, month, day)
25+
26+
def days_in_year(self, year: int) -> int:
27+
"""the number of days in the specified year for this calendar"""
28+
return super().days_in_year(year + self.SELEUCID_OFFSET)

0 commit comments

Comments
 (0)