diff --git a/src/undate/converters/base.py b/src/undate/converters/base.py index 04db129..1cf1b6d 100644 --- a/src/undate/converters/base.py +++ b/src/undate/converters/base.py @@ -48,6 +48,8 @@ from functools import cache from typing import Dict, Type +from undate.date import Date + logger = logging.getLogger(__name__) @@ -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 @@ -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 @@ -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. diff --git a/src/undate/converters/calendars/gregorian.py b/src/undate/converters/calendars/gregorian.py index c0e0a19..b3b103b 100644 --- a/src/undate/converters/calendars/gregorian.py +++ b/src/undate/converters/calendars/gregorian.py @@ -1,4 +1,4 @@ -from calendar import monthrange +from calendar import monthrange, isleap from undate.converters.base import BaseCalendarConverter @@ -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 diff --git a/src/undate/converters/calendars/hebrew/converter.py b/src/undate/converters/calendars/hebrew/converter.py index d540021..165d67e 100644 --- a/src/undate/converters/calendars/hebrew/converter.py +++ b/src/undate/converters/calendars/hebrew/converter.py @@ -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() @@ -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. diff --git a/src/undate/converters/calendars/islamic/converter.py b/src/undate/converters/calendars/islamic/converter.py index c658c90..67f2a64 100644 --- a/src/undate/converters/calendars/islamic/converter.py +++ b/src/undate/converters/calendars/islamic/converter.py @@ -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() @@ -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]: diff --git a/src/undate/converters/calendars/seleucid.py b/src/undate/converters/calendars/seleucid.py index bddf867..ae54965 100644 --- a/src/undate/converters/calendars/seleucid.py +++ b/src/undate/converters/calendars/seleucid.py @@ -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) diff --git a/src/undate/undate.py b/src/undate/undate.py index e2068e1..e6561bf 100644 --- a/src/undate/undate.py +++ b/src/undate/undate.py @@ -19,7 +19,7 @@ # Pre 3.10 requires Union for multiple types, e.g. Union[int, None] instead of int | None from typing import Dict, Optional, Union -from undate.converters.base import BaseDateConverter +from undate.converters.base import BaseCalendarConverter, BaseDateConverter from undate.date import ONE_DAY, Date, DatePrecision, Timedelta, UnDelta @@ -32,10 +32,19 @@ class Calendar(StrEnum): SELEUCID = auto() @staticmethod - def get_converter(calendar): + def get_converter(calendar) -> BaseCalendarConverter: # calendar converter must be available with a name matching # the title-case name of the calendar enum entry - converter_cls = BaseDateConverter.available_converters()[calendar.value.title()] + try: + converter_cls = BaseDateConverter.available_converters()[ + calendar.value.title() + ] + except KeyError as err: + raise ValueError(f"Unknown calendar '{calendar}'") from err + if not issubclass(converter_cls, BaseCalendarConverter): + raise ValueError( + f"Requested converter '{calendar.value.title()}' is not a CalendarConverter" + ) return converter_cls() @@ -124,10 +133,11 @@ def calculate_earliest_latest(self, year, month, day): min_year = int(str(year).replace(self.MISSING_DIGIT, "0")) max_year = int(str(year).replace(self.MISSING_DIGIT, "9")) else: - # use the configured min/max allowable years if we - # don't have any other bounds - min_year = self.MIN_ALLOWABLE_YEAR - max_year = self.MAX_ALLOWABLE_YEAR + # if we don't have any other bounds, + # use calendar-specific min year if there is one, otherwise use + # the configured min/max allowable years + min_year = self.calendar_converter.MIN_YEAR or self.MIN_ALLOWABLE_YEAR + max_year = self.calendar_converter.MAX_YEAR or self.MAX_ALLOWABLE_YEAR # if month is passed in as a string but completely unknown, # treat as unknown/none (date precision already set in init) @@ -166,7 +176,7 @@ def calculate_earliest_latest(self, year, month, day): else: # if we have no day or partial day, calculate min / max min_day = 1 # is min day ever anything other than 1 ? - rel_year = year if year and isinstance(year, int) else None + rel_year = year if year and isinstance(year, int) else max_year # use month if it is an integer; otherwise use previusly determined # max month (which may not be 12 depending if partially unknown) rel_month = month if month and isinstance(month, int) else latest_month @@ -417,7 +427,9 @@ def is_known(self, part: str) -> bool: return isinstance(self.initial_values[part], int) 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 return isinstance(self.initial_values[part], str) + # and self.initial_values[part].replace(self.MISSING_DIGIT, "") != "" @property def year(self) -> Optional[str]: @@ -459,6 +471,52 @@ def _get_date_part(self, part: str) -> Optional[str]: value = self.initial_values.get(part) return str(value) if value else None + @property + def possible_years(self) -> list[int] | range: + """A list or range of possible years for this date in the original calendar. + Returns a list with a single year for dates with fully-known years.""" + if self.known_year: + return [self.earliest.year] + + step = 1 + if ( + self.is_partially_known("year") + and str(self.year).replace(self.MISSING_DIGIT, "") != "" + ): + # determine the smallest step size for the missing digit + earliest_year = int(str(self.year).replace(self.MISSING_DIGIT, "0")) + latest_year = int(str(self.year).replace(self.MISSING_DIGIT, "9")) + missing_digit_place = len(str(self.year)) - str(self.year).rfind( + self.MISSING_DIGIT + ) + # convert place to 1, 10, 100, 1000, etc. + step = 10 ** (missing_digit_place - 1) + return range(earliest_year, latest_year + 1, step) + + # otherwise, year is fully unknown + # returning range from min year to max year is not useful in any scenario! + raise ValueError( + "Possible years cannot be returned for completely unknown year" + ) + + @property + def representative_years(self) -> list[int]: + """A list of representative years for this date.""" + try: + # todo: filter by calendar to minimum needed + try: + return self.calendar_converter.representative_years( + list(self.possible_years) + ) + except NotImplementedError: + # if calendar converter does not support representative years, return all years + return list(self.possible_years) + except ValueError: + return [ + self.calendar_converter.LEAP_YEAR, + self.calendar_converter.NON_LEAP_YEAR, + ] + def duration(self) -> Timedelta | UnDelta: """What is the duration of this date? Calculate based on earliest and latest date within range, @@ -473,56 +531,39 @@ def duration(self) -> Timedelta | UnDelta: if self.precision == DatePrecision.DAY: return 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: - latest = self.latest - # if year is unknown, calculate month duration in - # leap year and non-leap year, in case length varies - if not self.known_year: - # TODO: should leap-year specific logic shift to the calendars, - # since it works differently depending on the calendar? - possible_years = [ - self.calendar_converter.LEAP_YEAR, - self.calendar_converter.NON_LEAP_YEAR, - ] - # TODO: handle partially known years like 191X, - # switch to representative years (depends on calendar) - # (to be implemented as part of ambiguous year duration) - else: - # otherwise, get possible durations for all possible months - # for a known year - possible_years = [self.earliest.year] - # for every possible month and year, get max days for that month, - possible_max_days = set() # appease mypy, which says month values could be None here; # Date object allows optional month, but earliest/latest initialization # should always be day-precision dates if self.earliest.month is not None and self.latest.month is not None: for possible_month in range(self.earliest.month, self.latest.month + 1): - for year in possible_years: + for year in self.representative_years: possible_max_days.add( self.calendar_converter.max_day(year, possible_month) ) - # if there is more than one possible value for month length, - # whether due to leap year / non-leap year or ambiguous month, - # return an uncertain delta - if len(possible_max_days) > 1: - return UnDelta(*possible_max_days) - - # otherwise, calculate timedelta normally based on maximum day - max_day = list(possible_max_days)[0] - latest = Date(self.earliest.year, self.earliest.month, max_day) - - return latest - self.earliest + ONE_DAY - - # TODO: handle year precision + unknown/partially known year - # (will be handled in separate branch) - - # otherwise, calculate based on earliest/latest range - # subtract earliest from latest and add a day to count start day + # if precision is year but year is unknown, return an uncertain delta + elif self.precision == DatePrecision.YEAR: + # this is currently hebrew-specific due to the way the start/end of year wraps for that calendar + # with contextlib.suppress(NotImplementedError): + possible_max_days = { + self.calendar_converter.days_in_year(y) + for y in self.representative_years + } + + # 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 def _missing_digit_minmax( diff --git a/tests/test_converters/test_base.py b/tests/test_converters/test_base.py index a4ac52d..6265c15 100644 --- a/tests/test_converters/test_base.py +++ b/tests/test_converters/test_base.py @@ -91,3 +91,5 @@ def test_not_implemented(self): BaseCalendarConverter().max_day(1900, 12) with pytest.raises(NotImplementedError): BaseCalendarConverter().to_gregorian(1900, 12, 31) + with pytest.raises(NotImplementedError): + BaseCalendarConverter().representative_years([1900, 1901]) diff --git a/tests/test_converters/test_calendars/test_gregorian.py b/tests/test_converters/test_calendars/test_gregorian.py new file mode 100644 index 0000000..e0bf5ef --- /dev/null +++ b/tests/test_converters/test_calendars/test_gregorian.py @@ -0,0 +1,40 @@ +from undate.converters.calendars import GregorianDateConverter + + +class TestGregorianDateConverter: + def test_to_gregorian(self): + converter = GregorianDateConverter() + # conversion is a no-op, returns values unchanged + assert converter.to_gregorian(2025, 6, 15) == (2025, 6, 15) + + def test_min_month(self): + assert GregorianDateConverter().min_month() == 1 + + def test_max_month(self): + assert GregorianDateConverter().max_month(2025) == 12 + + def test_max_day(self): + converter = GregorianDateConverter() + assert converter.max_day(2025, 1) == 31 + assert converter.max_day(2025, 2) == 28 + assert converter.max_day(converter.LEAP_YEAR, 2) == 29 + assert converter.max_day(2025, 12) == 31 + + def test_representative_years(self): + converter = GregorianDateConverter() + # single year is not filtered + assert converter.representative_years([2025]) == [2025] + # multiple non-leap years, returns just the first + assert converter.representative_years([2025, 2026]) == [2025] + # next leap year is 2028; returns first leap year and first non-leap year, in input order + assert converter.representative_years([2025, 2026, 2028, 2029]) == [2025, 2028] + + # if no years are provided, returns a known leap year and non-leap year + assert converter.representative_years() == [ + converter.LEAP_YEAR, + converter.NON_LEAP_YEAR, + ] + assert converter.representative_years([]) == [ + converter.LEAP_YEAR, + converter.NON_LEAP_YEAR, + ] diff --git a/tests/test_converters/test_calendars/test_hebrew/test_hebrew_converter.py b/tests/test_converters/test_calendars/test_hebrew/test_hebrew_converter.py index c3c8b7c..6fe8c96 100644 --- a/tests/test_converters/test_calendars/test_hebrew/test_hebrew_converter.py +++ b/tests/test_converters/test_calendars/test_hebrew/test_hebrew_converter.py @@ -153,3 +153,37 @@ def test_compare_across_calendars(self): ) expected_gregorian_years = [-3261, 33, 1056, 1350, 1655, 1995] assert [d.earliest.year for d in sorted_dates] == expected_gregorian_years + + def test_days_in_year(self): + converter = HebrewDateConverter() + assert converter.days_in_year(4816) == 353 + assert converter.days_in_year(4817) == 355 + assert converter.days_in_year(4818) == 384 + assert converter.days_in_year(4819) == 355 + + def test_representative_years(self): + converter = HebrewDateConverter() + # single year is not filtered + assert converter.representative_years([4816]) == [4816] + # 4816 has 353 days; 4817 has 355; 4818 has 384; 4819 has 355 + assert converter.representative_years([4816, 4817, 4818, 4819]) == [ + 4816, + 4817, + 4818, + ] + assert converter.representative_years([4816, 4817, 4818, 4819, 4837]) == [ + 4816, + 4817, + 4818, + 4837, + ] + + # if no years are provided, returns a known leap year and non-leap years + assert converter.representative_years() == [ + converter.LEAP_YEAR, + converter.NON_LEAP_YEAR, + ] + assert converter.representative_years([]) == [ + converter.LEAP_YEAR, + converter.NON_LEAP_YEAR, + ] diff --git a/tests/test_converters/test_calendars/test_islamic/test_islamic_converter.py b/tests/test_converters/test_calendars/test_islamic/test_islamic_converter.py index 4acacd0..cfcace2 100644 --- a/tests/test_converters/test_calendars/test_islamic/test_islamic_converter.py +++ b/tests/test_converters/test_calendars/test_islamic/test_islamic_converter.py @@ -152,3 +152,23 @@ def test_compare_across_calendars(self): ) expected_gregorian_years = [33, 1049, 1350, 1479, 1495, 1995] assert [d.earliest.year for d in sorted_dates] == expected_gregorian_years + + def test_representative_years(self): + converter = IslamicDateConverter() + # single year is not filtered + # 1458 is a leap year; 1457 and 1459 are not + assert converter.representative_years([1457]) == [1457] + # multiple non-leap years, returns just the first + assert converter.representative_years([1457, 1459]) == [1457] + # next leap year is 2028; returns first leap year and first non-leap year, in input order + assert converter.representative_years([1457, 1458, 1459]) == [1457, 1458] + + # if no years are provided, returns a known leap year and non-leap years + assert converter.representative_years() == [ + converter.LEAP_YEAR, + converter.NON_LEAP_YEAR, + ] + assert converter.representative_years([]) == [ + converter.LEAP_YEAR, + converter.NON_LEAP_YEAR, + ] diff --git a/tests/test_converters/test_calendars/test_seleucid.py b/tests/test_converters/test_calendars/test_seleucid.py index fd8bc82..d07e5f1 100644 --- a/tests/test_converters/test_calendars/test_seleucid.py +++ b/tests/test_converters/test_calendars/test_seleucid.py @@ -53,6 +53,12 @@ def test_gregorian_earliest_latest(self): assert date.latest == Date(1146, 10, 15) assert date.label == f"{date_str} {SeleucidDateConverter.calendar_name}" + def test_days_in_year(self): + converter = SeleucidDateConverter() + assert converter.days_in_year(2350) == 354 + assert converter.days_in_year(2349) == 385 + assert converter.days_in_year(2351) == 355 + # TODO: update validation error to say seleucid instead of hebrew diff --git a/tests/test_undate.py b/tests/test_undate.py index cf2b252..2cbaf7d 100644 --- a/tests/test_undate.py +++ b/tests/test_undate.py @@ -1,9 +1,12 @@ from datetime import date, datetime +from enum import auto +from unittest import mock import pytest from undate import Undate, UndateInterval, Calendar -from undate.converters.base import BaseCalendarConverter +from undate.undate import StrEnum # import whichever version is used there +from undate.converters.base import BaseCalendarConverter, BaseDateConverter from undate.date import Date, DatePrecision, Timedelta, UnDelta, UnInt @@ -393,6 +396,45 @@ def test_sorting(self): # someyear = Undate("1XXX") # assert sorted([d1991, someyear]) == [someyear, d1991] + def test_possible_years(self): + assert Undate(1991).possible_years == [1991] + assert Undate("190X").possible_years == range(1900, 1910) + assert Undate("19XX").possible_years == range(1900, 2000) + # uses step when missing digit is not last digit + assert Undate("19X1").possible_years == range(1901, 1992, 10) + assert Undate("2X25").possible_years == range(2025, 2926, 100) + assert Undate("1XXX").possible_years == range(1000, 2000) + # completely unknown year raises value error, because the range is not useful + with pytest.raises( + ValueError, match="cannot be returned for completely unknown year" + ): + assert Undate("XXXX").possible_years + + def test_representative_years(self): + # single year is returned as is + assert Undate("1991").representative_years == [1991] + # for an uncertain year, returns first leap year and non-leap year in range + assert Undate("190X").representative_years == [1900, 1904] + assert Undate("19XX").representative_years == [1900, 1904] + # works for other calendars + assert Undate("481X", calendar="Hebrew").representative_years == [ + 4810, + 4811, + 4812, + 4813, + 4816, + 4818, + ] + + # use mock to simulate a calendar without representative years filtering + with mock.patch( + "undate.converters.calendars.HebrewDateConverter.representative_years" + ) as mock_representative_years: + mock_representative_years.side_effect = NotImplementedError + assert Undate("481X", calendar="Hebrew").representative_years == list( + range(4810, 4820) + ) + def test_duration(self): day_duration = Undate(2022, 11, 7).duration() assert isinstance(day_duration, Timedelta) @@ -438,6 +480,22 @@ def test_partiallyknown_duration(self): assert isinstance(feb_duration, UnDelta) assert feb_duration.days == UnInt(28, 29) + def test_partiallyknownyear_duration(self): + assert Undate("190X").duration().days == UnInt(365, 366) + assert Undate("XXXX").duration().days == UnInt(365, 366) + # if possible years don't include any leap years, duration is not ambiguous + assert Undate("19X1").duration().days == 365 + # year duration logic should work in other calendars + # islamic + assert Undate("108X", calendar="Islamic").duration().days == UnInt(354, 355) + # completely unknown years is calculated based on representative years + assert Undate("XXXX", calendar="Islamic").duration().days == UnInt(354, 355) + assert Undate("536X", calendar="Hebrew").duration().days == UnInt(353, 385) + # different set of years could vary + assert Undate("53X2", calendar="Hebrew").duration().days == UnInt(354, 385) + # fully unknown year also works for Hebrew calendar + assert Undate("XXX", calendar="Hebrew").duration().days == UnInt(353, 385) + def test_known_year(self): assert Undate(2022).known_year is True assert Undate(month=2, day=5).known_year is False @@ -508,3 +566,25 @@ def test_calendar_get_converter(): converter = Calendar.get_converter(cal) assert isinstance(converter, BaseCalendarConverter) assert converter.name.lower() == cal.name.lower() + + class BogusCalendar(StrEnum): + """Unsupported calendars""" + + FOOBAR = auto() + DUMMY = auto() + + # test error handling + # ensure we raise a ValueError when an invalid calendar is requested + with pytest.raises(ValueError, match="Unknown calendar"): + Calendar.get_converter(BogusCalendar.FOOBAR) + + class DummyFormatter(BaseDateConverter): + name = "Dummy" + + # also error if you request a converter that is not a calendar converter + # NOTE: this fails because get_converter converts the enum to title case... + # can't be tested with any of the existing non-calendar converters + with pytest.raises( + ValueError, match="Requested converter 'Dummy' is not a CalendarConverter" + ): + Calendar.get_converter(BogusCalendar.DUMMY)