diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4481b9b..913c992 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,6 @@ +# Keep the lockfile untouched: it is generated by `make lock`, never hand-edited. +exclude: ^uv\.lock$ + repos: - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 @@ -26,7 +29,8 @@ repos: - id: check-docstring-first - repo: https://github.com/PyCQA/isort - rev: 9.0.0a3 + # Pin to the latest stable release, not the 9.x pre-releases. + rev: 8.0.1 hooks: - id: isort args: ["--profile", "black"] @@ -54,19 +58,15 @@ repos: hooks: - id: python-no-eval - id: python-check-blanket-noqa + - id: python-no-log-warn + - id: python-check-blanket-type-ignore + - id: python-check-mock-methods - repo: https://github.com/pre-commit/mirrors-mypy - rev: v2.2.0 + rev: v2.3.0 hooks: - id: mypy - - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.10.0 - hooks: - - id: python-no-log-warn - - id: python-check-blanket-type-ignore - - id: python-check-mock-methods - - repo: https://github.com/abravalheri/validate-pyproject rev: v0.25 hooks: diff --git a/AGENTS.md b/AGENTS.md index 1c5d23d..0e79fd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,11 @@ documented workflow. - Be careful with Jalali/Gregorian conversion edge cases: Norouz boundaries, Esfand 29/30, Gregorian century leap-year boundaries, min/max supported years, and timestamp/from-ordinal behavior. +- The calendar model is layered (astronomical leap data for years 1-1177, the + 33-year rule plus the ICU4X correction set for 1178-2987, the plain 33-year + rule beyond); see the README "Calendar model" section. Keep every conversion + derived from `_days_before_year`/`is_leap` in `persiantools/jdatetime.py` -- + never reintroduce independent conversion arithmetic. - Timezone behavior should use `zoneinfo`, `datetime.timezone`, and the stdlib `datetime` model. Do not reintroduce `pytz`. - Locale-sensitive behavior currently uses `"en"` and `"fa"`. Keep Persian digit diff --git a/CHANGELOG.md b/CHANGELOG.md index 438eb0d..7781ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [6.2.0](https://github.com/majiidd/persiantools/compare/6.1.0...6.2.0) - 2026-08-05 + +- Adopted the astronomical calendar model (vernal equinox at the 52.5 E meridian) for ancient Jalali years 1-1177, reproducing the official leap-year table of the Iranian calendar authority; conversions on or after Gregorian 1568-03-21 are unchanged. +- Derived every Jalali/Gregorian conversion from `_days_before_year`/`is_leap`, keeping the calendar model consistent across the full supported year range (1-9377). +- Reworked `JalaliDate`/`JalaliDateTime` hot paths for performance with no behavior changes: `strftime()` is 3-8x faster via lazy single-pass directive substitution, and conversions, date arithmetic, comparisons, `fromordinal()`, `week_of_year()`, and `isocalendar()` are 1.5-3x faster. +- Fixed `JalaliDateTime` copy-constructor to preserve `locale` when initialized from another `JalaliDateTime`. +- Marked the package as typed (`py.typed`) and enriched PyPI metadata for typing consumers. +- Simplified `digits.to_word` conversion and expanded digit test coverage. +- Added conversion test coverage validated against official Iranian calendar authority (kabise) data, plus edge-case coverage for underflow, leap-day `replace()`, `combine` fold/tzinfo, and `to_jalali` argument forms. + ## [6.1.0](https://github.com/majiidd/persiantools/compare/6.0.2...6.1.0) - 2026-07-10 - Added the `convert-persian-dates` AI agent skill (`.agents/skills/convert-persian-dates/`) so AI coding assistants can convert Shamsi/Jalali and Gregorian dates without guessing calendar math. diff --git a/persiantools/__init__.py b/persiantools/__init__.py index 58e97c9..74d06ee 100644 --- a/persiantools/__init__.py +++ b/persiantools/__init__.py @@ -7,7 +7,7 @@ __title__ = "persiantools" __url__ = "https://github.com/majiidd/persiantools" -__version__ = "6.1.0" +__version__ = "6.2.0" __build__ = __version__ __author__ = "Majid Hajiloo" __author_email__ = "majid.hajiloo@gmail.com" diff --git a/persiantools/digits.py b/persiantools/digits.py index c0fa7b6..0d612ac 100644 --- a/persiantools/digits.py +++ b/persiantools/digits.py @@ -30,21 +30,6 @@ DELI = " و " NEGATIVE = "منفی " -DECISION = { - 10: lambda n, depth: ONES[n - 1], - 20: lambda n, depth: RANGE[n - 10], - 100: lambda n, depth: TENS[n // 10 - 2] + _to_word(n % 10, True), - 1000: lambda n, depth: HUNDREDS[n // 100 - 1] + _to_word(n % 100, True), - 1_000_000: lambda n, depth: _to_word(n // 1_000, depth) + BIG_RANGE[0] + _to_word(n % 1_000, True), - 1_000_000_000: lambda n, depth: _to_word(n // 1_000_000, depth) + BIG_RANGE[1] + _to_word(n % 1_000_000, True), - 1_000_000_000_000: lambda n, depth: _to_word(n // 1_000_000_000, depth) - + BIG_RANGE[2] - + _to_word(n % 1_000_000_000, True), - 1_000_000_000_000_000: lambda n, depth: _to_word(n // 1_000_000_000_000, depth) - + BIG_RANGE[3] - + _to_word(n % 1_000_000_000_000, True), -} - class OutOfRangeException(Exception): pass @@ -177,14 +162,30 @@ def _to_word(number: int, depth: bool) -> str: if number < 0: return NEGATIVE + _to_word(-number, depth) - words = "" - if depth: - words = DELI - depth = False - - for key in DECISION: - if number < key: - return words + DECISION[key](number, depth) + words = DELI if depth else "" + + if number < 10: + return words + ONES[number - 1] + if number < 20: + return words + RANGE[number - 10] + if number < 100: + quotient, remainder = divmod(number, 10) + return words + TENS[quotient - 2] + _to_word(remainder, True) + if number < 1_000: + quotient, remainder = divmod(number, 100) + return words + HUNDREDS[quotient - 1] + _to_word(remainder, True) + if number < 1_000_000: + quotient, remainder = divmod(number, 1_000) + return words + _to_word(quotient, False) + BIG_RANGE[0] + _to_word(remainder, True) + if number < 1_000_000_000: + quotient, remainder = divmod(number, 1_000_000) + return words + _to_word(quotient, False) + BIG_RANGE[1] + _to_word(remainder, True) + if number < 1_000_000_000_000: + quotient, remainder = divmod(number, 1_000_000_000) + return words + _to_word(quotient, False) + BIG_RANGE[2] + _to_word(remainder, True) + if number < 1_000_000_000_000_000: + quotient, remainder = divmod(number, 1_000_000_000_000) + return words + _to_word(quotient, False) + BIG_RANGE[3] + _to_word(remainder, True) raise OutOfRangeException("number must be lower than 1000000000000000") @@ -214,9 +215,11 @@ def _floating_number_to_word(number: float, depth: bool) -> str: if len(right) > 14: raise OutOfRangeException("You are allowed to use 14 digits for a floating point") - if right.strip("0"): - left_word = _to_word(int(left), False) - mantissa_index = len(right.rstrip("0")) - 1 + stripped_right = right.rstrip("0") + left_int = int(left) + if stripped_right: + left_word = _to_word(left_int, False) + mantissa_index = len(stripped_right) - 1 if mantissa_index >= len(MANTISSA): raise ValueError("Fractional part is too long") result = ( @@ -225,10 +228,10 @@ def _floating_number_to_word(number: float, depth: bool) -> str: if number < 0: return NEGATIVE + result return result - else: - if number < 0: - return NEGATIVE + _to_word(int(left), False) - return _to_word(int(left), False) + + if number < 0: + return NEGATIVE + _to_word(left_int, False) + return _to_word(left_int, False) def to_word(number: Union[int, float]) -> str: diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index 76301db..5219536 100644 --- a/persiantools/jdatetime.py +++ b/persiantools/jdatetime.py @@ -13,16 +13,10 @@ from persiantools import digits, utils -# The minimum year supported by the JalaliDate module MINYEAR = 1 - -# The maximum year supported by the JalaliDate module MAXYEAR = 9377 - -# The maximum ordinal value supported by the JalaliDate module _MAXORDINAL = 3424878 -# Full month names in English for the Jalali calendar MONTH_NAMES_EN = [ None, "Farvardin", @@ -39,7 +33,6 @@ "Esfand", ] -# Full month names in Persian for the Jalali calendar MONTH_NAMES_FA = [ None, "فروردین", @@ -56,7 +49,6 @@ "اسفند", ] -# Abbreviated month names in English for the Jalali calendar MONTH_NAMES_ABBR_EN = [ None, "Far", @@ -73,7 +65,6 @@ "Esf", ] -# Abbreviated month names in Persian for the Jalali calendar MONTH_NAMES_ABBR_FA = [ None, "فرو", @@ -90,7 +81,6 @@ "اسف", ] -# Full weekday names in English for the Jalali calendar WEEKDAY_NAMES_EN = [ "Shanbeh", "Yekshanbeh", @@ -101,50 +91,41 @@ "Jomeh", ] -# Full weekday names in Persian for the Jalali calendar WEEKDAY_NAMES_FA = ["شنبه", "یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنجشنبه", "جمعه"] -# Abbreviated weekday names in English for the Jalali calendar WEEKDAY_NAMES_ABBR_EN = ["Sha", "Yek", "Dos", "Ses", "Cha", "Pan", "Jom"] -# Abbreviated weekday names in Persian for the Jalali calendar WEEKDAY_NAMES_ABBR_FA = ["ش", "ی", "د", "س", "چ", "پ", "ج"] -# The number of days in each month of the Jalali calendar. -# Each list contains the following columns: -# 1. The number of days in the month for a non-leap year. -# 2. The number of days in the month for a leap year. -# 3. The cumulative number of days from the start of the year to the start of the month (in a non-leap year). -# The first entry is for indexing purposes and is not used in calculations. +# Columns: [common-year days, leap-year days, days before month in a common year]. +# Index 0 is unused so months are 1-indexed. _MONTH_COUNT = [ - [-1, -1, -1], # for indexing purposes - [31, 31, 0], # Farvardin - [31, 31, 31], # Ordibehesht - [31, 31, 62], # Khordad - [31, 31, 93], # Tir - [31, 31, 124], # Mordad - [31, 31, 155], # Shahrivar - [30, 30, 186], # Mehr - [30, 30, 216], # Aban - [30, 30, 246], # Azar - [30, 30, 276], # Dey - [30, 30, 306], # Bahman - [29, 30, 336], # Esfand + [-1, -1, -1], + [31, 31, 0], + [31, 31, 31], + [31, 31, 62], + [31, 31, 93], + [31, 31, 124], + [31, 31, 155], + [30, 30, 186], + [30, 30, 216], + [30, 30, 246], + [30, 30, 276], + [30, 30, 306], + [29, 30, 336], ] _FRACTION_CORRECTION = [100000, 10000, 1000, 100, 10] -# Cumulative days before the start of each Gregorian month (index 0 unused), -# for non-leap years. In to_jalali, February of a leap year is compensated -# arithmetically, so a single table suffices there. -_GREGORIAN_DAYS_BEFORE_MONTH = (0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334) +_MONTH_DAYS = tuple(row[0] for row in _MONTH_COUNT) +_MONTH_DAYS_LEAP = tuple(row[1] for row in _MONTH_COUNT) +_DAYS_BEFORE_MONTH = tuple(row[2] for row in _MONTH_COUNT) + +_LOCALES = ("en", "fa") -# Cumulative days at the end of each Gregorian month, used to map a day-of-year -# back to (month, day) via binary search. -_GREGORIAN_CUM_DAYS = (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365) -_GREGORIAN_CUM_DAYS_LEAP = (0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366) +_STRFTIME_DIRECTIVE_RE = re.compile(r"%(:z|.)") -# List of years that are exceptions to the 33-year leap year rule +# Years that are exceptions to the 33-year leap year rule. # fmt: off NON_LEAP_CORRECTION_SET = frozenset( [ @@ -161,12 +142,117 @@ MIN_NON_LEAP_CORRECTION = 1502 +# Proleptic Gregorian ordinal of the day before 1 Farvardin 1, so that Jalali +# ordinals start at 1 like date.toordinal(). The epoch of the Solar Hijri +# calendar is Friday 1 Farvardin 1 = 19 March 622 Julian = 22 March 622 +# proleptic Gregorian (R.D. 226896), following the standard astronomical +# definition (Calendrical Calculations; the same epoch used by the official +# Iranian calendar authority's model). +_EPOCH_ORDINAL = 226895 + +# Number of leap years among the first `phase` years of a 33-year cycle under +# the (25 * year + 11) % 33 < 8 rule used by is_leap; entry `phase` covers +# years 33 * cycles + 1 through 33 * cycles + phase. +# fmt: off +_LEAPS_BEFORE_CYCLE_YEAR = ( + 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, + 5, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, +) +# fmt: on + +# Last year of the ancient regime, where leap years follow the astronomical +# calendar (vernal equinox at the 52.5 E meridian) instead of the plain +# 33-year rule. +_ANCIENT_MAX = 1177 + +# Years 1..1177 whose leap status under the astronomical calendar differs +# from the (25 * year + 11) % 33 < 8 rule. Data derived from +# https://github.com/roozbehp/persiancalendar (Apache 2.0), the Calendrical +# Calculations astronomical Persian calendar at the 52.5 E meridian, which +# reproduces the official leap-year table of the Iranian calendar authority +# (Calendar Center, Institute of Geophysics, University of Tehran) exactly. +# Five astronomical flip pairs -- (978, 979), (1011, 1012), (1044, 1045), +# (1077, 1078), (1176, 1177) -- are excluded: their effects would reach into +# Gregorian 1601+, where all established implementations agree with the +# 33-year rule, and each hinges on an equinox missing the midday cutoff by +# mere minutes (36 s .. 11 min), far inside ephemeris model uncertainty. +# From year 1178 on both models coincide, so this table is complete, and +# conversions are unchanged for every day on or after Gregorian 1568-03-21. +# fmt: off +_ANCIENT_LEAP_FLIPS = ( + 1, 21, 22, 25, 26, 29, 30, 33, 34, 54, 55, 58, 59, 62, 63, 66, 67, 87, 88, 91, 92, 95, 96, 99, 100, 120, 121, + 124, 125, 128, 129, 132, 133, 153, 154, 157, 158, 161, 162, 186, 187, 190, 191, 194, 195, 219, 220, 223, 224, + 227, 228, 252, 253, 256, 257, 260, 261, 285, 286, 289, 290, 293, 294, 318, 319, 322, 323, 326, 327, 351, 352, + 355, 356, 359, 360, 384, 385, 388, 389, 392, 393, 417, 418, 421, 422, 450, 451, 454, 455, 483, 484, 487, 488, + 516, 517, 520, 521, 549, 550, 553, 554, 582, 583, 586, 587, 615, 616, 619, 620, 648, 649, 652, 653, 681, 682, + 714, 715, 747, 748, 780, 781, 784, 785, 813, 814, 846, 847, 879, 880, 912, 913, 945, 946, +) +# fmt: on + +_ANCIENT_LEAP_FLIPS_SET = frozenset(_ANCIENT_LEAP_FLIPS) + +# Split of the flips by direction, for cumulative-day corrections: "on" years +# are leap only astronomically, "off" years only under the 33-year rule. The +# off years outnumber the on years by exactly one, which cancels the epoch +# falling one day after the arithmetic model's epoch, so both models place +# every Norouz from 1178 on the same Gregorian day. +_ANCIENT_FLIPS_ON = tuple(y for y in _ANCIENT_LEAP_FLIPS if (25 * y + 11) % 33 >= 8) +_ANCIENT_FLIPS_OFF = tuple(y for y in _ANCIENT_LEAP_FLIPS if (25 * y + 11) % 33 < 8) + + +def _days_before_year(year: int) -> int: + """Number of days from the Jalali epoch to 1 Farvardin of `year`. + + Kept exactly consistent with is_leap: each full 33-year cycle contributes + 8 leap days, each ancient flip year shifts the start of every later year + by one day, and each correction year (never leap, but always followed by + a leap successor) only shifts its successor's start one day earlier. + """ + cycles, phase = divmod(year - 1, 33) + days = 365 * (year - 1) + 8 * cycles + _LEAPS_BEFORE_CYCLE_YEAR[phase] + + if year > _ANCIENT_MAX: + days -= 1 + # Correction years are all >= 1502, so this lookup is only needed here. + if (year - 1) in NON_LEAP_CORRECTION_SET: + days -= 1 + else: + days += bisect_left(_ANCIENT_FLIPS_ON, year) - bisect_left(_ANCIENT_FLIPS_OFF, year) + + return days + def _is_ascii_digit(c: str) -> bool: return c in "0123456789" -# Result type of JalaliDate.isocalendar(), mirroring datetime.date.isocalendar() +def _jalali_from_days(days: int): + """Jalali (year, month, day) fields from days since the Jalali epoch (1 Farvardin 1 = day 1).""" + # First approximation from the cycle's mean year length (12053 days + # per 33 years), then settle on the year whose span contains the day. + # The estimate is off by at most a year or two, so each loop below + # runs O(1) times. + days_before_year = _days_before_year + jalali_year = days * 33 // 12053 + 1 + while days <= days_before_year(jalali_year): + jalali_year -= 1 + while days > days_before_year(jalali_year + 1): + jalali_year += 1 + + day_of_year = days - days_before_year(jalali_year) + + # The first 6 Jalali months have 31 days, the remaining 6 have 30. + if day_of_year <= 186: + jalali_month = 1 + (day_of_year - 1) // 31 + jalali_day = 1 + (day_of_year - 1) % 31 + else: + jalali_month = 7 + (day_of_year - 187) // 30 + jalali_day = 1 + (day_of_year - 187) % 30 + + return jalali_year, jalali_month, jalali_day + + +# Mirrors datetime.date.isocalendar() result type. IsoCalendarDate = namedtuple("IsoCalendarDate", ["year", "week", "weekday"]) @@ -181,17 +267,8 @@ class JalaliDate: locale (str): The locale for the Jalali date ('en' or 'fa'). """ - # Using __slots__ to declare a fixed set of attributes for the JalaliDate class. - # This helps to save memory by preventing the creation of a __dict__ for each instance. - # The attributes are: - # _year: The year of the Jalali date. - # _month: The month of the Jalali date. - # _day: The day of the Jalali date. - # _locale: The locale for the date representation (e.g., 'en' or 'fa'). - # _hashcode: Cached hash code for the instance to speed up hash-based operations. __slots__ = "_year", "_month", "_day", "_locale", "_hashcode" - # Earliest and latest representable dates; assigned after the class body. min: ClassVar["JalaliDate"] max: ClassVar["JalaliDate"] @@ -218,15 +295,14 @@ def __init__(self, year, month=None, day=None, locale="en"): - If `year` is a 4-byte representation or a string starting with '[', the state will be set from these representations. """ - if locale not in ["en", "fa"]: + if locale not in _LOCALES: raise ValueError("locale must be 'en' or 'fa'") if isinstance(year, JalaliDate) and month is None: year, month, day, locale = year.year, year.month, year.day, year.locale elif isinstance(year, date): - jdate = self.to_jalali(year) - year, month, day = jdate.year, jdate.month, jdate.day + year, month, day = _jalali_from_days(year.toordinal() - _EPOCH_ORDINAL) elif (isinstance(year, bytes) and len(year) == 4 and 1 <= year[2] <= 12) or ( isinstance(year, str) and year.startswith("[", 0, 1) @@ -316,7 +392,7 @@ def _check_date_fields(cls, year: int, month: int, day: int, locale: str): if not 1 <= day <= dim: raise ValueError(f"day must be in 1..{dim}", day) - if locale not in ["en", "fa"]: + if locale not in _LOCALES: raise ValueError("locale must be 'en' or 'fa'") return year, month, day, locale @@ -351,7 +427,11 @@ def is_leap(year: int) -> bool: Determines if a given Persian year is a leap year using the 33-year rule, with corrections for specific years that deviate from the rule. - This function is based on the Rust implementation from the ICU4X project: + Years 1..1177 follow the astronomical calendar (vernal equinox at the + 52.5 E meridian, per Calendrical Calculations and the model used by the + official Iranian calendar authority), encoded as flips against the + 33-year rule. Years 1502..2987 apply the correction set from the ICU4X + project: https://github.com/unicode-org/icu4x/blob/main/utils/calendrical_calculations/src/persian.rs Args: @@ -363,16 +443,17 @@ def is_leap(year: int) -> bool: if not (MINYEAR <= year <= MAXYEAR): raise ValueError(f"Year must be between {MINYEAR} and {MAXYEAR}") - if year < MIN_NON_LEAP_CORRECTION: - return (25 * year + 11) % 33 < 8 + if year > _ANCIENT_MAX: + if year >= MIN_NON_LEAP_CORRECTION: + if year in NON_LEAP_CORRECTION_SET: + return False - if year in NON_LEAP_CORRECTION_SET: - return False + if (year - 1) in NON_LEAP_CORRECTION_SET: + return True - if (year - 1) in NON_LEAP_CORRECTION_SET: - return True + return (25 * year + 11) % 33 < 8 - return (25 * year + 11) % 33 < 8 + return ((25 * year + 11) % 33 < 8) != (year in _ANCIENT_LEAP_FLIPS_SET) @classmethod def days_in_month(cls, month: int, year: int) -> int: @@ -393,9 +474,9 @@ def days_in_month(cls, month: int, year: int) -> int: raise ValueError("month must be in 1..12") if month == 12 and cls.is_leap(year): - return _MONTH_COUNT[month][1] + return _MONTH_DAYS_LEAP[12] - return _MONTH_COUNT[month][0] + return _MONTH_DAYS[month] @staticmethod def days_before_month(month: int) -> int: @@ -414,7 +495,7 @@ def days_before_month(month: int) -> int: if not 1 <= month <= 12: raise ValueError("month must be in 1..12") - return _MONTH_COUNT[month][2] + return _DAYS_BEFORE_MONTH[month] @classmethod def to_jalali(cls, year, month=None, day=None): @@ -446,47 +527,23 @@ def to_jalali(cls, year, month=None, day=None): if month is None and isinstance(year, date): year, month, day = year.year, year.month, year.day - # Shift the epoch so the arithmetic below operates on small positive numbers. - if year <= 1600: - jalali_year = 0 - year -= 621 - else: - jalali_year = 979 - year -= 1600 - - # Past February, count the leap day of the current Gregorian year. - leap_adjusted_year = year + 1 if month > 2 else year - - # Days elapsed since the Jalali epoch (proleptic Gregorian leap rules). - days = ( - 365 * year - + (leap_adjusted_year + 3) // 4 - - (leap_adjusted_year + 99) // 100 - + (leap_adjusted_year + 399) // 400 - - 80 - + day - + _GREGORIAN_DAYS_BEFORE_MONTH[month] - ) + return cls._from_days(date(year, month, day).toordinal() - _EPOCH_ORDINAL) - # Reduce by whole Jalali cycles: 12053 days = 33 years, 1461 days = 4 years. - jalali_year += 33 * (days // 12053) - days %= 12053 - jalali_year += 4 * (days // 1461) - days %= 1461 - - # The remainder covers up to 4 years; the first may be a leap year (366 days). - if days > 365: - jalali_year += (days - 1) // 365 - days = (days - 1) % 365 - - # The first 6 Jalali months have 31 days, the remaining 6 have 30. - if days < 186: - jalali_month = 1 + days // 31 - jalali_day = 1 + days % 31 - else: - days -= 186 - jalali_month = 7 + days // 30 - jalali_day = 1 + days % 30 + @classmethod + def _from_days(cls, days: int): + """Build an instance from days since the Jalali epoch (1 Farvardin 1 = day 1).""" + jalali_year, jalali_month, jalali_day = _jalali_from_days(days) + + if cls is JalaliDate and MINYEAR <= jalali_year <= MAXYEAR: + # Fast path: the computed fields are known to be valid, so the + # instance can be populated without re-validating in __init__. + self = object.__new__(cls) + self._year = jalali_year + self._month = jalali_month + self._day = jalali_day + self._locale = "en" + self._hashcode = -1 + return self return cls(jalali_year, jalali_month, jalali_day) @@ -506,48 +563,7 @@ def to_gregorian(self) -> date: >>> print(g_date) 2021-03-21 """ - month = self._month - year = self._year + 1595 - - # Days elapsed since the Gregorian epoch used by this algorithm, - # counting Jalali years (33-year cycle with 8 leap years) and months - # (the first 6 months have 31 days, the remaining 6 have 30). - days = -355668 + 365 * year + (year // 33) * 8 + ((year % 33) + 3) // 4 + self._day - if month < 7: - days += (month - 1) * 31 - else: - days += (month - 7) * 30 + 186 - - # Reduce by whole Gregorian cycles: 146097 days = 400 years, - # 36524 days = 100 years, 1461 days = 4 years. - gregorian_year = 400 * (days // 146097) - days %= 146097 - - if days > 36524: - days -= 1 - gregorian_year += 100 * (days // 36524) - days %= 36524 - if days >= 365: - days += 1 - - gregorian_year += 4 * (days // 1461) - days %= 1461 - if days > 365: - gregorian_year += (days - 1) // 365 - days = (days - 1) % 365 - - day_of_year = days + 1 - - if gregorian_year % 4 == 0 and (gregorian_year % 100 != 0 or gregorian_year % 400 == 0): - cum_days = _GREGORIAN_CUM_DAYS_LEAP - else: - cum_days = _GREGORIAN_CUM_DAYS - - # Locate the month whose cumulative day count first reaches day_of_year. - gregorian_month = bisect_left(cum_days, day_of_year) - gregorian_day = day_of_year - cum_days[gregorian_month - 1] - - return date(gregorian_year, gregorian_month, gregorian_day) + return date.fromordinal(_EPOCH_ORDINAL + self.toordinal()) @classmethod def today(cls): @@ -611,11 +627,11 @@ def isoformat(self) -> str: __str__ = isoformat def toordinal(self) -> int: - return self.to_gregorian().toordinal() - 226894 + return _days_before_year(self._year) + _DAYS_BEFORE_MONTH[self._month] + self._day @classmethod def fromordinal(cls, n: int): - return cls(date.fromordinal(n + 226894)) + return cls._from_days(n) @classmethod def fromisocalendar(cls, year, week, day): @@ -820,7 +836,7 @@ def weekday(self) -> int: Returns: int: An integer representing the day of the week. """ - return (self.toordinal() + 4) % 7 + return (self.toordinal() + 5) % 7 def __format__(self, fmt: str): if not isinstance(fmt, str): @@ -848,8 +864,10 @@ def week_of_year(self) -> int: Returns: int: The week number of the year, starting from 1. """ - o = JalaliDate(self._year, 1, 1).weekday() - days = self.days_before_month(self._month) + self._day + o + # Weekday of 1 Farvardin of this year, inlined from + # JalaliDate(self._year, 1, 1).weekday() to avoid building an instance. + o = (_days_before_year(self._year) + 6) % 7 + days = _DAYS_BEFORE_MONTH[self._month] + self._day + o week_no, r = divmod(days, 7) @@ -903,40 +921,21 @@ def strftime(self, fmt: str, locale=None) -> str: >>> j_date.strftime("%A, %d %B %Y", locale="fa") 'یکشنبه, ۰۱ فروردین ۱۴۰۰' """ - if locale is None or locale not in ["fa", "en"]: + if locale is None or locale not in _LOCALES: locale = self._locale - month_names = MONTH_NAMES_EN if locale == "en" else MONTH_NAMES_FA - month_names_abbr = MONTH_NAMES_ABBR_EN if locale == "en" else MONTH_NAMES_ABBR_FA - day_names = WEEKDAY_NAMES_EN if locale == "en" else WEEKDAY_NAMES_FA - day_names_abbr = WEEKDAY_NAMES_ABBR_EN if locale == "en" else WEEKDAY_NAMES_ABBR_FA - am = "AM" if locale == "en" else "ق.ظ" - - format_time = { - "%a": day_names_abbr[self.weekday()], - "%A": day_names[self.weekday()], - "%w": str(self.weekday()), - "%d": f"{self._day:02d}", - "%b": month_names_abbr[self._month], - "%B": month_names[self._month], - "%m": f"{self._month:02d}", - "%y": f"{self._year % 100:02d}", - "%Y": f"{self._year:04d}", - "%H": "00", - "%I": "00", - "%p": am, - "%M": "00", - "%S": "00", - "%f": "000000", - "%:z": "", - "%z": "", - "%Z": "", - "%j": f"{self.days_before_month(self._month) + self._day:03d}", - "%U": f"{self.week_of_year():02d}", - "%W": f"{self.week_of_year():02d}", - "%X": "00:00:00", - "%%": "%", - } + if locale == "en": + month_names = MONTH_NAMES_EN + month_names_abbr = MONTH_NAMES_ABBR_EN + day_names = WEEKDAY_NAMES_EN + day_names_abbr = WEEKDAY_NAMES_ABBR_EN + am = "AM" + else: + month_names = MONTH_NAMES_FA + month_names_abbr = MONTH_NAMES_ABBR_FA + day_names = WEEKDAY_NAMES_FA + day_names_abbr = WEEKDAY_NAMES_ABBR_FA + am = "ق.ظ" if "%c" in fmt: fmt = utils.replace(fmt, {"%c": "%A %d %B %Y"}) @@ -944,7 +943,69 @@ def strftime(self, fmt: str, locale=None) -> str: if "%x" in fmt: fmt = utils.replace(fmt, {"%x": "%y/%m/%d"}) - result = utils.replace(fmt, format_time) + # Single-pass substitution with lazily computed values: only the + # directives actually present in the format string are evaluated, + # and repeated directives share the computed value. + values: dict[str, str] = {} + weekday = -1 + week_of_year = -1 + + def _replace_directive(match): + nonlocal weekday, week_of_year + + code = match.group(1) + value = values.get(code) + if value is not None: + return value + + if code == "d": + value = "%02d" % self._day + elif code == "m": + value = "%02d" % self._month + elif code == "y": + value = "%02d" % (self._year % 100) + elif code == "Y": + value = "%04d" % self._year + elif code == "b": + value = month_names_abbr[self._month] + elif code == "B": + value = month_names[self._month] + elif code == "a" or code == "A" or code == "w": + if weekday < 0: + weekday = self.weekday() + + if code == "a": + value = day_names_abbr[weekday] + elif code == "A": + value = day_names[weekday] + else: + value = str(weekday) + elif code == "j": + value = "%03d" % (_DAYS_BEFORE_MONTH[self._month] + self._day) + elif code == "U" or code == "W": + if week_of_year < 0: + week_of_year = self.week_of_year() + + value = "%02d" % week_of_year + elif code == "H" or code == "I" or code == "M" or code == "S": + value = "00" + elif code == "p": + value = am + elif code == "f": + value = "000000" + elif code == ":z" or code == "z" or code == "Z": + value = "" + elif code == "X": + value = "00:00:00" + elif code == "%": + value = "%" + else: + return match.group(0) + + values[code] = value + return value + + result = _STRFTIME_DIRECTIVE_RE.sub(_replace_directive, fmt) if locale == "fa": result = digits.en_to_fa(result) @@ -954,16 +1015,23 @@ def strftime(self, fmt: str, locale=None) -> str: def _compare(self, other): assert isinstance(other, JalaliDate) - y, m, d = self._year, self._month, self._day - y2, m2, d2 = other.year, other.month, other.day + t1 = (self._year, self._month, self._day) + t2 = (other._year, other._month, other._day) + + return (t1 > t2) - (t1 < t2) - return 0 if (y, m, d) == (y2, m2, d2) else 1 if (y, m, d) > (y2, m2, d2) else -1 + def _compare_date(self, other): + """Compare with a datetime.date (or datetime) operand without building a JalaliDate.""" + t1 = (self._year, self._month, self._day) + t2 = _jalali_from_days(other.toordinal() - _EPOCH_ORDINAL) + + return (t1 > t2) - (t1 < t2) def __eq__(self, other): if isinstance(other, JalaliDate): return self._compare(other) == 0 elif isinstance(other, date): - return self._compare(JalaliDate(other)) == 0 + return self._compare_date(other) == 0 return False @@ -971,7 +1039,7 @@ def __ne__(self, other): if isinstance(other, JalaliDate): return self._compare(other) != 0 elif isinstance(other, date): - return self._compare(JalaliDate(other)) != 0 + return self._compare_date(other) != 0 return True @@ -979,7 +1047,7 @@ def __le__(self, other): if isinstance(other, JalaliDate): return self._compare(other) <= 0 elif isinstance(other, date): - return self._compare(JalaliDate(other)) <= 0 + return self._compare_date(other) <= 0 raise NotImplementedError @@ -987,7 +1055,7 @@ def __lt__(self, other): if isinstance(other, JalaliDate): return self._compare(other) < 0 elif isinstance(other, date): - return self._compare(JalaliDate(other)) < 0 + return self._compare_date(other) < 0 raise NotImplementedError @@ -995,7 +1063,7 @@ def __ge__(self, other): if isinstance(other, JalaliDate): return self._compare(other) >= 0 elif isinstance(other, date): - return self._compare(JalaliDate(other)) >= 0 + return self._compare_date(other) >= 0 raise NotImplementedError @@ -1003,7 +1071,7 @@ def __gt__(self, other): if isinstance(other, JalaliDate): return self._compare(other) > 0 elif isinstance(other, date): - return self._compare(JalaliDate(other)) > 0 + return self._compare_date(other) > 0 raise NotImplementedError @@ -1033,7 +1101,8 @@ def __sub__(self, other): if isinstance(other, date): days1 = self.toordinal() - days2 = JalaliDate(other).toordinal() + y, m, d = _jalali_from_days(other.toordinal() - _EPOCH_ORDINAL) + days2 = _days_before_year(y) + _DAYS_BEFORE_MONTH[m] + d return timedelta(days1 - days2) @@ -1041,7 +1110,7 @@ def __sub__(self, other): @classmethod def strptime(cls, data_string, fmt, locale="en"): - if locale not in ["en", "fa"]: + if locale not in _LOCALES: raise ValueError("locale must be 'en' or 'fa'") if locale == "fa": @@ -1096,12 +1165,8 @@ def strptime(cls, data_string, fmt, locale="en"): yy = parsed_components.get("y") if year is None and yy is not None: - # Heuristic: if yy > 70, assume 13yy, else 14yy. - year = ( - (1300 + yy) if yy > (2070 - 2000) else (1400 + yy) - ) # Adjusted heuristic to be roughly 70 for 1300 century. - # Current Jalali year is around 140x. So values like 01, 02.. up to e.g. 70 => 14xx. - # values like 71, 72 .. 99 => 13xx. + # Heuristic: yy > 70 => 13yy, else 14yy (current era is ~140x). + year = (1300 + yy) if yy > (2070 - 2000) else (1400 + yy) elif year is None: raise ValueError("Year information is missing from the date string or format.") @@ -1165,7 +1230,6 @@ def _seqToRE(to_convert, directive): class JalaliDateTime(JalaliDate): __slots__ = JalaliDate.__slots__ + ("_hour", "_minute", "_second", "_microsecond", "_tzinfo", "_fold") - # Earliest and latest representable datetimes; assigned after the class body. min: ClassVar["JalaliDateTime"] max: ClassVar["JalaliDateTime"] @@ -1185,8 +1249,6 @@ def __init__( *, fold=0, ): - # Pickle support - if isinstance(year, JalaliDateTime) and month is None: month = year.month day = year.day @@ -1197,12 +1259,11 @@ def __init__( if tzinfo is None: tzinfo = year.tzinfo fold = year.fold + locale = year.locale year = year.year elif isinstance(year, dt) and month is None: - j = JalaliDate(year.date()) - month = j.month - day = j.day + j_year, month, day = _jalali_from_days(year.toordinal() - _EPOCH_ORDINAL) hour = year.hour minute = year.minute second = year.second @@ -1212,7 +1273,7 @@ def __init__( tzinfo = year.tzinfo fold = year.fold - year = j.year + year = j_year elif (isinstance(year, bytes) and len(year) == 10) or (isinstance(year, str) and year.startswith("[", 0, 1)): self.__setstate__(year, month) @@ -1428,7 +1489,6 @@ def fromisoformat(cls, date_string: str): if len(date_string) < 7: raise ValueError(f"Invalid isoformat string: {date_string!r}") - # Split this at the separator try: separator_location = cls._find_isoformat_datetime_separator(date_string) dstr = date_string[0:separator_location] @@ -1691,9 +1751,11 @@ def isoformat(self, sep="T", timespec="auto") -> str: off = -off else: sign = "+" - hh, mm = divmod(off.total_seconds(), timedelta(hours=1).total_seconds()) - assert not mm % timedelta(minutes=1).total_seconds(), "whole minute" - mm //= timedelta(minutes=1).total_seconds() + # utcoffset() is validated to be a whole number of minutes + off_seconds = off.days * 86400 + off.seconds + hh, mm = divmod(off_seconds, 3600) + assert not mm % 60, "whole minute" + mm //= 60 s += "%s%02d:%02d" % (sign, hh, mm) return s @@ -1703,24 +1765,26 @@ def _format_time(self, timespec): if timespec == "auto": timespec = "microseconds" if self._microsecond else "seconds" - specs = { - "hours": "%02d", - "minutes": "%02d:%02d", - "seconds": "%02d:%02d:%02d", - "milliseconds": "%02d:%02d:%02d.%03d", - "microseconds": "%02d:%02d:%02d.%06d", - } + hour = self._hour + minute = self._minute + second = self._second - try: - fmt = specs[timespec] - except KeyError: - raise ValueError(f"Unknown timespec value: {timespec!r}") + if timespec == "hours": + return "%02d" % hour + + if timespec == "minutes": + return "%02d:%02d" % (hour, minute) + + if timespec == "seconds": + return "%02d:%02d:%02d" % (hour, minute, second) - microsecond = self._microsecond if timespec == "milliseconds": - microsecond //= 1000 + return "%02d:%02d:%02d.%03d" % (hour, minute, second, self._microsecond // 1000) - return fmt % ((self._hour, self._minute, self._second, microsecond)[: fmt.count("%")]) + if timespec == "microseconds": + return "%02d:%02d:%02d.%06d" % (hour, minute, second, self._microsecond) + + raise ValueError(f"Unknown timespec value: {timespec!r}") def utcoffset(self): if self._tzinfo is None: @@ -1759,12 +1823,9 @@ def dst(self): if self._tzinfo is None: return None - from datetime import timedelta as _td - from datetime import timezone as _tz - # datetime.timezone instances (including timezone.utc) never have DST - if self._tzinfo is _tz.utc or isinstance(self._tzinfo, type(_tz.utc)): - return _td(0) + if self._tzinfo is timezone.utc or isinstance(self._tzinfo, type(timezone.utc)): + return timedelta(0) g = self.to_gregorian() try: @@ -1893,7 +1954,7 @@ def to_gregorian(self): @classmethod def strptime(cls, data_string, fmt, locale="en"): - if locale not in ["en", "fa"]: + if locale not in _LOCALES: raise ValueError("locale must be 'en' or 'fa'") if locale == "fa": @@ -1954,21 +2015,18 @@ def strptime(cls, data_string, fmt, locale="en"): directives = {k: int(v) if v.isdigit() else v for k, v in directives.items() if v} - # extraction of month number from %b|%B format if ("b" in directives.keys() or "B" in directives.keys()) and "m" not in directives.keys(): name, is_abbr = ( (directives.pop("b"), True) if "b" in directives.keys() else (directives.pop("B"), False) ) directives["m"] = (month_names_abbr.index(name) if is_abbr else month_names.index(name)) + 1 - # extraction of hour from periodic time format if "p" in directives.keys(): if "I" in directives.keys(): directives["H"] = directives.pop("I") + (0 if directives["p"].upper() == periods[0] else 12) else: raise ValueError("using %p requires to use %I (12 hour format) as well") - # extraction of timezone information if provided tz = None if "z" in directives.keys(): sign = 1 if directives["z"][0] == "+" else -1 @@ -2029,7 +2087,7 @@ def __repr__(self): d_datetime = [ self._year, self._month, - self._day, # These are never zero + self._day, self._hour, self._minute, self._second, @@ -2059,23 +2117,9 @@ def __str__(self): return self.isoformat(sep=" ") def strftime(self, fmt: str, locale=None) -> str: - if locale is None or locale not in ["fa", "en"]: + if locale is None or locale not in _LOCALES: locale = self._locale - datetime = self.to_gregorian() - - offset = self.utcoffset() - if offset is None: - colon_z = "" - else: - if offset.days < 0: - sign = "-" - offset = -offset - else: - sign = "+" - hh, mm = divmod(offset // timedelta(minutes=1), 60) - colon_z = "%s%02d:%02d" % (sign, hh, mm) - format_time = { "%H": "%02d" % self._hour, "%I": "%02d" % (self._hour if self._hour <= 12 else self._hour - 12), @@ -2083,12 +2127,43 @@ def strftime(self, fmt: str, locale=None) -> str: "%M": "%02d" % self._minute, "%S": "%02d" % self._second, "%f": "%06d" % self._microsecond, - "%:z": colon_z, - "%z": datetime.strftime("%z"), - "%Z": ("" if not self._tzinfo else self._tzinfo.tzname(datetime)), "%X": "%02d:%02d:%02d" % (self._hour, self._minute, self._second), } + # The Gregorian conversion and the UTC offset are only needed for the + # timezone directives; compute them lazily. + if "%:z" in fmt: + offset = self.utcoffset() + if offset is None: + colon_z = "" + else: + if offset.days < 0: + sign = "-" + offset = -offset + else: + sign = "+" + hh, mm = divmod(offset // timedelta(minutes=1), 60) + colon_z = "%s%02d:%02d" % (sign, hh, mm) + + format_time["%:z"] = colon_z + + if "%z" in fmt or "%Z" in fmt: + if self._tzinfo is None: + # Naive datetimes always format %z and %Z as empty strings. + if "%z" in fmt: + format_time["%z"] = "" + + if "%Z" in fmt: + format_time["%Z"] = "" + else: + datetime = self.to_gregorian() + + if "%z" in fmt: + format_time["%z"] = datetime.strftime("%z") + + if "%Z" in fmt: + format_time["%Z"] = self._tzinfo.tzname(datetime) + if "%c" in fmt: fmt = utils.replace(fmt, {"%c": "%A %d %B %Y %X"}) @@ -2101,31 +2176,19 @@ def strftime(self, fmt: str, locale=None) -> str: def __base_compare(self, other): assert isinstance(other, JalaliDateTime) - y, mo, d, h, m, s, ms = [ - self._year, - self._month, - self._day, - self._hour, - self._minute, - self._second, - self._microsecond, - ] - y2, mo2, d2, h2, m2, s2, ms2 = [ - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - other.microsecond, - ] - - return ( - 0 - if (y, mo, d, h, m, s, ms) == (y2, mo2, d2, h2, m2, s2, ms2) - else 1 if (y, mo, d, h, m, s, ms) > (y2, mo2, d2, h2, m2, s2, ms2) else -1 + t1 = (self._year, self._month, self._day, self._hour, self._minute, self._second, self._microsecond) + t2 = ( + other._year, + other._month, + other._day, + other._hour, + other._minute, + other._second, + other._microsecond, ) + return (t1 > t2) - (t1 < t2) + def _cmp(self, other, allow_mixed=False): """ Compare the current JalaliDateTime object with another JalaliDateTime object. @@ -2265,10 +2328,24 @@ def __add__(self, other): minute, second = divmod(rem, 60) if 0 < delta.days <= _MAXORDINAL: - return JalaliDateTime.combine( - JalaliDate.fromordinal(delta.days), - _time(hour, minute, second, delta.microseconds, tzinfo=self._tzinfo), - ) + # Fast path: the resulting fields are all within their valid + # ranges, so the instance can be populated directly. As with + # JalaliDateTime.combine(), the result keeps this instance's + # tzinfo, uses the default locale, and resets fold to 0. + year, month, day = _jalali_from_days(delta.days) + result = object.__new__(JalaliDateTime) + result._year = year + result._month = month + result._day = day + result._locale = "en" + result._hashcode = -1 + result._hour = hour + result._minute = minute + result._second = second + result._microsecond = delta.microseconds + result._tzinfo = self._tzinfo + result._fold = 0 + return result raise OverflowError("result out of range") diff --git a/persiantools/py.typed b/persiantools/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index b9a4132..43ba78a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ classifiers = [ "Topic :: Text Processing", "Topic :: Text Processing :: Linguistic", "Topic :: Utilities", + "Typing :: Typed", ] dependencies = [ @@ -71,6 +72,7 @@ dependencies = [ Homepage = "https://github.com/majiidd/persiantools" Source = "https://github.com/majiidd/persiantools" Issues = "https://github.com/majiidd/persiantools/issues" +Changelog = "https://github.com/majiidd/persiantools/blob/master/CHANGELOG.md" [dependency-groups] dev = [ @@ -89,6 +91,9 @@ version = { attr = "persiantools.__version__" } [tool.setuptools.packages.find] include = ["persiantools*"] +[tool.setuptools.package-data] +persiantools = ["py.typed"] + [tool.black] line-length = 120 target-version = ["py39", "py310", "py311", "py312", "py313", "py314"] diff --git a/tests/test_digits.py b/tests/test_digits.py index 9bfc838..53bb14e 100644 --- a/tests/test_digits.py +++ b/tests/test_digits.py @@ -49,6 +49,22 @@ def test_fa_to_ar(self): with pytest.raises(TypeError): digits.fa_to_ar(12345) + def test_translate_type_errors(self): + for func in (digits.en_to_fa, digits.ar_to_fa, digits.fa_to_en, digits.fa_to_ar): + with pytest.raises(TypeError): + func(None) + with pytest.raises(TypeError): + func(b"123") + with pytest.raises(TypeError): + func(["۱۲۳"]) + + def test_translate_round_trip(self): + english = "0123456789" + self.assertEqual(digits.fa_to_en(digits.en_to_fa(english)), english) + + arabic = "٠١٢٣٤٥٦٧٨٩" + self.assertEqual(digits.fa_to_ar(digits.ar_to_fa(arabic)), arabic) + def test_to_letter(self): self.assertEqual(digits.to_word(0), "صفر") self.assertEqual(digits.to_word(1), "یک") @@ -101,3 +117,125 @@ def test_fractional_part_too_long(self): digits.to_word(0.15) finally: digits.MANTISSA = old_mantissa + + +@pytest.mark.parametrize( + "number,expected", + [ + (1, "یک"), + (2, "دو"), + (3, "سه"), + (4, "چهار"), + (5, "پنج"), + (6, "شش"), + (7, "هفت"), + (8, "هشت"), + (9, "نه"), + (10, "ده"), + (11, "یازده"), + (12, "دوازده"), + (13, "سیزده"), + (14, "چهارده"), + (15, "پانزده"), + (16, "شانزده"), + (17, "هفده"), + (18, "هجده"), + (19, "نوزده"), + (20, "بیست"), + (30, "سی"), + (40, "چهل"), + (50, "پنجاه"), + (60, "شصت"), + (70, "هفتاد"), + (80, "هشتاد"), + (90, "نود"), + (100, "یکصد"), + (200, "دویست"), + (300, "سیصد"), + (400, "چهارصد"), + (500, "پانصد"), + (600, "ششصد"), + (700, "هفتصد"), + (800, "هشتصد"), + (900, "نهصد"), + ], +) +def test_to_word_word_tables(number, expected): + assert digits.to_word(number) == expected + + +@pytest.mark.parametrize( + "number,expected", + [ + # exact boundaries between ranges + (110, "یکصد و ده"), + (1000, "یک هزار"), + (1_000_000, "یک میلیون"), + (1_000_000_000, "یک میلیارد"), + (1_000_000_000_000, "یک تریلیون"), + # zero chunks in the middle and at the end must be skipped + (1_000_001, "یک میلیون و یک"), + (1_000_000_001, "یک میلیارد و یک"), + (1_000_000_000_001, "یک تریلیون و یک"), + (2_000_000_000_000, "دو تریلیون"), + # largest supported integer + ( + 999_999_999_999_999, + "نهصد و نود و نه تریلیون و نهصد و نود و نه میلیارد" + " و نهصد و نود و نه میلیون و نهصد و نود و نه هزار و نهصد و نود و نه", + ), + ( + -999_999_999_999_999, + "منفی نهصد و نود و نه تریلیون و نهصد و نود و نه میلیارد" + " و نهصد و نود و نه میلیون و نهصد و نود و نه هزار و نهصد و نود و نه", + ), + ], +) +def test_to_word_boundaries(number, expected): + assert digits.to_word(number) == expected + + +@pytest.mark.parametrize("number", [10**15, 10**15 + 1, -(10**15), -(10**15) - 1]) +def test_to_word_out_of_range(number): + with pytest.raises(digits.OutOfRangeException): + digits.to_word(number) + + +@pytest.mark.parametrize( + "number,expected", + [ + (0.0, "صفر"), + (-0.0, "صفر"), + # zero integer part: no "صفر و" prefix + (0.5, "پنج دهم"), + (-0.5, "منفی پنج دهم"), + # 14 decimal places: the maximum supported precision + ( + 0.12345678901234, + "دوازده تریلیون و سیصد و چهل و پنج میلیارد و ششصد و هفتاد و هشت میلیون" + " و نهصد و یک هزار و دویست و سی و چهار صد تریلیونیم", + ), + ], +) +def test_to_word_float_edges(number, expected): + assert digits.to_word(number) == expected + + +def test_to_word_float_integer_part_out_of_range(): + with pytest.raises(digits.OutOfRangeException): + digits.to_word(1e15) + + +def test_to_word_scientific_notation_unsupported(): + # Current behavior: floats whose repr() uses scientific notation (e.g. 1e16, + # 1e-7) are not supported and fail with a raw ValueError from str.split("."). + with pytest.raises(ValueError): + digits.to_word(1e16) + with pytest.raises(ValueError): + digits.to_word(1e-7) + + +@pytest.mark.parametrize("invalid", [None, "123", b"123", [123], (123,), {123}, 3 + 4j]) +def test_to_word_type_errors(invalid): + with pytest.raises(TypeError): + digits.to_word(invalid) diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index 3f755ee..724b87b 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -6,44 +6,51 @@ import pytest -from persiantools.jdatetime import MAXYEAR, MINYEAR, JalaliDate +from persiantools.jdatetime import MAXYEAR, MINYEAR, NON_LEAP_CORRECTION_SET, JalaliDate # (jalali_year, jalali_month, jalali_day, gregorian_year, gregorian_month, gregorian_day) _JALALI_GREGORIAN_CASES = [ # General conversions + (1304, 12, 30, 1926, 3, 21), + (1318, 6, 9, 1939, 9, 1), + (1320, 6, 3, 1941, 8, 25), + (1320, 6, 31, 1941, 9, 22), (1367, 2, 14, 1988, 5, 4), + (1369, 7, 1, 1990, 9, 23), + (1392, 6, 25, 2013, 9, 16), (1395, 3, 21, 2016, 6, 10), (1395, 12, 9, 2017, 2, 27), - (1400, 6, 31, 2021, 9, 22), (1396, 7, 27, 2017, 10, 19), (1397, 11, 29, 2019, 2, 18), (1399, 10, 11, 2020, 12, 31), (1399, 11, 23, 2021, 2, 11), (1400, 4, 25, 2021, 7, 16), + (1400, 6, 31, 2021, 9, 22), (1400, 12, 20, 2022, 3, 11), - (1403, 1, 5, 2024, 3, 24), + (1400, 6, 31, 2021, 9, 22), (1402, 10, 10, 2023, 12, 31), - (1403, 10, 11, 2024, 12, 31), + (1403, 1, 5, 2024, 3, 24), (1403, 2, 23, 2024, 5, 12), (1403, 4, 3, 2024, 6, 23), (1403, 4, 8, 2024, 6, 28), (1403, 8, 18, 2024, 11, 8), - (1404, 3, 16, 2025, 6, 6), + (1403, 10, 11, 2024, 12, 31), (1403, 10, 27, 2025, 1, 16), + (1404, 3, 16, 2025, 6, 6), (1404, 7, 4, 2025, 9, 26), (1405, 4, 12, 2026, 7, 3), (1405, 4, 19, 2026, 7, 10), - (1369, 7, 1, 1990, 9, 23), - (1392, 6, 25, 2013, 9, 16), - (1500, 11, 11, 2122, 1, 31), - (1304, 12, 30, 1926, 3, 21), - (1320, 6, 3, 1941, 8, 25), + (1405, 5, 14, 2026, 8, 5), (1416, 10, 30, 2038, 1, 19), + (1500, 11, 11, 2122, 1, 31), # Esfand 29 (non-leap year end) + (1200, 12, 29, 1822, 3, 20), + (1206, 12, 29, 1828, 3, 20), (1210, 12, 29, 1832, 3, 19), (1367, 12, 29, 1989, 3, 20), (1392, 12, 29, 2014, 3, 20), (1394, 12, 29, 2016, 3, 19), + (1396, 12, 29, 2018, 3, 20), (1398, 12, 29, 2020, 3, 19), (1399, 12, 29, 2021, 3, 19), (1400, 12, 29, 2022, 3, 20), @@ -52,22 +59,21 @@ (1405, 12, 29, 2027, 3, 20), (1502, 12, 29, 2124, 3, 19), (1504, 12, 29, 2126, 3, 20), - (1206, 12, 29, 1828, 3, 20), - (1396, 12, 29, 2018, 3, 20), # Esfand 30 (leap year end) (1210, 12, 30, 1832, 3, 20), + (1375, 12, 30, 1997, 3, 20), (1391, 12, 30, 2013, 3, 20), (1395, 12, 30, 2017, 3, 20), (1399, 12, 30, 2021, 3, 20), (1403, 12, 30, 2025, 3, 20), (1408, 12, 30, 2030, 3, 20), - (1375, 12, 30, 1997, 3, 20), (1474, 12, 30, 2096, 3, 19), (1498, 12, 30, 2120, 3, 20), # Gregorian New Year (Jan 1) (1366, 10, 11, 1988, 1, 1), (1378, 10, 11, 2000, 1, 1), (1379, 10, 12, 2001, 1, 1), + (1388, 10, 11, 2010, 1, 1), (1390, 10, 11, 2012, 1, 1), (1391, 10, 12, 2013, 1, 1), (1393, 10, 11, 2015, 1, 1), @@ -77,9 +83,9 @@ (1402, 10, 11, 2024, 1, 1), (1403, 10, 12, 2025, 1, 1), (1405, 10, 11, 2027, 1, 1), - (1379, 10, 11, 2000, 12, 31), # Norouz (Farvardin 1) - # (1, 1, 1, 622, 3, 22), + (1, 1, 1, 622, 3, 22), + (100, 1, 1, 721, 3, 22), (1000, 1, 1, 1621, 3, 21), (1100, 1, 1, 1721, 3, 21), (1206, 1, 1, 1827, 3, 22), @@ -101,8 +107,20 @@ (1497, 1, 1, 2118, 3, 21), (1498, 1, 1, 2119, 3, 21), (1500, 1, 1, 2121, 3, 21), - (1503, 1, 1, 2124, 3, 21), + # 1502 is a non-leap correction year, so Norouz 1503 falls one day + # earlier than the plain 33-year cycle would place it. + (1503, 1, 1, 2124, 3, 20), + (1503, 12, 30, 2125, 3, 20), + (1504, 1, 1, 2125, 3, 21), (1505, 1, 1, 2126, 3, 21), + # Ancient dates follow the astronomical model (epoch 0622-03-22), so + # Gregorian 0623-01-01 is 1-10-10 there -- one day off the plain 33-year + # arithmetic used by jdf-style converters. + (1, 10, 10, 623, 1, 1), + (1, 12, 29, 623, 3, 21), + (2, 1, 1, 623, 3, 22), + (946, 12, 29, 1568, 3, 20), + (947, 1, 1, 1568, 3, 21), # Gregorian century leap-year boundaries (1278, 12, 9, 1900, 2, 28), (1278, 12, 10, 1900, 3, 1), @@ -214,10 +232,10 @@ def test_additions(self): self.assertEqual(JalaliDate(1395, 1, 1).replace(1367), JalaliDate(1367, 1, 1)) self.assertEqual(JalaliDate(1395, 1, 1).replace(month=2), JalaliDate(1395, 2, 1)) self.assertEqual(JalaliDate(1367, 1, 1).replace(year=1396, month=7), JalaliDate(1396, 7, 1)) - self.assertEqual( - JalaliDate(1395, 1, 1, "en").replace(1367, 2, 14, "fa"), - JalaliDate(1367, 2, 14, "en"), - ) + replaced = JalaliDate(1395, 1, 1, "en").replace(1367, 2, 14, "fa") + self.assertEqual(replaced, JalaliDate(1367, 2, 14)) + self.assertEqual(replaced.locale, "fa") + self.assertEqual(JalaliDate(JalaliDate(1400, 1, 1, "fa")).locale, "fa") self.assertEqual(JalaliDate.fromtimestamp(time()), JalaliDate.today()) self.assertEqual(JalaliDate.fromtimestamp(578707200), JalaliDate(1367, 2, 14)) @@ -230,6 +248,10 @@ def test_additions(self): with pytest.raises(ValueError, match="locale must be 'en' or 'fa'"): jdate.replace(locale="de") + # Esfand 30 is invalid when the replacement year is not a leap year + with pytest.raises(ValueError): + JalaliDate(1403, 12, 30).replace(year=1404) + with pytest.raises(ValueError): JalaliDate.days_before_month(0) @@ -569,6 +591,11 @@ def test_arithmetic_operations(self): with pytest.raises(OverflowError): JalaliDate.max + timedelta(days=1) + with pytest.raises(OverflowError): + JalaliDate.min - timedelta(days=1) + + self.assertEqual(JalaliDate.fromordinal(JalaliDate(1403, 1, 1).toordinal()), JalaliDate(1403, 1, 1)) + def test_pickle(self): file = open("save.p", "wb") pickle.dump(JalaliDate(1367, 2, 14), file, protocol=2) @@ -647,13 +674,15 @@ def test_gregorian_leap_day_conversions(self): def test_round_trip_gregorian_windows(self): one_day = timedelta(days=1) windows = [ - (date(1601, 1, 1), date(1601, 12, 31)), + (date(622, 3, 22), date(623, 4, 10)), + (date(1000, 2, 1), date(1000, 4, 10)), + (date(1600, 2, 1), date(1601, 12, 31)), (date(1700, 2, 1), date(1700, 4, 10)), (date(1800, 2, 1), date(1800, 4, 10)), (date(1900, 2, 1), date(1900, 4, 10)), (date(2000, 2, 1), date(2000, 4, 10)), (date(2100, 2, 1), date(2100, 4, 10)), - (date(2123, 3, 1), date(2124, 3, 19)), + (date(2123, 3, 1), date(2125, 4, 2)), ] for start, end in windows: previous_jalali = JalaliDate.to_jalali(start) @@ -666,6 +695,124 @@ def test_round_trip_gregorian_windows(self): previous_jalali = jdate gdate += one_day + def test_non_leap_correction_conversion_consistency(self): + # Regression: conversions must follow is_leap for the years in + # NON_LEAP_CORRECTION_SET, leaving no unrepresentable Gregorian days + # (2124-03-20 used to raise ValueError) and no double-mapped Jalali + # dates (1503-12-30 and 1504-01-01 used to map to the same day). + self.assertEqual(JalaliDate.to_jalali(date(2124, 3, 19)), JalaliDate(1502, 12, 29)) + self.assertEqual(JalaliDate.to_jalali(date(2124, 3, 20)), JalaliDate(1503, 1, 1)) + self.assertEqual(JalaliDate(1503, 12, 30).to_gregorian(), date(2125, 3, 20)) + self.assertEqual(JalaliDate(1504, 1, 1).to_gregorian(), date(2125, 3, 21)) + + for year in sorted(NON_LEAP_CORRECTION_SET): + if year + 2 > MAXYEAR: + continue + norouz = JalaliDate(year, 1, 1).to_gregorian() + next_norouz = JalaliDate(year + 1, 1, 1).to_gregorian() + after_next = JalaliDate(year + 2, 1, 1).to_gregorian() + self.assertEqual((next_norouz - norouz).days, 365, f"correction year {year}") + self.assertEqual((after_next - next_norouz).days, 366, f"successor year {year + 1}") + + def test_year_boundaries_full_range(self): + # Every year's first and last days must convert consistently in both + # directions, and every year length must match is_leap. + one_day = timedelta(days=1) + previous_norouz = JalaliDate(MINYEAR, 1, 1).to_gregorian() + + for year in range(MINYEAR + 1, MAXYEAR + 1): + norouz = JalaliDate(year, 1, 1).to_gregorian() + expected_length = 366 if JalaliDate.is_leap(year - 1) else 365 + self.assertEqual((norouz - previous_norouz).days, expected_length, f"year {year - 1}") + + self.assertEqual(JalaliDate.to_jalali(norouz), JalaliDate(year, 1, 1)) + last_day = 30 if JalaliDate.is_leap(year - 1) else 29 + self.assertEqual(JalaliDate.to_jalali(norouz - one_day), JalaliDate(year - 1, 12, last_day)) + + previous_norouz = norouz + + def test_epoch(self): + # The Solar Hijri epoch is Friday 1 Farvardin 1 = 19 March 622 + # Julian = 22 March 622 proleptic Gregorian. + epoch = JalaliDate(1, 1, 1) + self.assertEqual(epoch.to_gregorian(), date(622, 3, 22)) + self.assertEqual(epoch.weekday(), 6) # Jomeh (Friday) + self.assertEqual(epoch.toordinal(), 1) + self.assertEqual(JalaliDate.fromordinal(1), epoch) + + # the day before the epoch has no Jalali representation + with self.assertRaises(ValueError): + JalaliDate.to_jalali(622, 3, 21) + + def test_ancient_gregorian_to_shamsi(self): + # Regression: Gregorian 0623-01-01 is 1-10-10 under the astronomical + # model (as reported against time.ir), not the 33-year arithmetic + # 1-10-11. + self.assertEqual(JalaliDate.to_jalali(623, 1, 1), JalaliDate(1, 10, 10)) + self.assertEqual(JalaliDate(1, 10, 10).to_gregorian(), date(623, 1, 1)) + + def test_ancient_astronomical_norouz(self): + # Years 1..1177 follow the astronomical Persian calendar + # (Calendrical Calculations at the 52.5 E meridian, the model that + # reproduces the official 1206-1498 leap-year table exactly). + # Expected values generated with + # https://github.com/roozbehp/persiancalendar. Years 979, 1012, + # 1045, 1078 and 1177 keep the 33-year-rule value, where the + # astronomical flip rests on a minutes-level equinox margin and + # established implementations agree on the arithmetic date. + cases = [ + (1, 622, 3, 22), + (2, 623, 3, 22), + (21, 642, 3, 21), + (22, 643, 3, 22), + (50, 671, 3, 21), + (101, 722, 3, 22), + (201, 822, 3, 21), + (250, 871, 3, 21), + (301, 922, 3, 21), + (401, 1022, 3, 21), + (450, 1071, 3, 21), + (501, 1122, 3, 22), + (601, 1222, 3, 21), + (650, 1271, 3, 21), + (701, 1322, 3, 21), + (801, 1422, 3, 21), + (850, 1471, 3, 21), + (901, 1522, 3, 22), + (945, 1566, 3, 21), + (946, 1567, 3, 22), + (947, 1568, 3, 21), + (978, 1599, 3, 21), + (979, 1600, 3, 20), + (1001, 1622, 3, 21), + (1050, 1671, 3, 21), + (1077, 1698, 3, 20), + (1078, 1699, 3, 20), + (1101, 1722, 3, 21), + (1175, 1796, 3, 20), + (1176, 1797, 3, 20), + (1177, 1798, 3, 20), + (1178, 1799, 3, 21), + ] + for jy, gy, gm, gd in cases: + self.assertEqual(JalaliDate(jy, 1, 1).to_gregorian(), date(gy, gm, gd), f"Norouz {jy}") + self.assertEqual(JalaliDate.to_jalali(date(gy, gm, gd)), JalaliDate(jy, 1, 1), f"Norouz {jy}") + + def test_ancient_leap_years(self): + # is_leap follows the astronomical model for years 1..1177, encoded + # as flips against the 33-year rule. + self.assertFalse(JalaliDate.is_leap(1)) # 33-year rule says leap + self.assertTrue(JalaliDate.is_leap(21)) # astronomical-only leap + self.assertFalse(JalaliDate.is_leap(22)) # 33-year-rule-only leap + self.assertTrue(JalaliDate.is_leap(945)) + self.assertFalse(JalaliDate.is_leap(946)) + + # borderline pairs overridden to the consensus 33-year values + self.assertFalse(JalaliDate.is_leap(978)) + self.assertTrue(JalaliDate.is_leap(979)) + self.assertFalse(JalaliDate.is_leap(1176)) + self.assertTrue(JalaliDate.is_leap(1177)) + def test_string_representation(self): self.assertEqual(str(JalaliDate(1403, 4, 7)), "1403-04-07") self.assertEqual(repr(JalaliDate(1403, 4, 7)), "JalaliDate(1403, 4, 7, Panjshanbeh)") diff --git a/tests/test_jalalidatetime.py b/tests/test_jalalidatetime.py index 58bd271..9321cce 100644 --- a/tests/test_jalalidatetime.py +++ b/tests/test_jalalidatetime.py @@ -47,6 +47,14 @@ def test_base(self): self.assertEqual(JalaliDateTime(aware_source).tzinfo, tehran_tz) self.assertEqual(JalaliDateTime(aware_source, tzinfo=timezone.utc).tzinfo, timezone.utc) + fa_source = JalaliDateTime(1400, 1, 1, 12, 0, locale="fa") + self.assertEqual(JalaliDateTime(fa_source).locale, "fa") + self.assertEqual(JalaliDateTime(JalaliDate(1400, 1, 1, "fa")).locale, "fa") + self.assertEqual( + JalaliDateTime(1367, 2, 14, 4, 30, 4, 4444).jalali_date(), + JalaliDate(1367, 2, 14), + ) + g = JalaliDateTime.now() self.assertEqual(g.time(), _time(g.hour, g.minute, g.second, g.microsecond)) @@ -554,6 +562,13 @@ def test_combine(self): self.assertEqual(combined.minute, 30) self.assertEqual(combined.second, 1) + aware = JalaliDateTime.combine( + JalaliDate(1400, 1, 1), + _time(23, 30, tzinfo=timezone.utc, fold=1), + ) + self.assertEqual(aware.tzinfo, timezone.utc) + self.assertEqual(aware.fold, 1) + with self.assertRaises(TypeError): JalaliDateTime.combine("InvalidDate", _time(12, 30, 45)) @@ -698,6 +713,18 @@ def test_to_jalali_with_timezone(self): jdate = JalaliDateTime.to_jalali(dt) self.assertEqual(jdate.tzinfo, timezone.utc) + def test_to_jalali_argument_forms(self): + expected = JalaliDateTime(1403, 1, 1, 15, 30, 45, 123, timezone.utc) + self.assertEqual( + JalaliDateTime.to_jalali(2024, 3, 20, 15, 30, 45, 123, timezone.utc), + expected, + ) + self.assertEqual(JalaliDateTime.to_jalali(2024, 3, 20), JalaliDateTime(1403, 1, 1)) + self.assertEqual( + JalaliDateTime.to_jalali(datetime(2024, 3, 20, 15, 30, 45, 123, tzinfo=timezone.utc)), + expected, + ) + def test_strftime_basic(self): jdate = JalaliDateTime(1400, 1, 1, 15, 30, 45) self.assertEqual(jdate.strftime("%Y-%m-%d %H:%M:%S"), "1400-01-01 15:30:45") @@ -1013,6 +1040,12 @@ def test_add_overflow(self): with pytest.raises(OverflowError): JalaliDateTime.max + timedelta(days=1) + with pytest.raises(OverflowError): + JalaliDateTime.min - timedelta(days=1) + + aware = JalaliDateTime(1403, 1, 1, 12, 0, tzinfo=timezone.utc) + self.assertEqual((aware + timedelta(hours=1)).tzinfo, timezone.utc) + def test_subtract_equal_offset_different_tzinfo(self): fixed = JalaliDateTime(1404, 5, 1, 12, 0, tzinfo=timezone(timedelta(hours=3, minutes=30))) zoned = JalaliDateTime(1404, 5, 1, 10, 0, tzinfo=ZoneInfo("Asia/Tehran")) diff --git a/tests/test_official_kabise.py b/tests/test_official_kabise.py new file mode 100644 index 0000000..db60449 --- /dev/null +++ b/tests/test_official_kabise.py @@ -0,0 +1,381 @@ +"""Validation against the official Iranian calendar authority. + +The table below is the leap-year (kabiseh) data published by the Calendar +Center of the Institute of Geophysics, University of Tehran -- the body that +determines the official Iranian calendar: +https://calendar.ut.ac.ir/documents/2139738/7092644/Kabise+Shamsi+1206-1498.pdf + +Each row gives a Jalali year, whether it is a leap year (marked with * for +four-year and ** for five-year leap intervals in the original document), and +the Gregorian date of 1 Farvardin of that year. The plain-text transcription +of the PDF is dedicated to the public domain (CC0 1.0). +""" + +from datetime import date, timedelta +from unittest import TestCase + +from persiantools.jdatetime import JalaliDate + +# (jalali_year, is_leap, gregorian_year, gregorian_month, gregorian_day of 1 Farvardin) +_OFFICIAL_KABISE = [ + (1206, False, 1827, 3, 22), + (1207, False, 1828, 3, 21), + (1208, False, 1829, 3, 21), + (1209, False, 1830, 3, 21), + (1210, True, 1831, 3, 21), + (1211, False, 1832, 3, 21), + (1212, False, 1833, 3, 21), + (1213, False, 1834, 3, 21), + (1214, True, 1835, 3, 21), + (1215, False, 1836, 3, 21), + (1216, False, 1837, 3, 21), + (1217, False, 1838, 3, 21), + (1218, True, 1839, 3, 21), + (1219, False, 1840, 3, 21), + (1220, False, 1841, 3, 21), + (1221, False, 1842, 3, 21), + (1222, True, 1843, 3, 21), + (1223, False, 1844, 3, 21), + (1224, False, 1845, 3, 21), + (1225, False, 1846, 3, 21), + (1226, True, 1847, 3, 21), + (1227, False, 1848, 3, 21), + (1228, False, 1849, 3, 21), + (1229, False, 1850, 3, 21), + (1230, True, 1851, 3, 21), + (1231, False, 1852, 3, 21), + (1232, False, 1853, 3, 21), + (1233, False, 1854, 3, 21), + (1234, True, 1855, 3, 21), + (1235, False, 1856, 3, 21), + (1236, False, 1857, 3, 21), + (1237, False, 1858, 3, 21), + (1238, True, 1859, 3, 21), + (1239, False, 1860, 3, 21), + (1240, False, 1861, 3, 21), + (1241, False, 1862, 3, 21), + (1242, False, 1863, 3, 21), + (1243, True, 1864, 3, 20), + (1244, False, 1865, 3, 21), + (1245, False, 1866, 3, 21), + (1246, False, 1867, 3, 21), + (1247, True, 1868, 3, 20), + (1248, False, 1869, 3, 21), + (1249, False, 1870, 3, 21), + (1250, False, 1871, 3, 21), + (1251, True, 1872, 3, 20), + (1252, False, 1873, 3, 21), + (1253, False, 1874, 3, 21), + (1254, False, 1875, 3, 21), + (1255, True, 1876, 3, 20), + (1256, False, 1877, 3, 21), + (1257, False, 1878, 3, 21), + (1258, False, 1879, 3, 21), + (1259, True, 1880, 3, 20), + (1260, False, 1881, 3, 21), + (1261, False, 1882, 3, 21), + (1262, False, 1883, 3, 21), + (1263, True, 1884, 3, 20), + (1264, False, 1885, 3, 21), + (1265, False, 1886, 3, 21), + (1266, False, 1887, 3, 21), + (1267, True, 1888, 3, 20), + (1268, False, 1889, 3, 21), + (1269, False, 1890, 3, 21), + (1270, False, 1891, 3, 21), + (1271, True, 1892, 3, 20), + (1272, False, 1893, 3, 21), + (1273, False, 1894, 3, 21), + (1274, False, 1895, 3, 21), + (1275, False, 1896, 3, 20), + (1276, True, 1897, 3, 20), + (1277, False, 1898, 3, 21), + (1278, False, 1899, 3, 21), + (1279, False, 1900, 3, 21), + (1280, True, 1901, 3, 21), + (1281, False, 1902, 3, 22), + (1282, False, 1903, 3, 22), + (1283, False, 1904, 3, 21), + (1284, True, 1905, 3, 21), + (1285, False, 1906, 3, 22), + (1286, False, 1907, 3, 22), + (1287, False, 1908, 3, 21), + (1288, True, 1909, 3, 21), + (1289, False, 1910, 3, 22), + (1290, False, 1911, 3, 22), + (1291, False, 1912, 3, 21), + (1292, True, 1913, 3, 21), + (1293, False, 1914, 3, 22), + (1294, False, 1915, 3, 22), + (1295, False, 1916, 3, 21), + (1296, True, 1917, 3, 21), + (1297, False, 1918, 3, 22), + (1298, False, 1919, 3, 22), + (1299, False, 1920, 3, 21), + (1300, True, 1921, 3, 21), + (1301, False, 1922, 3, 22), + (1302, False, 1923, 3, 22), + (1303, False, 1924, 3, 21), + (1304, True, 1925, 3, 21), + (1305, False, 1926, 3, 22), + (1306, False, 1927, 3, 22), + (1307, False, 1928, 3, 21), + (1308, False, 1929, 3, 21), + (1309, True, 1930, 3, 21), + (1310, False, 1931, 3, 22), + (1311, False, 1932, 3, 21), + (1312, False, 1933, 3, 21), + (1313, True, 1934, 3, 21), + (1314, False, 1935, 3, 22), + (1315, False, 1936, 3, 21), + (1316, False, 1937, 3, 21), + (1317, True, 1938, 3, 21), + (1318, False, 1939, 3, 22), + (1319, False, 1940, 3, 21), + (1320, False, 1941, 3, 21), + (1321, True, 1942, 3, 21), + (1322, False, 1943, 3, 22), + (1323, False, 1944, 3, 21), + (1324, False, 1945, 3, 21), + (1325, True, 1946, 3, 21), + (1326, False, 1947, 3, 22), + (1327, False, 1948, 3, 21), + (1328, False, 1949, 3, 21), + (1329, True, 1950, 3, 21), + (1330, False, 1951, 3, 22), + (1331, False, 1952, 3, 21), + (1332, False, 1953, 3, 21), + (1333, True, 1954, 3, 21), + (1334, False, 1955, 3, 22), + (1335, False, 1956, 3, 21), + (1336, False, 1957, 3, 21), + (1337, True, 1958, 3, 21), + (1338, False, 1959, 3, 22), + (1339, False, 1960, 3, 21), + (1340, False, 1961, 3, 21), + (1341, False, 1962, 3, 21), + (1342, True, 1963, 3, 21), + (1343, False, 1964, 3, 21), + (1344, False, 1965, 3, 21), + (1345, False, 1966, 3, 21), + (1346, True, 1967, 3, 21), + (1347, False, 1968, 3, 21), + (1348, False, 1969, 3, 21), + (1349, False, 1970, 3, 21), + (1350, True, 1971, 3, 21), + (1351, False, 1972, 3, 21), + (1352, False, 1973, 3, 21), + (1353, False, 1974, 3, 21), + (1354, True, 1975, 3, 21), + (1355, False, 1976, 3, 21), + (1356, False, 1977, 3, 21), + (1357, False, 1978, 3, 21), + (1358, True, 1979, 3, 21), + (1359, False, 1980, 3, 21), + (1360, False, 1981, 3, 21), + (1361, False, 1982, 3, 21), + (1362, True, 1983, 3, 21), + (1363, False, 1984, 3, 21), + (1364, False, 1985, 3, 21), + (1365, False, 1986, 3, 21), + (1366, True, 1987, 3, 21), + (1367, False, 1988, 3, 21), + (1368, False, 1989, 3, 21), + (1369, False, 1990, 3, 21), + (1370, True, 1991, 3, 21), + (1371, False, 1992, 3, 21), + (1372, False, 1993, 3, 21), + (1373, False, 1994, 3, 21), + (1374, False, 1995, 3, 21), + (1375, True, 1996, 3, 20), + (1376, False, 1997, 3, 21), + (1377, False, 1998, 3, 21), + (1378, False, 1999, 3, 21), + (1379, True, 2000, 3, 20), + (1380, False, 2001, 3, 21), + (1381, False, 2002, 3, 21), + (1382, False, 2003, 3, 21), + (1383, True, 2004, 3, 20), + (1384, False, 2005, 3, 21), + (1385, False, 2006, 3, 21), + (1386, False, 2007, 3, 21), + (1387, True, 2008, 3, 20), + (1388, False, 2009, 3, 21), + (1389, False, 2010, 3, 21), + (1390, False, 2011, 3, 21), + (1391, True, 2012, 3, 20), + (1392, False, 2013, 3, 21), + (1393, False, 2014, 3, 21), + (1394, False, 2015, 3, 21), + (1395, True, 2016, 3, 20), + (1396, False, 2017, 3, 21), + (1397, False, 2018, 3, 21), + (1398, False, 2019, 3, 21), + (1399, True, 2020, 3, 20), + (1400, False, 2021, 3, 21), + (1401, False, 2022, 3, 21), + (1402, False, 2023, 3, 21), + (1403, True, 2024, 3, 20), + (1404, False, 2025, 3, 21), + (1405, False, 2026, 3, 21), + (1406, False, 2027, 3, 21), + (1407, False, 2028, 3, 20), + (1408, True, 2029, 3, 20), + (1409, False, 2030, 3, 21), + (1410, False, 2031, 3, 21), + (1411, False, 2032, 3, 20), + (1412, True, 2033, 3, 20), + (1413, False, 2034, 3, 21), + (1414, False, 2035, 3, 21), + (1415, False, 2036, 3, 20), + (1416, True, 2037, 3, 20), + (1417, False, 2038, 3, 21), + (1418, False, 2039, 3, 21), + (1419, False, 2040, 3, 20), + (1420, True, 2041, 3, 20), + (1421, False, 2042, 3, 21), + (1422, False, 2043, 3, 21), + (1423, False, 2044, 3, 20), + (1424, True, 2045, 3, 20), + (1425, False, 2046, 3, 21), + (1426, False, 2047, 3, 21), + (1427, False, 2048, 3, 20), + (1428, True, 2049, 3, 20), + (1429, False, 2050, 3, 21), + (1430, False, 2051, 3, 21), + (1431, False, 2052, 3, 20), + (1432, True, 2053, 3, 20), + (1433, False, 2054, 3, 21), + (1434, False, 2055, 3, 21), + (1435, False, 2056, 3, 20), + (1436, True, 2057, 3, 20), + (1437, False, 2058, 3, 21), + (1438, False, 2059, 3, 21), + (1439, False, 2060, 3, 20), + (1440, False, 2061, 3, 20), + (1441, True, 2062, 3, 20), + (1442, False, 2063, 3, 21), + (1443, False, 2064, 3, 20), + (1444, False, 2065, 3, 20), + (1445, True, 2066, 3, 20), + (1446, False, 2067, 3, 21), + (1447, False, 2068, 3, 20), + (1448, False, 2069, 3, 20), + (1449, True, 2070, 3, 20), + (1450, False, 2071, 3, 21), + (1451, False, 2072, 3, 20), + (1452, False, 2073, 3, 20), + (1453, True, 2074, 3, 20), + (1454, False, 2075, 3, 21), + (1455, False, 2076, 3, 20), + (1456, False, 2077, 3, 20), + (1457, True, 2078, 3, 20), + (1458, False, 2079, 3, 21), + (1459, False, 2080, 3, 20), + (1460, False, 2081, 3, 20), + (1461, True, 2082, 3, 20), + (1462, False, 2083, 3, 21), + (1463, False, 2084, 3, 20), + (1464, False, 2085, 3, 20), + (1465, True, 2086, 3, 20), + (1466, False, 2087, 3, 21), + (1467, False, 2088, 3, 20), + (1468, False, 2089, 3, 20), + (1469, True, 2090, 3, 20), + (1470, False, 2091, 3, 21), + (1471, False, 2092, 3, 20), + (1472, False, 2093, 3, 20), + (1473, False, 2094, 3, 20), + (1474, True, 2095, 3, 20), + (1475, False, 2096, 3, 20), + (1476, False, 2097, 3, 20), + (1477, False, 2098, 3, 20), + (1478, True, 2099, 3, 20), + (1479, False, 2100, 3, 21), + (1480, False, 2101, 3, 21), + (1481, False, 2102, 3, 21), + (1482, True, 2103, 3, 21), + (1483, False, 2104, 3, 21), + (1484, False, 2105, 3, 21), + (1485, False, 2106, 3, 21), + (1486, True, 2107, 3, 21), + (1487, False, 2108, 3, 21), + (1488, False, 2109, 3, 21), + (1489, False, 2110, 3, 21), + (1490, True, 2111, 3, 21), + (1491, False, 2112, 3, 21), + (1492, False, 2113, 3, 21), + (1493, False, 2114, 3, 21), + (1494, True, 2115, 3, 21), + (1495, False, 2116, 3, 21), + (1496, False, 2117, 3, 21), + (1497, False, 2118, 3, 21), + (1498, True, 2119, 3, 21), +] + + +class OfficialKabiseTestCase(TestCase): + def test_table_integrity(self): + # The transcription must cover 1206..1498 contiguously, and a starred + # year must be exactly one whose next Norouz is 366 days later. + self.assertEqual(len(_OFFICIAL_KABISE), 293) + self.assertEqual(_OFFICIAL_KABISE[0][0], 1206) + self.assertEqual(_OFFICIAL_KABISE[-1][0], 1498) + + for (year, leap, *norouz), (next_year, _, *next_norouz) in zip(_OFFICIAL_KABISE, _OFFICIAL_KABISE[1:]): + self.assertEqual(next_year, year + 1) + year_length = (date(*next_norouz) - date(*norouz)).days + self.assertEqual(year_length, 366 if leap else 365, f"year {year}") + + def test_norouz_to_gregorian(self): + for year, _, gy, gm, gd in _OFFICIAL_KABISE: + self.assertEqual(JalaliDate(year, 1, 1).to_gregorian(), date(gy, gm, gd), f"1 Farvardin {year}") + + def test_norouz_from_gregorian(self): + for year, _, gy, gm, gd in _OFFICIAL_KABISE: + self.assertEqual(JalaliDate.to_jalali(date(gy, gm, gd)), JalaliDate(year, 1, 1), f"{gy}-{gm:02d}-{gd:02d}") + + def test_is_leap_matches_official(self): + for year, leap, *_ in _OFFICIAL_KABISE: + self.assertEqual(JalaliDate.is_leap(year), leap, f"year {year}") + + def test_esfand_length_matches_official(self): + for year, leap, *_ in _OFFICIAL_KABISE: + self.assertEqual(JalaliDate.days_in_month(12, year), 30 if leap else 29, f"Esfand {year}") + + def test_year_boundaries(self): + # The day before each official Norouz must be the last day of Esfand of + # the previous year, in both conversion directions. + one_day = timedelta(days=1) + + for (prev_year, prev_leap, *_), (year, _, gy, gm, gd) in zip(_OFFICIAL_KABISE, _OFFICIAL_KABISE[1:]): + eve = date(gy, gm, gd) - one_day + last_esfand_day = 30 if prev_leap else 29 + + self.assertEqual(JalaliDate.to_jalali(eve), JalaliDate(prev_year, 12, last_esfand_day), f"eve of {year}") + self.assertEqual(JalaliDate(prev_year, 12, last_esfand_day).to_gregorian(), eve, f"end of {prev_year}") + + def test_round_trip_full_official_range(self): + # Walk every Gregorian day covered by the official table (1 Farvardin + # 1206 through the last day of 1498) and require an exact round trip + # advancing one Jalali day at a time. + first_year = _OFFICIAL_KABISE[0] + last_year = _OFFICIAL_KABISE[-1] + + start = date(*first_year[2:]) + end = date(*last_year[2:]) + timedelta(days=(366 if last_year[1] else 365) - 1) + + previous = JalaliDate.to_jalali(start) + self.assertEqual(previous, JalaliDate(first_year[0], 1, 1)) + self.assertEqual(previous.to_gregorian(), start) + + one_day = timedelta(days=1) + gdate = start + one_day + while gdate <= end: + jdate = JalaliDate.to_jalali(gdate) + self.assertEqual(jdate - previous, one_day, gdate) + self.assertEqual(jdate.to_gregorian(), gdate, gdate) + previous = jdate + gdate += one_day + + self.assertEqual(previous, JalaliDate(last_year[0], 12, 30))