diff --git a/CHANGELOG.md b/CHANGELOG.md index 351048e..4edae6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog +## [5.2.0](https://github.com/majiidd/persiantools/compare/5.1.1...5.2.0) - 2025-01-17 + +- Enhanced character conversion functions using regular expressions. +- Improved date handling in Jalali date and time classes. +- Added new test cases for edge cases and date conversions. + +## [5.1.1](https://github.com/majiidd/persiantools/compare/5.1.0...5.1.1) - 2025-01-16 + +- Improved leap year calculation for issue #48. + ## [5.1.0](https://github.com/majiidd/persiantools/compare/5.0.0...5.1.0) - 2024-11-08 - Improved CI/CD pipeline run time through optimized caching. diff --git a/LICENSE b/LICENSE index d1f8349..b6ef18b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2016-2024 Majid Hajiloo +Copyright (c) 2016-2025 Majid Hajiloo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/persiantools/__init__.py b/persiantools/__init__.py index 8d50703..10df430 100644 --- a/persiantools/__init__.py +++ b/persiantools/__init__.py @@ -7,7 +7,7 @@ __title__ = "persiantools" __url__ = "https://github.com/majiidd/persiantools" -__version__ = "5.1.1" +__version__ = "5.2.0" __build__ = __version__ __author__ = "Majid Hajiloo" __author_email__ = "majid.hajiloo@gmail.com" diff --git a/persiantools/characters.py b/persiantools/characters.py index ebe9c2c..74fdb47 100644 --- a/persiantools/characters.py +++ b/persiantools/characters.py @@ -1,4 +1,4 @@ -from persiantools.utils import replace +import re CHARACTER_MAP_AR_TO_FA = { "دِ": "د", @@ -14,6 +14,9 @@ CHARACTER_MAP_FA_TO_AR = {"ی": "ي", "ک": "ك"} +AR_TO_FA_PATTERN = re.compile("|".join(map(re.escape, CHARACTER_MAP_AR_TO_FA.keys()))) +FA_TO_AR_PATTERN = re.compile("|".join(map(re.escape, CHARACTER_MAP_FA_TO_AR.keys()))) + def ar_to_fa(string: str) -> str: """ @@ -35,7 +38,7 @@ def ar_to_fa(string: str) -> str: if not isinstance(string, str): raise TypeError("Input must be of type str") - return replace(string, CHARACTER_MAP_AR_TO_FA) + return AR_TO_FA_PATTERN.sub(lambda match: CHARACTER_MAP_AR_TO_FA[match.group(0)], string) def fa_to_ar(string: str) -> str: @@ -58,4 +61,4 @@ def fa_to_ar(string: str) -> str: if not isinstance(string, str): raise TypeError("Input must be of type str") - return replace(string, CHARACTER_MAP_FA_TO_AR) + return FA_TO_AR_PATTERN.sub(lambda match: CHARACTER_MAP_FA_TO_AR[match.group(0)], string) diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index b424006..a8ec78f 100644 --- a/persiantools/jdatetime.py +++ b/persiantools/jdatetime.py @@ -132,90 +132,21 @@ _FRACTION_CORRECTION = [100000, 10000, 1000, 100, 10] # List of years that are exceptions to the 33-year leap year rule -NON_LEAP_CORRECTION = [ - 1502, - 1601, - 1634, - 1667, - 1700, - 1733, - 1766, - 1799, - 1832, - 1865, - 1898, - 1931, - 1964, - 1997, - 2030, - 2059, - 2063, - 2096, - 2129, - 2158, - 2162, - 2191, - 2195, - 2224, - 2228, - 2257, - 2261, - 2290, - 2294, - 2323, - 2327, - 2356, - 2360, - 2389, - 2393, - 2422, - 2426, - 2455, - 2459, - 2488, - 2492, - 2521, - 2525, - 2554, - 2558, - 2587, - 2591, - 2620, - 2624, - 2653, - 2657, - 2686, - 2690, - 2719, - 2723, - 2748, - 2752, - 2756, - 2781, - 2785, - 2789, - 2818, - 2822, - 2847, - 2851, - 2855, - 2880, - 2884, - 2888, - 2913, - 2917, - 2921, - 2946, - 2950, - 2954, - 2979, - 2983, - 2987, -] - -NON_LEAP_CORRECTION_SET = frozenset(NON_LEAP_CORRECTION) - -MIN_NON_LEAP_CORRECTION = NON_LEAP_CORRECTION[0] +# fmt: off +NON_LEAP_CORRECTION_SET = frozenset( + [ + 1502, 1601, 1634, 1667, 1700, 1733, 1766, 1799, 1832, 1865, 1898, 1931, + 1964, 1997, 2030, 2059, 2063, 2096, 2129, 2158, 2162, 2191, 2195, 2224, + 2228, 2257, 2261, 2290, 2294, 2323, 2327, 2356, 2360, 2389, 2393, 2422, + 2426, 2455, 2459, 2488, 2492, 2521, 2525, 2554, 2558, 2587, 2591, 2620, + 2624, 2653, 2657, 2686, 2690, 2719, 2723, 2748, 2752, 2756, 2781, 2785, + 2789, 2818, 2822, 2847, 2851, 2855, 2880, 2884, 2888, 2913, 2917, 2921, + 2946, 2950, 2954, 2979, 2983, 2987, + ] +) +# fmt: on + +MIN_NON_LEAP_CORRECTION = 1502 def _is_ascii_digit(c: str) -> bool: @@ -270,10 +201,7 @@ def __init__(self, year, month=None, day=None, locale="en"): raise ValueError("locale must be 'en' or 'fa'") if isinstance(year, JalaliDate) and month is None: - month = year.month - day = year.day - locale = year.locale - year = year.year + year, month, day, locale = year.year, year.month, year.day, year.locale elif isinstance(year, date): jdate = self.to_jalali(year) @@ -284,32 +212,49 @@ def __init__(self, year, month=None, day=None, locale="en"): ): self.__setstate__(year) - year = self._year - month = self._month - day = self._day - - year, month, day, locale = self._check_date_fields(year, month, day, locale) + year, month, day = self._year, self._month, self._day - self._year = year - self._month = month - self._day = day - self._locale = locale + self._year, self._month, self._day, self._locale = self._check_date_fields(year, month, day, locale) self._hashcode = -1 @property def year(self) -> int: + """ + Get the year component of the date. + + Returns: + int: The year as an integer. + """ return self._year @property def month(self) -> int: + """ + Get the month component of the date. + + Returns: + int: The month as an integer. + """ return self._month @property def day(self) -> int: + """ + Get the day of the month. + + Returns: + int: The day of the month. + """ return self._day @property def locale(self): + """ + Get the locale setting for the current instance. + + Returns: + str: The locale setting. + """ return self._locale @locale.setter @@ -394,7 +339,7 @@ def is_leap(year: int) -> bool: Returns: bool: True if the year is a leap year, False otherwise. """ - if not MINYEAR <= year <= MAXYEAR: + if not (MINYEAR <= year <= MAXYEAR): raise ValueError(f"Year must be between {MINYEAR} and {MAXYEAR}") if year < MIN_NON_LEAP_CORRECTION: @@ -653,7 +598,19 @@ def fromordinal(cls, n: int): @classmethod def fromisoformat(cls, date_string: str): - """Construct a date from the output of JalaliDate.isoformat().""" + """ + Construct a JalaliDate from an ISO 8601 formatted date string. + + Args: + date_string (str): The date string in ISO 8601 format. + + Returns: + JalaliDate: A JalaliDate object corresponding to the given date string. + + Raises: + TypeError: If the provided argument is not a string. + ValueError: If the provided string is not a valid ISO 8601 formatted date. + """ if not isinstance(date_string, str): raise TypeError("fromisoformat: argument must be str") @@ -751,6 +708,21 @@ def fromtimestamp(cls, timestamp: float): return cls(date.fromtimestamp(timestamp)) def weekday(self) -> int: + """ + Returns the day of the week as an integer. + + The days of the week are represented as follows: + 0 - Shanbeh + 1 - Yekshanbeh + 2 - Doshanbeh + 3 - Seshanbeh + 4 - Chaharshanbeh + 5 - Panjshanbeh + 6 - Jomeh + + Returns: + int: An integer representing the day of the week. + """ return (self.toordinal() + 4) % 7 def __format__(self, fmt: str): @@ -762,9 +734,23 @@ def __format__(self, fmt: str): return str(self) def isoweekday(self) -> int: + """ + Return the ISO weekday. + + The ISO weekday is a number representing the day of the week, where Shanbeh is 1 and Jomeh is 7. + + Returns: + int: An integer representing the ISO weekday. + """ return self.weekday() + 1 def week_of_year(self) -> int: + """ + Calculate the week number of the year for the current Jalali date. + + 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 @@ -776,10 +762,24 @@ def week_of_year(self) -> int: return week_no def isocalendar(self): - """Return a 3-tuple containing ISO year, week number, and weekday.""" + """ + Return a 3-tuple containing ISO year, week number, and weekday. + + Returns: + tuple: A tuple containing the ISO year, ISO week number, and ISO weekday. + """ return self.year, self.week_of_year(), self.isoweekday() def ctime(self) -> str: + """ + Return a string representing the date and time in a locale’s appropriate format. + + This method uses the strftime() function with the format code "%c" to generate + a string representation of the date and time. + + Returns: + str: A string representing the date and time. + """ return self.strftime("%c") def strftime(self, fmt: str, locale=None) -> str: @@ -923,7 +923,6 @@ def __add__(self, other): __radd__ = __add__ def __sub__(self, other): - """Subtract two JalaliDates/dates, or a JalaliDate/date and a timedelta.""" if isinstance(other, timedelta): return self + timedelta(-other.days) @@ -1030,22 +1029,52 @@ def _check_time_fields(hour, minute, second, microsecond): @property def hour(self) -> int: + """ + Get the hour component of the datetime. + + Returns: + int: The hour component of the datetime. + """ return self._hour @property def minute(self) -> int: + """ + Get the minute component of the datetime. + + Returns: + int: The minute component of the datetime. + """ return self._minute @property def second(self) -> int: + """ + Get the second component of the time. + + Returns: + int: The second component of the time. + """ return self._second @property def microsecond(self) -> int: + """ + Returns the microsecond component of the datetime. + + Returns: + int: The microsecond component of the datetime. + """ return self._microsecond @property def tzinfo(self): + """ + Returns the time zone information associated with this datetime object. + + :return: The time zone information. + :rtype: tzinfo + """ return self._tzinfo @classmethod diff --git a/tests/test_characters.py b/tests/test_characters.py index 202bdc5..9906d2d 100644 --- a/tests/test_characters.py +++ b/tests/test_characters.py @@ -24,11 +24,18 @@ def test_ar_to_fa(self): with pytest.raises(TypeError): characters.ar_to_fa(12345) + with self.assertRaises(TypeError): + characters.ar_to_fa(None) + orig = "السلام عليكم ٠١٢٣٤٥٦٧٨٩" converted = characters.ar_to_fa(orig) converted = digits.ar_to_fa(converted) self.assertEqual(converted, "السلام علیکم ۰۱۲۳۴۵۶۷۸۹") + input_string = "يياكشيسِ" + expected_output = "ییاکشیس" + self.assertEqual(characters.ar_to_fa(input_string), expected_output) + def test_fa_to_fa(self): self.assertEqual(characters.ar_to_fa("السلام علیکم"), "السلام علیکم") self.assertEqual(characters.ar_to_fa("کیک"), "کیک") @@ -48,3 +55,6 @@ def test_fa_to_ar(self): with pytest.raises(TypeError): characters.fa_to_ar(12345) + + with self.assertRaises(TypeError): + characters.fa_to_ar(None) diff --git a/tests/test_digits.py b/tests/test_digits.py index 32dd440..daa33bd 100644 --- a/tests/test_digits.py +++ b/tests/test_digits.py @@ -85,5 +85,8 @@ def test_to_letter(self): with pytest.raises(digits.OutOfRangeException): digits.to_word(1000000000000001) + with pytest.raises(digits.OutOfRangeException): + digits.to_word(0.123456789012345) + with pytest.raises(TypeError): digits.to_word("123") diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index a385976..4a6b842 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -12,9 +12,7 @@ class TestJalaliDate(TestCase): def test_shamsi_to_gregorian(self): cases = [ - (JalaliDate(1210, 12, 29), date(1832, 3, 19)), - (JalaliDate(1210, 12, 30), date(1832, 3, 20)), - (JalaliDate(1211, 1, 1), date(1832, 3, 21)), + (JalaliDate(1100, 1, 1), date(1721, 3, 21)), (JalaliDate(1367, 2, 14), date(1988, 5, 4)), (JalaliDate(1395, 3, 21), date(2016, 6, 10)), (JalaliDate(1395, 12, 9), date(2017, 2, 27)), @@ -33,6 +31,7 @@ def test_shamsi_to_gregorian(self): (JalaliDate(1403, 4, 8), date(2024, 6, 28)), (JalaliDate(1403, 8, 18), date(2024, 11, 8)), (JalaliDate(1403, 10, 27), date(2025, 1, 16)), + (JalaliDate(1210, 12, 29), date(1832, 3, 19)), (JalaliDate(1367, 12, 29), date(1989, 3, 20)), (JalaliDate(1392, 12, 29), date(2014, 3, 20)), (JalaliDate(1398, 12, 29), date(2020, 3, 19)), @@ -40,6 +39,8 @@ def test_shamsi_to_gregorian(self): (JalaliDate(1400, 12, 29), date(2022, 3, 20)), (JalaliDate(1402, 12, 29), date(2024, 3, 19)), (JalaliDate(1403, 12, 29), date(2025, 3, 19)), + (JalaliDate(1504, 12, 29), date(2126, 3, 20)), + (JalaliDate(1210, 12, 30), date(1832, 3, 20)), (JalaliDate(1391, 12, 30), date(2013, 3, 20)), (JalaliDate(1395, 12, 30), date(2017, 3, 20)), (JalaliDate(1399, 12, 30), date(2021, 3, 20)), @@ -55,6 +56,7 @@ def test_shamsi_to_gregorian(self): (JalaliDate(1400, 10, 11), date(2022, 1, 1)), (JalaliDate(1402, 10, 11), date(2024, 1, 1)), (JalaliDate(1403, 10, 12), date(2025, 1, 1)), + (JalaliDate(1211, 1, 1), date(1832, 3, 21)), (JalaliDate(1367, 1, 1), date(1988, 3, 21)), (JalaliDate(1388, 1, 1), date(2009, 3, 21)), (JalaliDate(1396, 1, 1), date(2017, 3, 21)), @@ -64,6 +66,7 @@ def test_shamsi_to_gregorian(self): (JalaliDate(1402, 1, 1), date(2023, 3, 21)), (JalaliDate(1403, 1, 1), date(2024, 3, 20)), (JalaliDate(1404, 1, 1), date(2025, 3, 21)), + (JalaliDate(1505, 1, 1), date(2126, 3, 21)), (JalaliDate.today(), date.today()), ] for jdate, gdate in cases: @@ -71,9 +74,6 @@ def test_shamsi_to_gregorian(self): def test_gregorian_to_shamsi(self): cases = [ - (date(1832, 3, 19), JalaliDate(1210, 12, 29)), - (date(1832, 3, 20), JalaliDate(1210, 12, 30)), - (date(1832, 3, 21), JalaliDate(1211, 1, 1)), (date(1988, 5, 4), JalaliDate(1367, 2, 14)), (date(2122, 1, 31), JalaliDate(1500, 11, 11)), (date(2017, 10, 19), JalaliDate(1396, 7, 27)), @@ -90,9 +90,12 @@ def test_gregorian_to_shamsi(self): (date(2000, 12, 31), JalaliDate(1379, 10, 11)), (date(2023, 12, 31), JalaliDate(1402, 10, 10)), (date(2024, 12, 31), JalaliDate(1403, 10, 11)), + (date(1832, 3, 19), JalaliDate(1210, 12, 29)), + (date(1832, 3, 20), JalaliDate(1210, 12, 30)), (date(2017, 3, 20), JalaliDate(1395, 12, 30)), (date(2021, 3, 20), JalaliDate(1399, 12, 30)), (date(2025, 3, 20), JalaliDate(1403, 12, 30)), + (date(1832, 3, 21), JalaliDate(1211, 1, 1)), (date(2000, 1, 1), JalaliDate(1378, 10, 11)), (date(2012, 1, 1), JalaliDate(1390, 10, 11)), (date(2013, 1, 1), JalaliDate(1391, 10, 12)), @@ -107,6 +110,12 @@ def test_gregorian_to_shamsi(self): (date(2023, 3, 21), JalaliDate(1402, 1, 1)), (date(2024, 3, 20), JalaliDate(1403, 1, 1)), (date(2025, 3, 21), JalaliDate(1404, 1, 1)), + (date(1827, 3, 22), JalaliDate(1206, 1, 1)), + (date(1828, 3, 21), JalaliDate(1207, 1, 1)), + (date(1839, 3, 21), JalaliDate(1218, 1, 1)), + (date(1864, 3, 20), JalaliDate(1243, 1, 1)), + (date(2118, 3, 21), JalaliDate(1497, 1, 1)), + (date(2119, 3, 21), JalaliDate(1498, 1, 1)), (date.today(), JalaliDate.today()), ] for gdate, jdate in cases: @@ -279,6 +288,8 @@ def test_leap(self): # Known non-leap years (1206, False), (1207, False), + (1208, False), + (1209, False), (1211, False), (1215, False), (1216, False), @@ -302,6 +313,10 @@ def test_leap(self): (1409, False), (1410, False), (1411, False), + (1493, False), + (1495, False), + (1496, False), + (1497, False), ] for year, is_leap in cases: self.assertEqual(JalaliDate.is_leap(year), is_leap) @@ -413,6 +428,7 @@ def test_week(self): self.assertEqual(JalaliDate(1399, 1, 2).week_of_year(), 2) self.assertEqual(JalaliDate(1403, 1, 5).week_of_year(), 2) self.assertEqual(JalaliDate(1403, 4, 3).week_of_year(), 15) + self.assertEqual(JalaliDate(1403, 10, 28).week_of_year(), 44) self.assertEqual(JalaliDate(1367, 2, 14).weekday(), 4) self.assertEqual(JalaliDate(1393, 1, 1).weekday(), 6) @@ -425,10 +441,13 @@ def test_week(self): self.assertEqual(JalaliDate(1397, 1, 1).weekday(), 4) self.assertEqual(JalaliDate(1397, 11, 29).weekday(), 2) self.assertEqual(JalaliDate(1400, 1, 1).weekday(), 1) + self.assertEqual(JalaliDate(1403, 10, 28).weekday(), 6) + self.assertEqual(JalaliDate(1403, 4, 3).isoweekday(), 2) self.assertEqual(JalaliDate(1400, 1, 1).isoweekday(), 2) self.assertEqual(JalaliDate(1396, 7, 27).isoweekday(), 6) self.assertEqual(JalaliDate(1397, 11, 29).isoweekday(), 3) + self.assertEqual(JalaliDate(1403, 10, 28).isoweekday(), 7) def test_operators(self): self.assertTrue(JalaliDate(1367, 2, 14) == JalaliDate(date(1988, 5, 4))) @@ -481,6 +500,8 @@ def test_arithmetic_operations(self): self.assertEqual(JalaliDate(1395, 3, 21) + timedelta(days=-38), JalaliDate(1395, 2, 14)) self.assertEqual(JalaliDate(1395, 3, 21) - timedelta(days=38), JalaliDate(1395, 2, 14)) self.assertEqual(JalaliDate(1397, 11, 29) + timedelta(days=2), JalaliDate(1397, 12, 1)) + self.assertEqual(JalaliDate(1403, 12, 29) + timedelta(days=2), JalaliDate(1404, 1, 1)) + self.assertEqual(JalaliDate(1403, 12, 29) + timedelta(days=365), JalaliDate(1404, 12, 28)) self.assertEqual(JalaliDate(1395, 3, 21) - JalaliDate(1395, 2, 14), timedelta(days=38)) self.assertEqual(JalaliDate(1397, 12, 1) - JalaliDate(1397, 11, 29), timedelta(hours=48)) @@ -492,6 +513,7 @@ def test_arithmetic_operations(self): self.assertEqual(JalaliDate(1400, 1, 1) - JalaliDate(1399, 12, 29), timedelta(days=2)) self.assertEqual(JalaliDate(1403, 1, 1) - JalaliDate(1402, 12, 29), timedelta(days=1)) self.assertEqual(JalaliDate(1404, 1, 1) - JalaliDate(1403, 12, 29), timedelta(days=2)) + self.assertEqual(JalaliDate(1404, 1, 1) - JalaliDate(1403, 12, 30), timedelta(days=1)) def test_pickle(self): file = open("save.p", "wb") @@ -513,7 +535,7 @@ def test_hash(self): self.assertEqual( {j1: "today", j2: "majid1", j3: "majid2"}, - {JalaliDate.today(): "today", JalaliDate(1367, 2, 14): "majid2"}, + {JalaliDate.today(): "today", JalaliDate(date(1988, 5, 4)): "majid1", JalaliDate(1367, 2, 14): "majid2"}, ) def test_invalid_dates(self): @@ -537,7 +559,7 @@ 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)") - def test_strptime_raises_not_implemented_error(self): + def test_strptime(self): with self.assertRaises(NotImplementedError): JalaliDate.strptime("1400-01-01", "%Y-%m-%d") @@ -546,3 +568,17 @@ def test_locale_setter_invalid_value(self): with pytest.raises(ValueError, match="locale must be 'en' or 'fa'"): jdate.locale = "de" + + def test_setstate(self): + jdate = JalaliDate(1400, 1, 1) + state = bytes([5, 87, 2, 14]) + jdate.__setstate__(state) + + self.assertEqual(jdate.year, 1367) + self.assertEqual(jdate.month, 2) + self.assertEqual(jdate.day, 14) + + jdate = JalaliDate(1400, 1, 1) + state = bytes([5, 112, 1]) # Invalid length + with pytest.raises(TypeError, match="not enough arguments"): + jdate.__setstate__(state)