persiantools 5.2.1__tar.gz → 5.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {persiantools-5.2.1 → persiantools-5.3.0}/LICENSE +1 -1
  2. {persiantools-5.2.1/persiantools.egg-info → persiantools-5.3.0}/PKG-INFO +1 -2
  3. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools/__init__.py +1 -1
  4. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools/jdatetime.py +133 -23
  5. {persiantools-5.2.1 → persiantools-5.3.0/persiantools.egg-info}/PKG-INFO +1 -2
  6. {persiantools-5.2.1 → persiantools-5.3.0}/setup.py +0 -1
  7. {persiantools-5.2.1 → persiantools-5.3.0}/tests/test_digits.py +11 -0
  8. {persiantools-5.2.1 → persiantools-5.3.0}/tests/test_jalalidate.py +181 -2
  9. {persiantools-5.2.1 → persiantools-5.3.0}/tests/test_jalalidatetime.py +185 -0
  10. {persiantools-5.2.1 → persiantools-5.3.0}/MANIFEST.in +0 -0
  11. {persiantools-5.2.1 → persiantools-5.3.0}/README.md +0 -0
  12. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools/characters.py +0 -0
  13. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools/digits.py +0 -0
  14. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools/utils.py +0 -0
  15. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools.egg-info/SOURCES.txt +0 -0
  16. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools.egg-info/dependency_links.txt +0 -0
  17. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools.egg-info/not-zip-safe +0 -0
  18. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools.egg-info/requires.txt +0 -0
  19. {persiantools-5.2.1 → persiantools-5.3.0}/persiantools.egg-info/top_level.txt +0 -0
  20. {persiantools-5.2.1 → persiantools-5.3.0}/pyproject.toml +0 -0
  21. {persiantools-5.2.1 → persiantools-5.3.0}/setup.cfg +0 -0
  22. {persiantools-5.2.1 → persiantools-5.3.0}/tests/test_characters.py +0 -0
  23. {persiantools-5.2.1 → persiantools-5.3.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2016-2025 Majid Hajiloo
3
+ Copyright (c) 2016 Majid Hajiloo
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persiantools
3
- Version: 5.2.1
3
+ Version: 5.3.0
4
4
  Summary: Jalali date and datetime with other tools
5
5
  Home-page: https://github.com/majiidd/persiantools
6
6
  Author: Majid Hajiloo
@@ -8,7 +8,6 @@ Author-email: majid.hajiloo@gmail.com
8
8
  License: MIT
9
9
  Keywords: jalali shamsi persian digits characters converter jalalidate jalalidatetime date datetime jdate jdatetime farsi
10
10
  Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
11
  Classifier: Natural Language :: Persian
13
12
  Classifier: Operating System :: OS Independent
14
13
  Classifier: Programming Language :: Python
@@ -7,7 +7,7 @@
7
7
 
8
8
  __title__ = "persiantools"
9
9
  __url__ = "https://github.com/majiidd/persiantools"
10
- __version__ = "5.2.1"
10
+ __version__ = "5.3.0"
11
11
  __build__ = __version__
12
12
  __author__ = "Majid Hajiloo"
13
13
  __author_email__ = "majid.hajiloo@gmail.com"
@@ -633,12 +633,13 @@ class JalaliDate:
633
633
  raise ValueError(f"Invalid date separator: {dtstr[4]}")
634
634
 
635
635
  month = int(dtstr[5:7])
636
-
637
636
  if dtstr[7] != "-":
638
637
  raise ValueError("Invalid date separator")
639
638
 
640
639
  day = int(dtstr[8:10])
641
640
 
641
+ cls._check_date_fields(year, month, day, "en")
642
+
642
643
  return [year, month, day]
643
644
 
644
645
  def __hash__(self):
@@ -941,8 +942,122 @@ class JalaliDate:
941
942
  raise NotImplementedError
942
943
 
943
944
  @classmethod
944
- def strptime(cls, data_string, fmt):
945
- raise NotImplementedError
945
+ def strptime(cls, data_string, fmt, locale="en"):
946
+ if locale not in ["en", "fa"]:
947
+ raise ValueError("locale must be 'en' or 'fa'")
948
+
949
+ if locale == "fa":
950
+ data_string = digits.fa_to_en(data_string)
951
+
952
+ month_names_list = MONTH_NAMES_EN[1:] if locale == "en" else MONTH_NAMES_FA[1:]
953
+ month_names_abbr_list = MONTH_NAMES_ABBR_EN[1:] if locale == "en" else MONTH_NAMES_ABBR_FA[1:]
954
+ weekday_names_list = WEEKDAY_NAMES_EN if locale == "en" else WEEKDAY_NAMES_FA
955
+ weekday_names_abbr_list = WEEKDAY_NAMES_ABBR_EN if locale == "en" else WEEKDAY_NAMES_ABBR_FA
956
+
957
+ directives_regex_pattern = {
958
+ "%Y": r"(?P<Y>\d{4})",
959
+ "%y": r"(?P<y>\d{2})",
960
+ "%m": r"(?P<m>1[0-2]|0?[1-9])",
961
+ "%d": r"(?P<d>\d{1,2})",
962
+ "%b": cls._seqToRE(month_names_abbr_list, "b"),
963
+ "%B": cls._seqToRE(month_names_list, "B"),
964
+ "%a": cls._seqToRE(weekday_names_abbr_list, "a"),
965
+ "%A": cls._seqToRE(weekday_names_list, "A"),
966
+ }
967
+
968
+ fmt = utils.replace(
969
+ fmt,
970
+ {
971
+ "%x": "%Y/%m/%d",
972
+ "%c": "%a %b %d %Y",
973
+ },
974
+ )
975
+
976
+ data_string_regex = utils.replace(fmt, directives_regex_pattern)
977
+ full_pattern = f"^{data_string_regex}$"
978
+
979
+ match = re.match(full_pattern, data_string, re.IGNORECASE)
980
+ if not match:
981
+ match_strict = re.match(f"^{data_string_regex}$", data_string, re.IGNORECASE)
982
+ if not match_strict:
983
+ raise ValueError(f"Date string '{data_string}' does not match format '{fmt}'")
984
+ directives = match_strict.groupdict()
985
+ else:
986
+ directives = match.groupdict()
987
+
988
+ parsed_components = {}
989
+ for k, v in directives.items():
990
+ if v is not None:
991
+ if k not in ["a", "A", "b", "B"] and v.isdigit():
992
+ parsed_components[k] = int(v)
993
+ else:
994
+ parsed_components[k] = v
995
+
996
+ year = parsed_components.get("Y")
997
+ yy = parsed_components.get("y")
998
+
999
+ if year is None and yy is not None:
1000
+ if not (0 <= yy <= 99):
1001
+ raise ValueError(f"Year without century (yy) '{yy}' out of range 00-99.")
1002
+ # Heuristic: if yy > 70, assume 13yy, else 14yy.
1003
+ year = (
1004
+ (1300 + yy) if yy > (2070 - 2000) else (1400 + yy)
1005
+ ) # Adjusted heuristic to be roughly 70 for 1300 century.
1006
+ # Current Jalali year is around 140x. So values like 01, 02.. up to e.g. 70 => 14xx.
1007
+ # values like 71, 72 .. 99 => 13xx.
1008
+ elif year is None:
1009
+ raise ValueError("Year information is missing from the date string or format.")
1010
+
1011
+ month = parsed_components.get("m")
1012
+ if month is None:
1013
+ month_name_abbr = parsed_components.get("b")
1014
+ month_name_full = parsed_components.get("B")
1015
+
1016
+ found_month = False
1017
+ if month_name_abbr is not None:
1018
+ normalized_month_abbr = month_name_abbr.capitalize() if locale == "en" else month_name_abbr
1019
+ try:
1020
+ month = month_names_abbr_list.index(normalized_month_abbr) + 1
1021
+ found_month = True
1022
+ except ValueError:
1023
+ try:
1024
+ month = month_names_list.index(normalized_month_abbr) + 1
1025
+ found_month = True
1026
+ except ValueError:
1027
+ pass
1028
+
1029
+ if not found_month and month_name_full is not None:
1030
+ normalized_month_full = month_name_full.capitalize() if locale == "en" else month_name_full
1031
+ try:
1032
+ month = month_names_list.index(normalized_month_full) + 1
1033
+ found_month = True
1034
+ except ValueError:
1035
+ pass
1036
+
1037
+ if not found_month:
1038
+ raise ValueError(
1039
+ f"Month name not recognized from '{month_name_abbr or month_name_full}' for locale '{locale}'."
1040
+ )
1041
+
1042
+ day = parsed_components.get("d")
1043
+ if day is None:
1044
+ raise ValueError("Day information is missing from the date string or format.")
1045
+
1046
+ cls._check_date_fields(year, month, day, locale)
1047
+
1048
+ return cls(year, month, day, locale=locale)
1049
+
1050
+ @staticmethod
1051
+ def _seqToRE(to_convert, directive):
1052
+ to_convert = sorted(to_convert, key=len, reverse=True)
1053
+ for value in to_convert:
1054
+ if value != "":
1055
+ break
1056
+ else:
1057
+ return ""
1058
+ regex = "|".join(re_escape(stuff) for stuff in to_convert)
1059
+ regex = f"(?P<{directive}>{regex}"
1060
+ return "%s)" % regex
946
1061
 
947
1062
 
948
1063
  _tzinfo_class = tzinfo
@@ -1604,21 +1719,26 @@ class JalaliDateTime(JalaliDate):
1604
1719
  look for the table under "strftime() and strptime() Format Codes" section.
1605
1720
  """
1606
1721
  directives_regex_pattern = {
1607
- "%Y": r"(?P<Y>\d\d\d\d)",
1722
+ "%Y": r"(?P<Y>\d{4})",
1608
1723
  "%m": r"(?P<m>1[0-2]|0[1-9]|[1-9])",
1609
1724
  "%d": r"(?P<d>3[0-1]|[1-2]\d|0[1-9]|[1-9]| [1-9])",
1610
- "%a": cls.__seqToRE(cls, weekday_names_abbr, "a"),
1611
- "%A": cls.__seqToRE(cls, weekday_names, "A"),
1612
- "%b": cls.__seqToRE(cls, month_names_abbr, "b"),
1613
- "%B": cls.__seqToRE(cls, month_names, "B"),
1725
+ "%a": cls._seqToRE(weekday_names_abbr, "a"),
1726
+ "%A": cls._seqToRE(weekday_names, "A"),
1727
+ "%b": cls._seqToRE(month_names_abbr, "b"),
1728
+ "%B": cls._seqToRE(month_names, "B"),
1614
1729
  "%H": r"(?P<H>2[0-3]|[0-1]\d|\d)",
1615
1730
  "%I": r"(?P<I>1[0-2]|0[1-9]|[1-9])",
1616
- "%p": cls.__seqToRE(cls, periods, "p"),
1731
+ "%p": cls._seqToRE(periods, "p"),
1617
1732
  "%M": r"(?P<M>[0-5]\d|\d)",
1618
1733
  "%S": r"(?P<S>6[0-1]|[0-5]\d|\d)",
1619
1734
  "%f": r"(?P<f>\d{1,6})",
1620
- "%z": r"(?P<z>[-+](?P<zH>[0-1]?[0-9]|2[0-3])(?P<zM>[0-5]?[0-9])(?P<zS>[0-5]?[0-9])?(\.(?P<zf>(\d{,6})))?)",
1621
- "%Z": cls.__seqToRE(cls, pytz.all_timezones, "Z"),
1735
+ "%z": (
1736
+ r"(?P<z>[-+](?P<zH>2[0-3]|[0-1]\d)"
1737
+ r"(?:[:]?)(?P<zM>[0-5]\d)"
1738
+ r"(?:[:]?(?P<zS>[0-5]\d))?"
1739
+ r"(?:\.(?P<zf>\d{1,6}))?)"
1740
+ ),
1741
+ "%Z": cls._seqToRE(pytz.all_timezones, "Z"),
1622
1742
  }
1623
1743
 
1624
1744
  fmt = utils.replace(
@@ -1631,8 +1751,9 @@ class JalaliDateTime(JalaliDate):
1631
1751
  )
1632
1752
 
1633
1753
  data_string_regex = utils.replace(fmt, directives_regex_pattern)
1754
+ full_pattern = f"^{data_string_regex}$"
1634
1755
 
1635
- if re.match(data_string_regex, data_string, re.IGNORECASE):
1756
+ if re.match(full_pattern, data_string, re.IGNORECASE):
1636
1757
  directives = re.search(data_string_regex, data_string, re.IGNORECASE).groupdict()
1637
1758
 
1638
1759
  if "Y" in directives.keys() and len(directives.get("Y")) < 4:
@@ -1684,17 +1805,6 @@ class JalaliDateTime(JalaliDate):
1684
1805
  else:
1685
1806
  raise ValueError("data string and format are not matched")
1686
1807
 
1687
- def __seqToRE(self, to_convert, directive):
1688
- to_convert = sorted(to_convert, key=len, reverse=True)
1689
- for value in to_convert:
1690
- if value != "":
1691
- break
1692
- else:
1693
- return ""
1694
- regex = "|".join(re_escape(stuff) for stuff in to_convert)
1695
- regex = f"(?P<{directive}>{regex}"
1696
- return "%s)" % regex
1697
-
1698
1808
  def __repr__(self):
1699
1809
  """Convert to formal string, for repr()."""
1700
1810
  d_datetime = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persiantools
3
- Version: 5.2.1
3
+ Version: 5.3.0
4
4
  Summary: Jalali date and datetime with other tools
5
5
  Home-page: https://github.com/majiidd/persiantools
6
6
  Author: Majid Hajiloo
@@ -8,7 +8,6 @@ Author-email: majid.hajiloo@gmail.com
8
8
  License: MIT
9
9
  Keywords: jalali shamsi persian digits characters converter jalalidate jalalidatetime date datetime jdate jdatetime farsi
10
10
  Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
11
  Classifier: Natural Language :: Persian
13
12
  Classifier: Operating System :: OS Independent
14
13
  Classifier: Programming Language :: Python
@@ -18,7 +18,6 @@ setup(
18
18
  long_description_content_type="text/markdown",
19
19
  classifiers=[
20
20
  "Intended Audience :: Developers",
21
- "License :: OSI Approved :: MIT License",
22
21
  "Natural Language :: Persian",
23
22
  "Operating System :: OS Independent",
24
23
  "Programming Language :: Python",
@@ -90,3 +90,14 @@ class TestDigits(TestCase):
90
90
 
91
91
  with pytest.raises(TypeError):
92
92
  digits.to_word("123")
93
+
94
+ def test_fractional_part_too_long(self):
95
+ old_mantissa = digits.MANTISSA
96
+ try:
97
+ digits.MANTISSA = ("دهم",)
98
+ with self.assertRaises(ValueError, msg="Fractional part is too long"):
99
+ # 0.15 -> left = "0", right = "15", right.rstrip("0") == "15"
100
+ # mantissa_index = len("15") - 1 == 1, which is >= len(MANTISSA) == 1.
101
+ digits.to_word(0.15)
102
+ finally:
103
+ digits.MANTISSA = old_mantissa
@@ -13,6 +13,7 @@ class TestJalaliDate(TestCase):
13
13
  def test_shamsi_to_gregorian(self):
14
14
  cases = [
15
15
  (JalaliDate(1100, 1, 1), date(1721, 3, 21)),
16
+ (JalaliDate(1210, 12, 30), date(1832, 3, 20)),
16
17
  (JalaliDate(1367, 2, 14), date(1988, 5, 4)),
17
18
  (JalaliDate(1395, 3, 21), date(2016, 6, 10)),
18
19
  (JalaliDate(1395, 12, 9), date(2017, 2, 27)),
@@ -67,6 +68,7 @@ class TestJalaliDate(TestCase):
67
68
  (JalaliDate(1402, 1, 1), date(2023, 3, 21)),
68
69
  (JalaliDate(1403, 1, 1), date(2024, 3, 20)),
69
70
  (JalaliDate(1404, 1, 1), date(2025, 3, 21)),
71
+ (JalaliDate(1498, 12, 30), date(2120, 3, 20)),
70
72
  (JalaliDate(1505, 1, 1), date(2126, 3, 21)),
71
73
  (JalaliDate.today(), date.today()),
72
74
  ]
@@ -124,6 +126,9 @@ class TestJalaliDate(TestCase):
124
126
 
125
127
  def test_checkdate(self):
126
128
  cases = [
129
+ (1206, 12, 30, False),
130
+ (1210, 12, 30, True),
131
+ (1214, 12, 30, True),
127
132
  (1367, 2, 14, True),
128
133
  (1395, 12, 30, True),
129
134
  (1394, 12, 30, False),
@@ -136,14 +141,21 @@ class TestJalaliDate(TestCase):
136
141
  (1396, 7, 27, True),
137
142
  (1397, 11, 29, True),
138
143
  (1399, 11, 31, False),
144
+ (1400, 1, 32, False),
139
145
  (1400, 4, 25, True),
140
146
  (1400, 12, 30, False),
141
147
  (1403, 4, 3, True),
142
148
  (1403, 12, 30, True),
149
+ (1473, 12, 30, False),
150
+ (1474, 12, 30, True),
151
+ (1498, 12, 30, True),
143
152
  ]
144
153
  for year, month, day, valid in cases:
145
154
  self.assertEqual(JalaliDate.check_date(year, month, day), valid)
146
155
 
156
+ with pytest.raises(ValueError):
157
+ JalaliDate._check_date_fields(1404, 3, 16, "ar")
158
+
147
159
  def test_completeday(self):
148
160
  jdate = JalaliDate(1398, 3, 17)
149
161
  self.assertEqual(jdate.year, 1398)
@@ -211,6 +223,18 @@ class TestJalaliDate(TestCase):
211
223
  with pytest.raises(ValueError, match="locale must be 'en' or 'fa'"):
212
224
  jdate.replace(locale="de")
213
225
 
226
+ with pytest.raises(ValueError):
227
+ JalaliDate.days_before_month(0)
228
+
229
+ with pytest.raises(ValueError):
230
+ JalaliDate.days_before_month(13)
231
+
232
+ with pytest.raises(ValueError):
233
+ JalaliDate.days_in_month(13, 1404)
234
+
235
+ with pytest.raises(ValueError):
236
+ JalaliDate.days_in_month(0, 1400)
237
+
214
238
  def test_leap(self):
215
239
  cases = [
216
240
  # First 33-year cycle
@@ -409,6 +433,24 @@ class TestJalaliDate(TestCase):
409
433
  with pytest.raises(ValueError):
410
434
  JalaliDate.fromisoformat("2021W123")
411
435
 
436
+ with pytest.raises(ValueError):
437
+ JalaliDate.fromisoformat("1395-03")
438
+
439
+ with pytest.raises(ValueError):
440
+ JalaliDate.fromisoformat("13950301")
441
+
442
+ with pytest.raises(ValueError):
443
+ JalaliDate.fromisoformat("1395-13-01")
444
+
445
+ with pytest.raises(ValueError):
446
+ JalaliDate.fromisoformat("1400-01-32")
447
+
448
+ with pytest.raises(ValueError):
449
+ JalaliDate.fromisoformat("1395-03-01 extra")
450
+
451
+ with pytest.raises(ValueError):
452
+ JalaliDate.fromisoformat(" 1395-03-01")
453
+
412
454
  j_date = JalaliDate(1400, 1, 1)
413
455
  self.assertEqual(j_date.strftime("%A, %d %B %Y"), "Yekshanbeh, 01 Farvardin 1400")
414
456
  self.assertEqual(j_date.strftime("%A, %d %B %Y", locale="fa"), "یکشنبه, ۰۱ فروردین ۱۴۰۰")
@@ -561,8 +603,145 @@ class TestJalaliDate(TestCase):
561
603
  self.assertEqual(repr(JalaliDate(1403, 4, 7)), "JalaliDate(1403, 4, 7, Panjshanbeh)")
562
604
 
563
605
  def test_strptime(self):
564
- with self.assertRaises(NotImplementedError):
565
- JalaliDate.strptime("1400-01-01", "%Y-%m-%d")
606
+ # 1. Basic parsing
607
+ self.assertEqual(JalaliDate.strptime("1367-02-14", "%Y-%m-%d"), JalaliDate(1367, 2, 14))
608
+ self.assertEqual(JalaliDate.strptime("1367/02/14", "%Y/%m/%d"), JalaliDate(1367, 2, 14))
609
+ self.assertEqual(JalaliDate.strptime("13670214", "%Y%m%d"), JalaliDate(1367, 2, 14))
610
+ self.assertEqual(JalaliDate.strptime("16/03/1404", "%d/%m/%Y"), JalaliDate(1404, 3, 16))
611
+ self.assertEqual(JalaliDate.strptime("16/03/1404", "%d/%m/%Y", locale="en"), JalaliDate(1404, 3, 16))
612
+
613
+ # 2. Year variations
614
+ self.assertEqual(JalaliDate.strptime("99-01-01", "%y-%m-%d"), JalaliDate(1399, 1, 1)) # 99 > 70 => 1399
615
+ self.assertEqual(JalaliDate.strptime("00-01-01", "%y-%m-%d"), JalaliDate(1400, 1, 1))
616
+ self.assertEqual(JalaliDate.strptime("01-01-01", "%y-%m-%d"), JalaliDate(1401, 1, 1)) # 01 <= 70 => 1401
617
+ self.assertEqual(JalaliDate.strptime("04-01-01", "%y-%m-%d"), JalaliDate(1404, 1, 1))
618
+ self.assertEqual(JalaliDate.strptime("70-10-10", "%y-%m-%d"), JalaliDate(1470, 10, 10)) # 70 => 1470
619
+
620
+ # 3. Month variations
621
+ self.assertEqual(JalaliDate.strptime("1400-01-15", "%Y-%m-%d"), JalaliDate(1400, 1, 15))
622
+ self.assertEqual(JalaliDate.strptime("1400-Far-15", "%Y-%b-%d"), JalaliDate(1400, 1, 15))
623
+ self.assertEqual(JalaliDate.strptime("1400-FAR-15", "%Y-%b-%d"), JalaliDate(1400, 1, 15))
624
+ self.assertEqual(JalaliDate.strptime("1400-far-15", "%Y-%b-%d"), JalaliDate(1400, 1, 15))
625
+ self.assertEqual(JalaliDate.strptime("1367-Ord-14", "%Y-%b-%d"), JalaliDate(1367, 2, 14))
626
+ self.assertEqual(JalaliDate.strptime("1400-Farvardin-15", "%Y-%B-%d"), JalaliDate(1400, 1, 15))
627
+ self.assertEqual(JalaliDate.strptime("1400-FARVARDIN-15", "%Y-%B-%d"), JalaliDate(1400, 1, 15))
628
+ self.assertEqual(JalaliDate.strptime("1400-farvardin-15", "%Y-%B-%d"), JalaliDate(1400, 1, 15))
629
+ self.assertEqual(JalaliDate.strptime("1400-Ordibehesht-10", "%Y-%B-%d"), JalaliDate(1400, 2, 10))
630
+ self.assertEqual(JalaliDate.strptime("1398-Esf-05", "%Y-%b-%d"), JalaliDate(1398, 12, 5))
631
+ self.assertEqual(JalaliDate.strptime("1403-Esfand-30", "%Y-%B-%d"), JalaliDate(1403, 12, 30))
632
+
633
+ # 4. Day variations
634
+ self.assertEqual(JalaliDate.strptime("1404-03-05", "%Y-%m-%d"), JalaliDate(1404, 3, 5))
635
+ self.assertEqual(JalaliDate.strptime("1404-03-31", "%Y-%m-%d"), JalaliDate(1404, 3, 31))
636
+
637
+ # 5. Locale testing ('en' and 'fa')
638
+ self.assertEqual(
639
+ JalaliDate.strptime("1401-Ord-05", "%Y-%b-%d", locale="en"), JalaliDate(1401, 2, 5, locale="en")
640
+ )
641
+
642
+ # Persian locale
643
+ self.assertEqual(
644
+ JalaliDate.strptime("۱۴۰۰-۰۱-۱۵", "%Y-%m-%d", locale="fa"), JalaliDate(1400, 1, 15, locale="fa")
645
+ )
646
+ self.assertEqual(
647
+ JalaliDate.strptime("۱۴۰۰-فروردین-۱۵", "%Y-%B-%d", locale="fa"), JalaliDate(1400, 1, 15, locale="fa")
648
+ )
649
+ self.assertEqual(
650
+ JalaliDate.strptime("۱۴۰۰-فرو-۱۵", "%Y-%b-%d", locale="fa"), JalaliDate(1400, 1, 15, locale="fa")
651
+ )
652
+ self.assertEqual(
653
+ JalaliDate.strptime("۱۴۰۰-اردیبهشت-۱۰", "%Y-%B-%d", locale="fa"), JalaliDate(1400, 2, 10, locale="fa")
654
+ )
655
+ self.assertEqual(
656
+ JalaliDate.strptime("۱۳۹۸-اسفند-۰۵", "%Y-%B-%d", locale="fa"), JalaliDate(1398, 12, 5, locale="fa")
657
+ )
658
+ self.assertEqual(
659
+ JalaliDate.strptime("۱۳۹۸-اسف-۰۵", "%Y-%b-%d", locale="fa"), JalaliDate(1398, 12, 5, locale="fa")
660
+ )
661
+ # Mixed Farsi digits and names
662
+ self.assertEqual(
663
+ JalaliDate.strptime("1398-اسفند-۰۵", "%Y-%B-%d", locale="fa"), JalaliDate(1398, 12, 5, locale="fa")
664
+ )
665
+
666
+ # 6. Format strings with literals and different separators
667
+ self.assertEqual(JalaliDate.strptime("Date: 1404/12 Day: 29", "Date: %Y/%m Day: %d"), JalaliDate(1404, 12, 29))
668
+ self.assertEqual(
669
+ JalaliDate.strptime("Year 1367 Month 02 Day 14", "Year %Y Month %m Day %d"), JalaliDate(1367, 2, 14)
670
+ )
671
+ # self.assertEqual(JalaliDate.strptime("1400|01|01", "%Y|%m|%d"), JalaliDate(1400, 1, 1))
672
+ self.assertEqual(JalaliDate.strptime("Jalali:1403-10-20", "Jalali:%Y-%m-%d"), JalaliDate(1403, 10, 20))
673
+
674
+ # 7. Weekday directives (%a and %A) - parsed but ignored for date construction
675
+ self.assertEqual(
676
+ JalaliDate.strptime("Jomeh, 1404-03-16", "%A, %Y-%m-%d"), JalaliDate(1404, 3, 16)
677
+ ) # 1404-03-16 is a Jomeh
678
+ self.assertEqual(JalaliDate.strptime("Sha, 1400-01-06", "%a, %Y-%m-%d"), JalaliDate(1400, 1, 6))
679
+ self.assertEqual(JalaliDate.strptime("Yekshanbeh 1400-Farvardin-01", "%A %Y-%B-%d"), JalaliDate(1400, 1, 1))
680
+ # Test with fa locale weekdays
681
+ self.assertEqual(
682
+ JalaliDate.strptime("شنبه, ۱۴۰۰-۰۱-۰۶", "%A, %Y-%m-%d", locale="fa"), JalaliDate(1400, 1, 6, locale="fa")
683
+ )
684
+ self.assertEqual(
685
+ JalaliDate.strptime("ش, ۱۴۰۰-۰۱-۰۶", "%a, %Y-%m-%d", locale="fa"), JalaliDate(1400, 1, 6, locale="fa")
686
+ )
687
+
688
+ # 8. Invalid inputs
689
+ with self.assertRaises(ValueError, msg="Month out of range"):
690
+ JalaliDate.strptime("1400-13-01", "%Y-%m-%d")
691
+ with self.assertRaises(ValueError, msg="Day out of range for month"):
692
+ JalaliDate.strptime("1400-01-32", "%Y-%m-%d")
693
+ with self.assertRaises(ValueError, msg="Day out of range for month (Mehr has 30 days)"):
694
+ JalaliDate.strptime("1400-07-31", "%Y-%m-%d")
695
+ with self.assertRaises(ValueError, msg="Invalid month name"):
696
+ JalaliDate.strptime("1399/Feb/20", "%Y/%b/%d")
697
+ with self.assertRaises(ValueError, msg="String does not match format"):
698
+ JalaliDate.strptime("1400/01/01 Extra", "%Y/%m/%d")
699
+ with self.assertRaises(ValueError, msg="String does not match format - incorrect separator"):
700
+ JalaliDate.strptime("1400.01.01", "%Y-%m-%d")
701
+ with self.assertRaises(ValueError, msg="Year with %Y must be 4 digits"):
702
+ JalaliDate.strptime("123-01-01", "%Y-%m-%d")
703
+ with self.assertRaises(ValueError, msg="Year with %y must be 2 digits"): # The regex for %y is \d{2}
704
+ JalaliDate.strptime("1-01-01", "%y-%m-%d")
705
+ with self.assertRaises(ValueError, msg="Date string does not match format (empty date string)"):
706
+ JalaliDate.strptime("", "%Y-%m-%d")
707
+ with self.assertRaises(ValueError, msg="Date string does not match format (empty format string)"):
708
+ JalaliDate.strptime("1400-01-01", "")
709
+ with self.assertRaises(ValueError, msg="Month information missing"):
710
+ JalaliDate.strptime("1400--01", "%Y-%m-%d")
711
+ with self.assertRaises(ValueError, msg="Day information missing"):
712
+ JalaliDate.strptime("1400-01-", "%Y-%m-%d")
713
+ with self.assertRaises(ValueError, msg="Year information missing"):
714
+ JalaliDate.strptime("-01-01", "%Y-%m-%d")
715
+ with self.assertRaises(ValueError):
716
+ JalaliDate.strptime("1402-12-30", "%Y-%m-%d")
717
+ with self.assertRaises(ValueError):
718
+ JalaliDate.strptime("98-12-30", "%y-%m-%d")
719
+ with self.assertRaises(ValueError):
720
+ JalaliDate.strptime("1404-01-01", "%Y-%m-%d", "ar")
721
+
722
+ # 9. Edge cases
723
+ # Leap year: 1399 was a leap year (Esfand has 30 days)
724
+ self.assertEqual(JalaliDate.strptime("1399-12-30", "%Y-%m-%d"), JalaliDate(1399, 12, 30))
725
+ # Non-leap year: 1400 was not a leap year (Esfand has 29 days)
726
+ self.assertEqual(JalaliDate.strptime("1400-12-29", "%Y-%m-%d"), JalaliDate(1400, 12, 29))
727
+ with self.assertRaises(ValueError, msg="Esfand 30 on non-leap year 1400"):
728
+ JalaliDate.strptime("1400-12-30", "%Y-%m-%d")
729
+
730
+ # Leap year: 1403 is a leap year
731
+ self.assertEqual(JalaliDate.strptime("1403-12-30", "%Y-%m-%d"), JalaliDate(1403, 12, 30))
732
+ self.assertEqual(JalaliDate.strptime("03-12-30", "%y-%m-%d"), JalaliDate(1403, 12, 30))
733
+
734
+ self.assertEqual(JalaliDate.strptime(f"{MINYEAR:04d}-01-01", "%Y-%m-%d"), JalaliDate(MINYEAR, 1, 1))
735
+ self.assertEqual(JalaliDate.strptime(f"{MAXYEAR:04d}-12-29", "%Y-%m-%d"), JalaliDate(MAXYEAR, 12, 29))
736
+
737
+ # Test %x and %c replacements
738
+ # %x: %Y/%m/%d
739
+ self.assertEqual(JalaliDate.strptime("1399/05/10", "%x"), JalaliDate(1399, 5, 10))
740
+ # %c: %a %b %d %Y
741
+ self.assertEqual(JalaliDate.strptime("Sha Far 01 1380", "%c"), JalaliDate(1380, 1, 1)) # 1380-01-01 is Shanbeh
742
+ self.assertEqual(JalaliDate.strptime("ش فرو ۰۱ ۱۳۸۰", "%c", locale="fa"), JalaliDate(1380, 1, 1, locale="fa"))
743
+
744
+ self.assertEqual(JalaliDate.strptime("1400-Tir-15", "%Y-%b-%d"), JalaliDate(1400, 4, 15))
566
745
 
567
746
  def test_locale_setter_invalid_value(self):
568
747
  jdate = JalaliDate.today()
@@ -92,6 +92,23 @@ class TestJalaliDateTime(TestCase):
92
92
  time.struct_time((1988, 5, 4, 14, 0, 0, 2, 125, 0)),
93
93
  )
94
94
 
95
+ def test_check_utc_offset(self):
96
+ JalaliDateTime.check_utc_offset("utcoffset", None)
97
+ JalaliDateTime.check_utc_offset("dst", None)
98
+
99
+ offset = timedelta(minutes=30)
100
+ JalaliDateTime.check_utc_offset("utcoffset", offset)
101
+ JalaliDateTime.check_utc_offset("dst", offset)
102
+
103
+ with pytest.raises(AssertionError):
104
+ JalaliDateTime.check_utc_offset("invalid", timedelta(minutes=30))
105
+
106
+ with pytest.raises(TypeError):
107
+ JalaliDateTime.check_utc_offset("utcoffset", 30)
108
+
109
+ with pytest.raises(ValueError):
110
+ JalaliDateTime.check_utc_offset("dst", timedelta(seconds=61))
111
+
95
112
  def test_others(self):
96
113
  self.assertTrue(JalaliDateTime.fromtimestamp(time.time() - 10) <= JalaliDateTime.now())
97
114
  self.assertEqual(JalaliDateTime(1367, 2, 14, 4, 30, 0, 0, pytz.utc).timestamp(), 578723400)
@@ -114,6 +131,9 @@ class TestJalaliDateTime(TestCase):
114
131
  self.assertIsNone(JalaliDateTime.today().tzname())
115
132
  self.assertIsNone(JalaliDateTime.today().dst())
116
133
 
134
+ dt = JalaliDateTime(1367, 2, 14, 4, 30, 0, 0, pytz.utc)
135
+ self.assertEqual(dt.dst(), timedelta(0))
136
+
117
137
  self.assertEqual(
118
138
  JalaliDateTime(1367, 2, 14, 4, 30, 0, 0).ctime(),
119
139
  "Chaharshanbeh 14 Ordibehesht 1367 04:30:00",
@@ -329,6 +349,11 @@ class TestJalaliDateTime(TestCase):
329
349
  jdt = JalaliDateTime(1374, 4, 8, 16, 28, 3, 227, pytz.utc)
330
350
  self.assertEqual(jdt, JalaliDateTime.strptime(jdt.strftime("%c %f %z %Z"), "%c %f %z %Z"))
331
351
 
352
+ jdt_utc_combined = JalaliDateTime(1374, 4, 8, 16, 28, 3, 227, timezone.utc)
353
+ self.assertEqual(
354
+ jdt_utc_combined, JalaliDateTime.strptime(jdt_utc_combined.strftime("%c %f %z %Z"), "%c %f %z %Z")
355
+ )
356
+
332
357
  def test_strptime_basic(self):
333
358
  date_string = "1400-01-01 12:30:45"
334
359
  fmt = "%Y-%m-%d %H:%M:%S"
@@ -352,6 +377,98 @@ class TestJalaliDateTime(TestCase):
352
377
  self.assertEqual(jdt.second, 45)
353
378
  self.assertEqual(jdt.utcoffset(), timedelta(hours=3, minutes=30))
354
379
 
380
+ def test_strptime_z_directive_valid_formats(self):
381
+ base_dt_str = "1400-01-01 10:00:00"
382
+ fmt_base = "%Y-%m-%d %H:%M:%S"
383
+
384
+ valid_z_formats = [
385
+ ("+0330", 3, 30),
386
+ ("-0500", -5, 0),
387
+ ("+0430", 4, 30),
388
+ ("-0715", -7, -15),
389
+ ("+0000", 0, 0),
390
+ ("-0000", 0, 0),
391
+ ("+1400", 14, 0),
392
+ ("-1400", -14, 0),
393
+ ("+0059", 0, 59),
394
+ ]
395
+
396
+ for tz_str, hours, minutes in valid_z_formats:
397
+ date_string = f"{base_dt_str} {tz_str}"
398
+ fmt = f"{fmt_base} %z"
399
+ with self.subTest(date_string=date_string, fmt=fmt):
400
+ jdt = JalaliDateTime.strptime(date_string, fmt)
401
+ self.assertIsNotNone(jdt.tzinfo, f"tzinfo should not be None for {date_string}")
402
+ self.assertIsInstance(jdt.tzinfo, timezone, f"tzinfo should be datetime.timezone for {date_string}")
403
+ expected_offset = timedelta(hours=hours, minutes=minutes)
404
+ self.assertEqual(jdt.utcoffset(), expected_offset, f"Offset mismatch for {date_string}")
405
+ self.assertEqual(jdt.year, 1400)
406
+ self.assertEqual(jdt.hour, 10)
407
+
408
+ def test_strptime_z_directive_invalid_formats(self):
409
+ base_dt_str = "1400-01-01 10:00:00"
410
+ fmt_base = "%Y-%m-%d %H:%M:%S"
411
+
412
+ invalid_z_formats = [
413
+ "0330", # Missing sign: In Python, the %z directive requires a leading '+' or '-' for the timezone offset.
414
+ "+330", # Incorrect number of digits (hour)
415
+ "+033", # Incorrect number of digits (minute)
416
+ "+03:3", # Incorrect number of digits (minute with colon)
417
+ "+03-30", # Invalid separator
418
+ "03:30", # Missing sign with colon
419
+ "+03:300", # Too many minute digits
420
+ "+030:30", # Too many hour digits
421
+ "+0A30", # Non-numeric hour
422
+ "+03B0", # Non-numeric minute
423
+ "+2500", # Hour out of range (max is +2359 or +1400 for this specific regex in Python for %z)
424
+ "+2400", # Hour part out of range (>=24)
425
+ "-2400",
426
+ "+0360", # Minute part out of range (>=60)
427
+ "-0360",
428
+ "+03:60",
429
+ " +0330", # Leading space before offset
430
+ "+0330 ", # Trailing space after offset (will cause full string match failure)
431
+ "GMT+0330", # Non-standard prefix
432
+ ]
433
+
434
+ for tz_str in invalid_z_formats:
435
+ date_string = f"{base_dt_str} {tz_str}"
436
+ fmt = f"{fmt_base} %z"
437
+ with self.subTest(date_string=date_string, fmt=fmt):
438
+ with self.assertRaises(ValueError, msg=f"Failed for invalid tz string: {tz_str}"):
439
+ JalaliDateTime.strptime(date_string, fmt)
440
+
441
+ # Test %z without enough characters following
442
+ with self.assertRaises(ValueError):
443
+ JalaliDateTime.strptime(f"{base_dt_str} +", f"{fmt_base} %z")
444
+ with self.assertRaises(ValueError):
445
+ JalaliDateTime.strptime(f"{base_dt_str} +03", f"{fmt_base} %z")
446
+
447
+ def test_strptime_z_directive_locale_independence(self):
448
+ base_dt_str = "1400-01-01 10:00:00"
449
+ tz_str = "+0545"
450
+ fmt = "%Y-%m-%d %H:%M:%S %z"
451
+ date_string = f"{base_dt_str} {tz_str}"
452
+ expected_offset = timedelta(hours=5, minutes=45)
453
+
454
+ # Test with locale 'en'
455
+ jdt_en = JalaliDateTime.strptime(date_string, fmt, locale="en")
456
+ self.assertIsNotNone(jdt_en.tzinfo)
457
+ self.assertEqual(jdt_en.utcoffset(), expected_offset)
458
+ self.assertEqual(jdt_en.year, 1400)
459
+
460
+ # Test with locale 'fa'
461
+ jdt_fa = JalaliDateTime.strptime(date_string, fmt, locale="fa")
462
+ self.assertIsNotNone(jdt_fa.tzinfo)
463
+ self.assertEqual(jdt_fa.utcoffset(), expected_offset)
464
+ self.assertEqual(jdt_fa.year, 1400)
465
+
466
+ date_string_fa_main = f"۱۴۰۰-۰۱-۰۱ ۱۰:۰۰:۰۰ {tz_str}"
467
+ jdt_fa_main = JalaliDateTime.strptime(date_string_fa_main, fmt, locale="fa")
468
+ self.assertIsNotNone(jdt_fa_main.tzinfo)
469
+ self.assertEqual(jdt_fa_main.utcoffset(), expected_offset)
470
+ self.assertEqual(jdt_fa_main.year, 1400)
471
+
355
472
  def test_strptime_with_locale_fa(self):
356
473
  date_string = "۱۴۰۰-۰۱-۰۱ ۱۲:۳۰:۴۵"
357
474
  fmt = "%Y-%m-%d %H:%M:%S"
@@ -612,6 +729,11 @@ class TestJalaliDateTime(TestCase):
612
729
  with self.assertRaises(ValueError):
613
730
  JalaliDateTime.fromisoformat("invalid-date-time")
614
731
 
732
+ self.assertEqual(JalaliDateTime._find_isoformat_datetime_separator("2021W12"), 7)
733
+
734
+ with pytest.raises(ValueError, match="Invalid ISO string"):
735
+ JalaliDateTime._find_isoformat_datetime_separator("2021-W12-")
736
+
615
737
  def test_find_isoformat_datetime_separator(self):
616
738
  separator = JalaliDateTime._find_isoformat_datetime_separator("1403-08-09T02:21:45")
617
739
  self.assertEqual(separator, 10)
@@ -637,3 +759,66 @@ class TestJalaliDateTime(TestCase):
637
759
  iso_format = original.isoformat()
638
760
  parsed = JalaliDateTime.fromisoformat(iso_format)
639
761
  self.assertEqual(original, parsed)
762
+
763
+ def test_isoformat_timezone_representation(self):
764
+ # 1. UTC Timezone
765
+ jdt_utc = JalaliDateTime(1400, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
766
+ self.assertEqual(jdt_utc.isoformat(), "1400-01-01T12:00:00+00:00")
767
+
768
+ # 2. Positive Offset Timezones
769
+ tz_plus_3_30 = timezone(timedelta(hours=3, minutes=30))
770
+ jdt_plus_3_30 = JalaliDateTime(1401, 2, 15, 10, 30, 0, tzinfo=tz_plus_3_30)
771
+ self.assertEqual(jdt_plus_3_30.isoformat(), "1401-02-15T10:30:00+03:30")
772
+
773
+ tz_plus_5 = timezone(timedelta(hours=5))
774
+ jdt_plus_5 = JalaliDateTime(1402, 3, 20, 8, 0, 0, tzinfo=tz_plus_5)
775
+ self.assertEqual(jdt_plus_5.isoformat(), "1402-03-20T08:00:00+05:00")
776
+
777
+ # 3. Negative Offset Timezones
778
+ tz_minus_4_45 = timezone(timedelta(hours=-4, minutes=-45))
779
+ jdt_minus_4_45 = JalaliDateTime(1403, 4, 10, 16, 15, 0, tzinfo=tz_minus_4_45)
780
+ self.assertEqual(jdt_minus_4_45.isoformat(), "1403-04-10T16:15:00-04:45")
781
+
782
+ tz_minus_4_45 = timezone(timedelta(hours=-4, minutes=45))
783
+ jdt_minus_4_45 = JalaliDateTime(1403, 4, 10, 16, 15, 0, tzinfo=tz_minus_4_45)
784
+ self.assertEqual(jdt_minus_4_45.isoformat(), "1403-04-10T16:15:00-03:15")
785
+
786
+ tz_direct_minus_4_45 = timezone(timedelta(seconds=-(4 * 3600 + 45 * 60)))
787
+ jdt_direct_minus_4_45 = JalaliDateTime(1403, 4, 10, 16, 15, 0, tzinfo=tz_direct_minus_4_45)
788
+ self.assertEqual(jdt_direct_minus_4_45.isoformat(), "1403-04-10T16:15:00-04:45")
789
+
790
+ tz_minus_8 = timezone(timedelta(hours=-8))
791
+ jdt_minus_8 = JalaliDateTime(1399, 12, 29, 23, 30, 59, tzinfo=tz_minus_8)
792
+ self.assertEqual(jdt_minus_8.isoformat(), "1399-12-29T23:30:59-08:00")
793
+
794
+ # 4. Offsets with zero minutes (covered by +05:00 and -08:00 above)
795
+ self.assertEqual(
796
+ JalaliDateTime(1402, 3, 20, 8, 0, 0, tzinfo=timezone(timedelta(hours=5))).isoformat(),
797
+ "1402-03-20T08:00:00+05:00",
798
+ )
799
+
800
+ # 5. Microseconds with timezone offset
801
+ jdt_utc_ms = JalaliDateTime(1400, 1, 1, 12, 30, 45, 123456, tzinfo=timezone.utc)
802
+ self.assertEqual(jdt_utc_ms.isoformat(), "1400-01-01T12:30:45.123456+00:00")
803
+
804
+ jdt_offset_ms = JalaliDateTime(1400, 6, 10, 10, 20, 30, 654321, tzinfo=tz_plus_3_30)
805
+ self.assertEqual(jdt_offset_ms.isoformat(), "1400-06-10T10:20:30.654321+03:30")
806
+
807
+ # 6. Default Separator 'T' (implicitly tested in all above cases)
808
+ self.assertIn("T", jdt_utc.isoformat())
809
+
810
+ # 7. Custom Separator
811
+ jdt_custom_sep = JalaliDateTime(1400, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
812
+ self.assertEqual(jdt_custom_sep.isoformat(sep=" "), "1400-01-01 12:00:00+00:00")
813
+
814
+ jdt_custom_sep_offset = JalaliDateTime(1401, 5, 5, 10, 10, 10, tzinfo=tz_minus_8)
815
+ self.assertEqual(jdt_custom_sep_offset.isoformat(sep="_"), "1401-05-05_10:10:10-08:00")
816
+
817
+ # 8. Naive JalaliDateTime (no offset string)
818
+ jdt_naive = JalaliDateTime(1398, 10, 5, 12, 30, 0)
819
+ self.assertEqual(jdt_naive.isoformat(), "1398-10-05T12:30:00")
820
+ # With microseconds
821
+ jdt_naive_ms = JalaliDateTime(1398, 10, 5, 12, 30, 0, 123)
822
+ self.assertEqual(jdt_naive_ms.isoformat(), "1398-10-05T12:30:00.000123")
823
+ # With custom separator
824
+ self.assertEqual(jdt_naive.isoformat(sep=" "), "1398-10-05 12:30:00")
File without changes
File without changes
File without changes