From 5cc19fdabefc8b32a57864d169ba79c705dcbbd4 Mon Sep 17 00:00:00 2001 From: rlskoeser Date: Tue, 26 Nov 2024 18:07:25 -0500 Subject: [PATCH] Add calendar converter base class and document how to add calendars --- src/undate/converters/base.py | 66 +++++++++++++++++-- src/undate/converters/calendars/gregorian.py | 14 ++-- .../converters/calendars/hijri/converter.py | 4 +- tests/test_converters/test_base.py | 4 +- tests/test_undate.py | 5 +- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/undate/converters/base.py b/src/undate/converters/base.py index 02cf820..ecdbf9b 100644 --- a/src/undate/converters/base.py +++ b/src/undate/converters/base.py @@ -1,10 +1,11 @@ """ -:class:`undate.converters.BaseDateConverter` provides a base class for +:class:`~undate.converters.BaseDateConverter` provides a base class for implementing date converters, which can provide support for -parsing and generating dates in different formats and also converting -dates between different calendars. +parsing and generating dates in different formats. +The converter subclass :class:`undate.converters.BaseCalendarConverter` +provides additional functionaly needed for calendar conversion. -To add support for a new date format or calendar conversion: +To add support for a new date converter: - Create a new file under ``undate/converters/`` - For converters with sufficient complexity, you may want to create a submodule; @@ -18,6 +19,25 @@ The new subclass should be loaded automatically and included in the converters returned by :meth:`BaseDateConverter.available_converters` +To add support for a new calendar converter: + +- Create a new file under ``undate/converters/calendars/`` + - For converters with sufficient complexity, you may want to create a submodule; + see ``undate.converters.calendars.hijri`` for an example. +- Extend ``BaseCalendarConverter`` and implement ``parse`` and ``to_string`` + formatter methods as desired/appropriate for your converter as well as the + additional methods for ``max_month``, ``max_day``, and convertion ``to_gregorian`` + calendar. +- Add unit tests for the new calendar logic under ``tests/test_converters/calendars/`` +- Add the new calendar to the ``Calendar`` enum of supported calendars in + ``undate/undate.py`` and confirm that the `get_converter` method loads your + calendar converter correctly (an existing unit test should cover this). +- Consider creating a notebook to demonstrate the use of the calendar + converter. + +Calendar converter subclasses are also automatically loaded and included +in the list of available converters. + ------------------- """ @@ -90,6 +110,42 @@ def available_converters(cls) -> Dict[str, Type["BaseDateConverter"]]: """ Dictionary of available converters keyed on name. """ + return {c.name: c for c in cls.subclasses()} # type: ignore + + @classmethod + def subclasses(cls) -> list[Type["BaseDateConverter"]]: + """ + List of available converters classes. Includes calendar convert + subclasses. + """ # ensure undate converters are imported cls.import_converters() - return {c.name: c for c in cls.__subclasses__()} # type: ignore + + # find all direct subclasses, excluding base calendar converter + subclasses = cls.__subclasses__() + subclasses.remove(BaseCalendarConverter) + # add all subclasses of calendar converter base class + subclasses.extend(BaseCalendarConverter.__subclasses__()) + return subclasses + + +class BaseCalendarConverter(BaseDateConverter): + """Base class for calendar converters, with additional methods required + for calendars.""" + + #: Converter name. Subclasses must define a unique name. + name: str = "Base Calendar Converter" + + def max_month(self, year: int) -> int: + """Maximum month for this calendar for this year""" + raise NotImplementedError + + def max_day(self, year: int, month: int) -> int: + """maximum numeric day for the specified year and month in this 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. + """ + raise NotImplementedError diff --git a/src/undate/converters/calendars/gregorian.py b/src/undate/converters/calendars/gregorian.py index 2db1156..f794329 100644 --- a/src/undate/converters/calendars/gregorian.py +++ b/src/undate/converters/calendars/gregorian.py @@ -1,12 +1,11 @@ from calendar import monthrange -from typing import Optional -from undate.converters.base import BaseDateConverter +from undate.converters.base import BaseCalendarConverter -class GregorianDateConverter(BaseDateConverter): +class GregorianDateConverter(BaseCalendarConverter): """ - Converter class for Gregorian calendar. + Calendar onverter class for Gregorian calendar. """ #: converter name: Gregorian @@ -20,7 +19,8 @@ def max_month(self, year: int) -> int: """Maximum month for this calendar for this year""" return 12 - def max_day(self, year: Optional[int] = None, month: Optional[int] = None) -> int: + def max_day(self, year: int, month: int) -> int: + """maximum numeric day for the specified year and month in this calendar""" # if month is known, use that to calculate if month: # if year is known, use it; otherwise use a known non-leap year @@ -38,4 +38,8 @@ def max_day(self, year: Optional[int] = None, month: Optional[int] = None) -> in return max_day def to_gregorian(self, year, month, day) -> 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. + """ + return (year, month, day) diff --git a/src/undate/converters/calendars/hijri/converter.py b/src/undate/converters/calendars/hijri/converter.py index 5c694f8..9a8ad72 100644 --- a/src/undate/converters/calendars/hijri/converter.py +++ b/src/undate/converters/calendars/hijri/converter.py @@ -3,13 +3,13 @@ from convertdate import islamic # type: ignore from lark.exceptions import UnexpectedCharacters -from undate.converters.base import BaseDateConverter +from undate.converters.base import BaseCalendarConverter from undate.converters.calendars.hijri.parser import hijri_parser from undate.converters.calendars.hijri.transformer import HijriDateTransformer from undate.undate import Undate, UndateInterval -class HijriDateConverter(BaseDateConverter): +class HijriDateConverter(BaseCalendarConverter): """ Converter for Hijri / Islamic calendar. diff --git a/tests/test_converters/test_base.py b/tests/test_converters/test_base.py index 60d5d1e..1426f13 100644 --- a/tests/test_converters/test_base.py +++ b/tests/test_converters/test_base.py @@ -18,7 +18,7 @@ def test_available_converters(self): def test_converters_are_unique(self): assert len(BaseDateConverter.available_converters()) == len( - BaseDateConverter.__subclasses__() + BaseDateConverter.subclasses() ), "Formatter names have to be unique." def test_parse_not_implemented(self): @@ -60,5 +60,5 @@ class ISO8601DateFormat2(BaseDateConverter): name = "ISO8601" # duplicates existing formatter assert len(BaseDateConverter.available_converters()) != len( - BaseDateConverter.__subclasses__() + BaseDateConverter.subclasses() ) diff --git a/tests/test_undate.py b/tests/test_undate.py index 37c9af9..ecf0777 100644 --- a/tests/test_undate.py +++ b/tests/test_undate.py @@ -3,7 +3,7 @@ import pytest -from undate.converters.base import BaseDateConverter +from undate.converters.base import BaseCalendarConverter from undate.date import DatePrecision, Timedelta from undate.undate import Undate, UndateInterval, Calendar @@ -573,4 +573,5 @@ def test_calendar_get_converter(): # calendar named in our calendar enum for cal in Calendar: converter = Calendar.get_converter(cal) - assert isinstance(converter, BaseDateConverter) + assert isinstance(converter, BaseCalendarConverter) + assert converter.name.lower() == cal.name.lower()