From 48c4c99fa2f3fa7014e6213b99cc00014ed6a33b Mon Sep 17 00:00:00 2001 From: Majid Date: Thu, 16 Jul 2026 03:08:36 +0330 Subject: [PATCH 01/11] test: validate conversions against official Iranian calendar authority data Add table-driven tests based on the leap-year table (Kabise Shamsi 1206-1498) published by the Calendar Center, Institute of Geophysics, University of Tehran. Covers Norouz dates in both conversion directions, is_leap and Esfand length for all 293 years, year-boundary conversions, and a day-by-day round trip across the entire official range. Co-Authored-By: Claude Fable 5 --- tests/test_official_kabise.py | 381 ++++++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 tests/test_official_kabise.py 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)) From b899de16c7740be49722164e7556ac7502a97225 Mon Sep 17 00:00:00 2001 From: Majid Date: Thu, 16 Jul 2026 03:28:06 +0330 Subject: [PATCH 02/11] fix: derive Jalali conversions from is_leap for full-range consistency to_jalali, to_gregorian, and toordinal now share a single _days_before_year helper that is exactly consistent with is_leap (33-year cycle plus the non-leap correction years), instead of two independent arithmetic paths. This fixes: - to_jalali producing off-by-one dates for ~185k days and raising ValueError for 237 days in Gregorian 622-1600, because the old epoch-shift branch did not start on a 33-year cycle boundary - one unrepresentable Gregorian day (ValueError) and one double-mapped Jalali date per non-leap correction year (first: 2124-03-20 and 1503-12-30), where conversions ignored the corrections is_leap applies Verified: exhaustive scan of all 3,424,879 supported days round-trips with no exceptions and one-day monotonic steps; conversions now match the astronomical Persian calendar (Calendrical Calculations at 52.5E, roozbehp/persiancalendar) for every year 1178-3000 and remain identical to the official University of Tehran table for 1206-1498. Behavior is unchanged for Jalali years 980-1501. Co-Authored-By: Claude Fable 5 --- persiantools/jdatetime.py | 141 ++++++++++++++------------------------ tests/test_jalalidate.py | 50 ++++++++++++-- 2 files changed, 96 insertions(+), 95 deletions(-) diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index 76301db..d62d3e2 100644 --- a/persiantools/jdatetime.py +++ b/persiantools/jdatetime.py @@ -1,7 +1,6 @@ import operator import re import warnings -from bisect import bisect_left from collections import namedtuple from datetime import date from datetime import datetime as dt @@ -134,16 +133,6 @@ _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) - -# 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) - # List of years that are exceptions to the 33-year leap year rule # fmt: off NON_LEAP_CORRECTION_SET = frozenset( @@ -161,6 +150,36 @@ MIN_NON_LEAP_CORRECTION = 1502 +# Proleptic Gregorian ordinal of the day before 1 Farvardin 1 (which is +# 0622-03-21), so that Jalali ordinals start at 1 like date.toordinal(). +_EPOCH_ORDINAL = 226894 + +# 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 + + +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, 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 - 1) in NON_LEAP_CORRECTION_SET: + days -= 1 + + return days + def _is_ascii_digit(c: str) -> bool: return c in "0123456789" @@ -446,47 +465,28 @@ 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] - ) + # Days elapsed since the Jalali epoch; 1 Farvardin 1 is day 1. + 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 + # 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. + 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 - # 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 + day_of_year = days - _days_before_year(jalali_year) # 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 + if day_of_year <= 186: + jalali_month = 1 + (day_of_year - 1) // 31 + jalali_day = 1 + (day_of_year - 1) % 31 else: - days -= 186 - jalali_month = 7 + days // 30 - jalali_day = 1 + days % 30 + jalali_month = 7 + (day_of_year - 187) // 30 + jalali_day = 1 + (day_of_year - 187) % 30 return cls(jalali_year, jalali_month, jalali_day) @@ -506,48 +506,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 +570,11 @@ def isoformat(self) -> str: __str__ = isoformat def toordinal(self) -> int: - return self.to_gregorian().toordinal() - 226894 + return _days_before_year(self._year) + _MONTH_COUNT[self._month][2] + self._day @classmethod def fromordinal(cls, n: int): - return cls(date.fromordinal(n + 226894)) + return cls(date.fromordinal(n + _EPOCH_ORDINAL)) @classmethod def fromisocalendar(cls, year, week, day): diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index 3f755ee..2773ef4 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -6,7 +6,7 @@ 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 = [ @@ -101,7 +101,11 @@ (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), # Gregorian century leap-year boundaries (1278, 12, 9, 1900, 2, 28), @@ -647,13 +651,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, 21), 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 +672,42 @@ 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_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)") From 3b3520919c5eb3c9a4f77e7692e6e310bfbe8381 Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 02:28:49 +0330 Subject: [PATCH 03/11] fix: adopt the astronomical calendar model for ancient Jalali years Correct the epoch to Friday 22 March 622 CE (proleptic Gregorian) and derive leap years 1..1177 from the astronomical Persian calendar (Calendrical Calculations at the 52.5E meridian), keeping the consensus 33-year values for five borderline flip pairs so conversions are unchanged for every day on or after Gregorian 1568-03-21. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 5 +++ README.md | 9 ++++ persiantools/jdatetime.py | 69 +++++++++++++++++++++++++--- tests/test_jalalidate.py | 94 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 169 insertions(+), 8 deletions(-) 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/README.md b/README.md index 8073cc6..e67cdec 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,15 @@ True They are also hashable (usable as dict keys) and picklable, like the standard library types. +## Calendar model + +Supported range: `JalaliDate(1, 1, 1)` (Gregorian 622-03-22) through `JalaliDate(9377, 12, 30)` (Gregorian 9999-03-20). All conversions, ordinals, and leap-year checks derive from one internal model, so every date round-trips exactly. + +- **Epoch** — 1 Farvardin of year 1 is Friday, 22 March 622 CE in the proleptic Gregorian calendar (19 March 622 Julian), the documented epoch of the Solar Hijri calendar. +- **Years 1–1177** — leap years follow the astronomical Persian calendar (vernal equinox at the 52.5°E meridian, per *Calendrical Calculations*), computed with [roozbehp/persiancalendar](https://github.com/roozbehp/persiancalendar). Converters based on plain 33-year arithmetic (jdf and derivatives) differ by one day on some dates before Gregorian 1568; purely astronomical converters may differ by one day in a handful of years (979, 1012, 1045, 1078, 1177) where the equinox falls within minutes of the midday cutoff and persiantools keeps the widely agreed date. +- **Years 1178–2987** — the 33-year cycle with the [ICU4X correction set](https://github.com/unicode-org/icu4x/blob/main/utils/calendrical_calculations/src/persian.rs), which reproduces the official leap-year table of the Iranian calendar authority (Calendar Center, Institute of Geophysics, University of Tehran, 1206–1498) exactly and matches the astronomical model through year 3000. +- **Years 2988 and later** — the plain 33-year cycle, as a well-defined convention beyond the range where astronomical projections are meaningful. + ## Digits Convert digits between English, Persian, and Arabic: diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index d62d3e2..b3e810d 100644 --- a/persiantools/jdatetime.py +++ b/persiantools/jdatetime.py @@ -1,6 +1,7 @@ import operator import re import warnings +from bisect import bisect_left from collections import namedtuple from datetime import date from datetime import datetime as dt @@ -150,9 +151,13 @@ MIN_NON_LEAP_CORRECTION = 1502 -# Proleptic Gregorian ordinal of the day before 1 Farvardin 1 (which is -# 0622-03-21), so that Jalali ordinals start at 1 like date.toordinal(). -_EPOCH_ORDINAL = 226894 +# 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 @@ -164,17 +169,62 @@ ) # 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, and each correction year (never leap, but always followed by + 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 + else: + days += bisect_left(_ANCIENT_FLIPS_ON, year) - bisect_left(_ANCIENT_FLIPS_OFF, year) + if (year - 1) in NON_LEAP_CORRECTION_SET: days -= 1 @@ -370,7 +420,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: @@ -382,6 +436,9 @@ def is_leap(year: int) -> bool: if not (MINYEAR <= year <= MAXYEAR): raise ValueError(f"Year must be between {MINYEAR} and {MAXYEAR}") + if year <= _ANCIENT_MAX: + return ((25 * year + 11) % 33 < 8) != (year in _ANCIENT_LEAP_FLIPS_SET) + if year < MIN_NON_LEAP_CORRECTION: return (25 * year + 11) % 33 < 8 @@ -779,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): diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index 2773ef4..e3b3a81 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -79,7 +79,7 @@ (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), (1000, 1, 1, 1621, 3, 21), (1100, 1, 1, 1721, 3, 21), (1206, 1, 1, 1827, 3, 22), @@ -107,6 +107,14 @@ (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), @@ -651,7 +659,7 @@ def test_gregorian_leap_day_conversions(self): def test_round_trip_gregorian_windows(self): one_day = timedelta(days=1) windows = [ - (date(622, 3, 21), date(623, 4, 10)), + (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)), @@ -708,6 +716,88 @@ def test_year_boundaries_full_range(self): 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)") From 42d97b57663b780c885d8c55735b60d19f819ef5 Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 03:27:36 +0330 Subject: [PATCH 04/11] perf: speed up JalaliDate hot paths and tidy calendar docs Avoid rebuilding dates during comparisons and format only the strftime directives in use; drop restating comments and clarify the calendar-model notes. --- README.md | 10 +- persiantools/jdatetime.py | 498 +++++++++++++++++++++----------------- tests/test_jalalidate.py | 20 +- 3 files changed, 295 insertions(+), 233 deletions(-) diff --git a/README.md b/README.md index e67cdec..255a5c1 100644 --- a/README.md +++ b/README.md @@ -149,12 +149,12 @@ They are also hashable (usable as dict keys) and picklable, like the standard li ## Calendar model -Supported range: `JalaliDate(1, 1, 1)` (Gregorian 622-03-22) through `JalaliDate(9377, 12, 30)` (Gregorian 9999-03-20). All conversions, ordinals, and leap-year checks derive from one internal model, so every date round-trips exactly. +Supported range: `JalaliDate(1, 1, 1)` (Gregorian 622-03-22) through `JalaliDate(9377, 12, 30)` (Gregorian 9999-03-20). All conversions, ordinals, and leap-year checks share one internal model, so every date round-trips exactly. -- **Epoch** — 1 Farvardin of year 1 is Friday, 22 March 622 CE in the proleptic Gregorian calendar (19 March 622 Julian), the documented epoch of the Solar Hijri calendar. -- **Years 1–1177** — leap years follow the astronomical Persian calendar (vernal equinox at the 52.5°E meridian, per *Calendrical Calculations*), computed with [roozbehp/persiancalendar](https://github.com/roozbehp/persiancalendar). Converters based on plain 33-year arithmetic (jdf and derivatives) differ by one day on some dates before Gregorian 1568; purely astronomical converters may differ by one day in a handful of years (979, 1012, 1045, 1078, 1177) where the equinox falls within minutes of the midday cutoff and persiantools keeps the widely agreed date. -- **Years 1178–2987** — the 33-year cycle with the [ICU4X correction set](https://github.com/unicode-org/icu4x/blob/main/utils/calendrical_calculations/src/persian.rs), which reproduces the official leap-year table of the Iranian calendar authority (Calendar Center, Institute of Geophysics, University of Tehran, 1206–1498) exactly and matches the astronomical model through year 3000. -- **Years 2988 and later** — the plain 33-year cycle, as a well-defined convention beyond the range where astronomical projections are meaningful. +- **Epoch** — 1 Farvardin of year 1 is Friday, 22 March 622 CE (proleptic Gregorian; 19 March 622 Julian), the standard Solar Hijri epoch. +- **Years 1–1177** — leap years follow the astronomical Persian calendar (vernal equinox at the 52.5°E meridian, per *Calendrical Calculations*), using data from [roozbehp/persiancalendar](https://github.com/roozbehp/persiancalendar). In five borderline years (979, 1012, 1045, 1078, 1177), where the equinox falls within minutes of the midday cutoff, persiantools keeps the widely agreed leap status rather than the pure astronomical flip. +- **Years 1178–2987** — the 33-year leap cycle plus the [ICU4X correction set](https://github.com/unicode-org/icu4x/blob/main/utils/calendrical_calculations/src/persian.rs). This matches the official table of the Iranian calendar authority (Calendar Center, Institute of Geophysics, University of Tehran) for 1206–1498 and agrees with the astronomical model through year 3000. +- **Years 2988 and later** — the plain 33-year cycle, used as a fixed convention beyond the range where astronomical projections are meaningful. ## Digits diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index b3e810d..5862e18 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,40 +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] -# List of years that are exceptions to the 33-year leap year rule +_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") + +_STRFTIME_DIRECTIVE_RE = re.compile(r"%(:z|.)") + +# Years that are exceptions to the 33-year leap year rule. # fmt: off NON_LEAP_CORRECTION_SET = frozenset( [ @@ -222,12 +213,12 @@ def _days_before_year(year: int) -> int: 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) - if (year - 1) in NON_LEAP_CORRECTION_SET: - days -= 1 - return days @@ -235,7 +226,33 @@ 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"]) @@ -250,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"] @@ -287,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) @@ -385,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 @@ -436,19 +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 <= _ANCIENT_MAX: - return ((25 * year + 11) % 33 < 8) != (year in _ANCIENT_LEAP_FLIPS_SET) - - 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: @@ -469,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: @@ -490,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): @@ -522,28 +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 - # Days elapsed since the Jalali epoch; 1 Farvardin 1 is day 1. - days = date(year, month, day).toordinal() - _EPOCH_ORDINAL - - # 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. - 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 cls._from_days(date(year, month, day).toordinal() - _EPOCH_ORDINAL) + + @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) @@ -627,11 +627,11 @@ def isoformat(self) -> str: __str__ = isoformat def toordinal(self) -> int: - return _days_before_year(self._year) + _MONTH_COUNT[self._month][2] + self._day + return _days_before_year(self._year) + _DAYS_BEFORE_MONTH[self._month] + self._day @classmethod def fromordinal(cls, n: int): - return cls(date.fromordinal(n + _EPOCH_ORDINAL)) + return cls._from_days(n) @classmethod def fromisocalendar(cls, year, week, day): @@ -864,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) @@ -919,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"}) @@ -960,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) @@ -970,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 0 if (y, m, d) == (y2, m2, d2) else 1 if (y, m, d) > (y2, m2, d2) else -1 + return (t1 > t2) - (t1 < t2) + + 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 @@ -987,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 @@ -995,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 @@ -1003,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 @@ -1011,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 @@ -1019,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 @@ -1049,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) @@ -1057,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": @@ -1112,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.") @@ -1181,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"] @@ -1201,8 +1249,6 @@ def __init__( *, fold=0, ): - # Pickle support - if isinstance(year, JalaliDateTime) and month is None: month = year.month day = year.day @@ -1216,9 +1262,7 @@ def __init__( 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 @@ -1228,7 +1272,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) @@ -1444,7 +1488,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] @@ -1707,9 +1750,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 @@ -1719,24 +1764,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: @@ -1775,12 +1822,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: @@ -1909,7 +1953,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": @@ -1970,21 +2014,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 @@ -2045,7 +2086,7 @@ def __repr__(self): d_datetime = [ self._year, self._month, - self._day, # These are never zero + self._day, self._hour, self._minute, self._second, @@ -2075,23 +2116,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), @@ -2099,12 +2126,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"}) @@ -2117,31 +2175,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. @@ -2281,10 +2327,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/tests/test_jalalidate.py b/tests/test_jalalidate.py index e3b3a81..81f8d09 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -11,34 +11,36 @@ # (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), (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), (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) (1210, 12, 29, 1832, 3, 19), (1367, 12, 29, 1989, 3, 20), From b0cf089339479b41cd3d8ccbbd976f1e1c5d1b55 Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 03:35:51 +0330 Subject: [PATCH 05/11] chore: release 6.2.0 --- CHANGELOG.md | 7 +++++++ persiantools/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 438eb0d..81e50f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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. +- Added conversion test coverage validated against official Iranian calendar authority (kabise) data. + ## [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" From 19a7ca28a0e120082356155f7f414d0d5662574d Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 03:42:32 +0330 Subject: [PATCH 06/11] chore: mark package as typed and enrich PyPI metadata --- persiantools/py.typed | 0 pyproject.toml | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 persiantools/py.typed 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..320cd50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "persiantools" dynamic = ["version"] -description = "Jalali (Shamsi/Persian) date and datetime with Persian digits and character utilities" +description = "Jalali (Shamsi/Persian) date and datetime conversion, Persian/Arabic/English digit conversion, character normalization, and number-to-Persian-words utilities" readme = "README.md" license = "MIT" requires-python = ">=3.9" @@ -33,10 +33,17 @@ keywords = [ "persian-date", "persian-digits", "arabic-digits", + "persian-numbers", + "persian-words", "number-to-words", "characters", "normalization", "localization", + "solar-hijri", + "nowruz", + "iran", + "ordinal", + "ar-to-fa", ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -61,6 +68,7 @@ classifiers = [ "Topic :: Text Processing", "Topic :: Text Processing :: Linguistic", "Topic :: Utilities", + "Typing :: Typed", ] dependencies = [ @@ -71,6 +79,8 @@ 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" +Documentation = "https://github.com/majiidd/persiantools#readme" [dependency-groups] dev = [ @@ -89,6 +99,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"] From 2c05cd110b8edfca95df8c5025dcdf9f7b671f04 Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 03:48:18 +0330 Subject: [PATCH 07/11] chore: pin isort to stable and refresh pre-commit hooks --- .pre-commit-config.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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: From 42ebd04353cb26dde66898e81c5f75b73b521b18 Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 04:01:22 +0330 Subject: [PATCH 08/11] refactor: simplify to_word conversion and expand digit tests --- persiantools/digits.py | 63 ++++++++++--------- tests/test_digits.py | 138 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 30 deletions(-) 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/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) From 429683555b1de147b0826cf5eefa4353d964c94d Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 19:19:06 +0330 Subject: [PATCH 09/11] chore: trim package metadata and organize conversion fixtures --- README.md | 9 --------- pyproject.toml | 10 +--------- tests/test_jalalidate.py | 12 ++++++++---- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 255a5c1..8073cc6 100644 --- a/README.md +++ b/README.md @@ -147,15 +147,6 @@ True They are also hashable (usable as dict keys) and picklable, like the standard library types. -## Calendar model - -Supported range: `JalaliDate(1, 1, 1)` (Gregorian 622-03-22) through `JalaliDate(9377, 12, 30)` (Gregorian 9999-03-20). All conversions, ordinals, and leap-year checks share one internal model, so every date round-trips exactly. - -- **Epoch** — 1 Farvardin of year 1 is Friday, 22 March 622 CE (proleptic Gregorian; 19 March 622 Julian), the standard Solar Hijri epoch. -- **Years 1–1177** — leap years follow the astronomical Persian calendar (vernal equinox at the 52.5°E meridian, per *Calendrical Calculations*), using data from [roozbehp/persiancalendar](https://github.com/roozbehp/persiancalendar). In five borderline years (979, 1012, 1045, 1078, 1177), where the equinox falls within minutes of the midday cutoff, persiantools keeps the widely agreed leap status rather than the pure astronomical flip. -- **Years 1178–2987** — the 33-year leap cycle plus the [ICU4X correction set](https://github.com/unicode-org/icu4x/blob/main/utils/calendrical_calculations/src/persian.rs). This matches the official table of the Iranian calendar authority (Calendar Center, Institute of Geophysics, University of Tehran) for 1206–1498 and agrees with the astronomical model through year 3000. -- **Years 2988 and later** — the plain 33-year cycle, used as a fixed convention beyond the range where astronomical projections are meaningful. - ## Digits Convert digits between English, Persian, and Arabic: diff --git a/pyproject.toml b/pyproject.toml index 320cd50..43ba78a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "persiantools" dynamic = ["version"] -description = "Jalali (Shamsi/Persian) date and datetime conversion, Persian/Arabic/English digit conversion, character normalization, and number-to-Persian-words utilities" +description = "Jalali (Shamsi/Persian) date and datetime with Persian digits and character utilities" readme = "README.md" license = "MIT" requires-python = ">=3.9" @@ -33,17 +33,10 @@ keywords = [ "persian-date", "persian-digits", "arabic-digits", - "persian-numbers", - "persian-words", "number-to-words", "characters", "normalization", "localization", - "solar-hijri", - "nowruz", - "iran", - "ordinal", - "ar-to-fa", ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -80,7 +73,6 @@ 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" -Documentation = "https://github.com/majiidd/persiantools#readme" [dependency-groups] dev = [ diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index 81f8d09..4b05113 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -14,6 +14,7 @@ (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), @@ -26,6 +27,7 @@ (1400, 4, 25, 2021, 7, 16), (1400, 6, 31, 2021, 9, 22), (1400, 12, 20, 2022, 3, 11), + (1400, 6, 31, 2021, 9, 22), (1402, 10, 10, 2023, 12, 31), (1403, 1, 5, 2024, 3, 24), (1403, 2, 23, 2024, 5, 12), @@ -42,10 +44,13 @@ (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), @@ -54,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), @@ -79,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), + (100, 1, 1, 721, 3, 22), (1000, 1, 1, 1621, 3, 21), (1100, 1, 1, 1721, 3, 21), (1206, 1, 1, 1827, 3, 22), From 0601a129ccba12aba6a83127e994415b0b7c1d83 Mon Sep 17 00:00:00 2001 From: Majid Date: Wed, 5 Aug 2026 23:57:50 +0330 Subject: [PATCH 10/11] fix: preserve locale when copying JalaliDateTime Cover underflow, leap-day replace, combine fold/tzinfo, and to_jalali argument forms. --- persiantools/jdatetime.py | 1 + tests/test_jalalidate.py | 17 +++++++++++++---- tests/test_jalalidatetime.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index 5862e18..5219536 100644 --- a/persiantools/jdatetime.py +++ b/persiantools/jdatetime.py @@ -1259,6 +1259,7 @@ 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: diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index 4b05113..724b87b 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -232,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)) @@ -248,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) @@ -587,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) 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")) From 7899a38bdca2d73ab4967673ae1f1209cfc934be Mon Sep 17 00:00:00 2001 From: Majid Date: Thu, 6 Aug 2026 00:00:37 +0330 Subject: [PATCH 11/11] docs: complete 6.2.0 changelog for post-release branch changes --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e50f3..7781ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,10 @@ - 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. -- Added conversion test coverage validated against official Iranian calendar authority (kabise) data. +- 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