Skip to content
Merged
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
37 changes: 37 additions & 0 deletions src/undate/converters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
from functools import cache
from typing import Dict, Type

from undate.date import Date

logger = logging.getLogger(__name__)


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

# provisional...
LEAP_YEAR = 0
NON_LEAP_YEAR = 0

def parse(self, value: str):
"""
Parse a string and return an :class:`~undate.undate.Undate` or
Expand Down Expand Up @@ -142,6 +148,16 @@ class BaseCalendarConverter(BaseDateConverter):
#: Converter name. Subclasses must define a unique name.
name: str = "Base Calendar Converter"

#: arbitrary known non-leap year
NON_LEAP_YEAR: int
#: arbitrary known leap year
LEAP_YEAR: int

# minimum year for this calendar, if there is one
MIN_YEAR: None | int = None
# maximum year for this calendar, if there is one
MAX_YEAR: None | int = None

def min_month(self) -> int:
"""Smallest numeric month for this calendar."""
raise NotImplementedError
Expand All @@ -162,6 +178,27 @@ def max_day(self, year: int, month: int) -> int:
"""maximum numeric day for the specified year and month in this calendar"""
raise NotImplementedError

def days_in_year(self, year: int) -> int:
"""Number of days in the specified year in this calendar. The default implementation
uses min and max month and max day methods along with Gregorian conversion method
to calculate the number of days in the specified year.
"""
year_start = Date(*self.to_gregorian(year, self.min_month(), 1))
last_month = self.max_month(year)
year_end = Date(
*self.to_gregorian(year, last_month, self.max_day(year, last_month))
)
# add 1 because the difference doesn't include the end point
return (year_end - year_start).days + 1

def representative_years(self, years: None | list[int] = None) -> list[int]:
"""Returns a list of representative years within the specified list.
Result should include one for each type of variant year for this
calendar (e.g., leap year and non-leap year). If no years are specified,
returns a list of representative years for the current calendar.
"""
raise NotImplementedError

def to_gregorian(self, year, month, day) -> tuple[int, int, int]:
"""Convert a date for this calendar specified by numeric year, month, and day,
into the Gregorian equivalent date. Should return a tuple of year, month, day.
Expand Down
29 changes: 28 additions & 1 deletion src/undate/converters/calendars/gregorian.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from calendar import monthrange
from calendar import monthrange, isleap

from undate.converters.base import BaseCalendarConverter

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

return max_day

def representative_years(self, years: None | list[int] = None) -> list[int]:
"""Takes a list of years and returns a subset with one leap year and one non-leap year.
If no years are specified, returns a known leap year and non-leap year.
"""

# if years is unset or list is empty
if not years:
return [self.LEAP_YEAR, self.NON_LEAP_YEAR]

found_leap = False
found_non_leap = False
rep_years = []
for year in years:
if isleap(year):
if not found_leap:
found_leap = True
rep_years.append(year)
else:
if not found_non_leap:
found_non_leap = True
rep_years.append(year)
# stop as soon as we've found one example of each type of year
if found_leap and found_non_leap:
break

return rep_years

def to_gregorian(self, year, month, day) -> tuple[int, int, int]:
"""Convert to Gregorian date. This returns the specified by year, month,
and day unchanged, but is provided for consistency since all calendar
Expand Down
35 changes: 35 additions & 0 deletions src/undate/converters/calendars/hebrew/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class HebrewDateConverter(BaseCalendarConverter):
name: str = "Hebrew"
calendar_name: str = "Anno Mundi"

#: arbitrary known non-leap year; 4816 is a non-leap year with 353 days (minimum possible)
NON_LEAP_YEAR: int = 4816
#: arbitrary known leap year; 4837 is a leap year with 385 days (maximum possible)
LEAP_YEAR: int = 4837

def __init__(self):
self.transformer = HebrewDateTransformer()

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

def days_in_year(self, year: int) -> int:
"""the number of days in the specified year for this calendar"""
return int(hebrew.year_days(year))

def representative_years(self, years: None | list[int] = None) -> list[int]:
"""Takes a list of years and returns a subset with all possible variations in number of days.
If no years are specified, returns ...
"""

year_lengths = set()
max_year_lengths = 6 # there are 6 different possible length years

# if years is unset or list is empty
if not years:
# NOTE: this does not cover all possible lengths, but should cover min/max
return [self.LEAP_YEAR, self.NON_LEAP_YEAR]

rep_years = []
for year in years:
days = self.days_in_year(year)
if days not in year_lengths:
year_lengths.add(days)
rep_years.append(year)

# stop if we find one example of each type of year
if len(year_lengths) == max_year_lengths:
break

return rep_years

def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
"""Convert a Hebrew date, specified by year, month, and day,
to the Gregorian equivalent date. Returns a tuple of year, month, day.
Expand Down
37 changes: 37 additions & 0 deletions src/undate/converters/calendars/islamic/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ class IslamicDateConverter(BaseCalendarConverter):
name: str = "Islamic"
calendar_name: str = "Islamic"

#: arbitrary known non-leap year
NON_LEAP_YEAR: int = 1457
#: arbitrary known leap year
LEAP_YEAR: int = 1458

# minimum year for islamic calendar is 1 AH, does not go negative
MIN_YEAR: None | int = 1
# convertdate gives a month 34 for numpy max year 2.5^16, so scale it back a bit
MAX_YEAR = int(2.5e12)

def __init__(self):
self.transformer = IslamicDateTransformer()

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

def representative_years(self, years: None | list[int] = None) -> list[int]:
"""Takes a list of years and returns a subset with one leap year and one non-leap year.
If no years are specified, returns a known leap year and non-leap year.
"""

# if years is unset or list is empty
if not years:
return [self.LEAP_YEAR, self.NON_LEAP_YEAR]
found_leap = False
found_non_leap = False
rep_years = []
for year in years:
if islamic.leap(year):
if not found_leap:
found_leap = True
rep_years.append(year)
else:
if not found_non_leap:
found_non_leap = True
rep_years.append(year)
# stop as soon as we've found one example of each type of year
if found_leap and found_non_leap:
break

return rep_years

def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
"""Convert a Hijri date, specified by year, month, and day,
to the Gregorian equivalent date. Returns a tuple of year, month, day.
"""
# NOTE: this results in weird numbers for months when year gets sufficiently high
return islamic.to_gregorian(year, month, day)

def parse(self, value: str) -> Union[Undate, UndateInterval]:
Expand Down
4 changes: 4 additions & 0 deletions src/undate/converters/calendars/seleucid.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
logic with :attr:`SELEUCID_OFFSET`. Returns a tuple of year, month, day.
"""
return super().to_gregorian(year + self.SELEUCID_OFFSET, month, day)

def days_in_year(self, year: int) -> int:
"""the number of days in the specified year for this calendar"""
return super().days_in_year(year + self.SELEUCID_OFFSET)
Loading