From 7e6aabb9d2e45245c06f9e5a5b39015ed639106d Mon Sep 17 00:00:00 2001 From: Majid Hajiloo Date: Fri, 28 Jun 2024 03:11:47 +0330 Subject: [PATCH] Add comprehensive tests and detailed docstrings --- .gitignore | 1 + .pre-commit-config.yaml | 6 +- persiantools/digits.py | 141 ++++++++++++++++++---- persiantools/jdatetime.py | 149 +++++++++++++++++++++++- tests/test_digits.py | 16 +++ tests/test_jalalidate.py | 68 +++++++++-- tests/test_jalalidatetime.py | 220 ++++++++++++++++++++++++++++++++++- 7 files changed, 563 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index 73f9239..0cea0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,4 @@ dmypy.json .idea/ cover/ .vscode/ +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9612f35..891a54b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.16.0 hooks: - id: pyupgrade args: ["--py38-plus"] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files args: [ '--maxkb=256' ] @@ -31,7 +31,7 @@ repos: types_or: [ python, pyi ] - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 additional_dependencies: [ flake8-bugbear, flake8-implicit-str-concat ] diff --git a/persiantools/digits.py b/persiantools/digits.py index 57a2b0e..0ac8397 100644 --- a/persiantools/digits.py +++ b/persiantools/digits.py @@ -98,58 +98,114 @@ class OutOfRangeException(Exception): def en_to_fa(string: str) -> str: - """Convert EN digits to Persian + """ + Convert English digits to Persian digits. + + This function takes a string containing English digits and converts them to their + corresponding Persian digits. + + Parameters: + string (str): A string containing English digits to be converted. - Usage:: + Returns: + str: A string with English digits converted to Persian digits. + + Example: >>> from persiantools import digits >>> converted = digits.en_to_fa("0123456789") - - :param string: A string, will be converted - :rtype: str + >>> print(converted) + ۰۱۲۳۴۵۶۷۸۹ """ return EN_TO_FA_REGEX.sub(lambda x: EN_TO_FA_MAP[x.group()], string) def ar_to_fa(string: str) -> str: - """Convert Arabic digits to Persian + """ + Convert Arabic digits to Persian digits. - Usage:: + This function takes a string containing Arabic digits and converts them to their + corresponding Persian digits. + + Parameters: + string (str): A string containing Arabic digits to be converted. + + Returns: + str: A string with Arabic digits converted to Persian digits. + + Example: >>> from persiantools import digits >>> converted = digits.ar_to_fa("٠١٢٣٤٥٦٧٨٩") - - :param string: A string, will be converted - :rtype: str + >>> print(converted) + ۰۱۲۳۴۵۶۷۸۹ """ return AR_TO_FA_REGEX.sub(lambda x: AR_TO_FA_MAP[x.group()], string) def fa_to_en(string: str) -> str: - """Convert Persian digits to EN + """ + Convert Persian digits to English digits. - Usage:: + This function takes a string containing Persian digits and converts them to their + corresponding English digits. + + Parameters: + string (str): A string containing Persian digits to be converted. + + Returns: + str: A string with Persian digits converted to English digits. + + Example: >>> from persiantools import digits >>> converted = digits.fa_to_en("۰۱۲۳۴۵۶۷۸۹") - - :param string: A string, will be converted - :rtype: str + >>> print(converted) + 0123456789 """ return FA_TO_EN_REGEX.sub(lambda x: FA_TO_EN_MAP[x.group()], string) def fa_to_ar(string: str) -> str: - """Convert Persian digits to Arabic + """ + Convert Persian digits to Arabic digits. - Usage:: + This function takes a string containing Persian digits and converts them to their + corresponding Arabic digits. + + Parameters: + string (str): A string containing Persian digits to be converted. + + Returns: + str: A string with Persian digits converted to Arabic digits. + + Example: >>> from persiantools import digits >>> converted = digits.fa_to_ar("۰۱۲۳۴۵۶۷۸۹") - - :param string: A string, will be converted - :rtype: str + >>> print(converted) + ٠١٢٣٤٥٦٧٨٩ """ return FA_TO_AR_REGEX.sub(lambda x: FA_TO_AR_MAP[x.group()], string) def _to_word(number: int, depth: bool) -> str: + """ + Convert a number to its Persian word representation. + + This function takes an integer and converts it to its Persian word representation. + It handles numbers up to 1,000,000,000,000,000 (one quadrillion). + + Parameters: + number (int): The number to be converted. + depth (bool): A flag indicating if the function is called recursively. + + Returns: + str: The Persian word representation of the number. + + Raises: + OutOfRangeException: If the number is outside the supported range. + + Example: + >>> print(_to_word(123, False)) + یکصد و بیست و سه + """ if number == 0: return ZERO if not depth else "" @@ -169,6 +225,26 @@ def _to_word(number: int, depth: bool) -> str: def _floating_number_to_word(number: float, depth: bool) -> str: + """ + Convert a floating-point number to its Persian word representation. + + This function takes a floating-point number and converts it to its Persian word representation. + It handles floating-point numbers up to 14 decimal places. + + Parameters: + number (float): The floating-point number to be converted. + depth (bool): A flag indicating if the function is called recursively. + + Returns: + str: The Persian word representation of the floating-point number. + + Raises: + OutOfRangeException: If the floating-point number has more than 14 decimal places. + + Example: + >>> print(_floating_number_to_word(123.456, False)) + یکصد و بیست و سه و چهارصد و پنجاه و شش هزارم + """ left, right = str(abs(number)).split(".") if len(right) > 14: raise OutOfRangeException("You are allowed to use 14 digits for a floating point") @@ -190,6 +266,31 @@ def _floating_number_to_word(number: float, depth: bool) -> str: def to_word(number: (float, int)) -> str: + """ + Convert a number to its Persian word representation. + + This function converts both integers and floating-point numbers to their + Persian word representations. It handles numbers up to 1 quadrillion (10^15) + for integers and up to 14 decimal places for floating-point numbers. + + Parameters: + number (float or int): The number to be converted. It can be an integer or a floating-point number. + + Returns: + str: The Persian word representation of the number. + + Raises: + OutOfRangeException: If the number is greater than or equal to 1 quadrillion (10^15) or if the + floating-point number has more than 14 decimal places. + TypeError: If the input is not a float or an int. + + Examples: + >>> digits.to_word(123) + 'یکصد و بیست و سه' + + >>> digits.to_word(123.456) + 'یکصد و بیست و سه و چهارصد و پنجاه و شش هزارم' + """ if isinstance(number, int): return _to_word(number, False) elif isinstance(number, float): diff --git a/persiantools/jdatetime.py b/persiantools/jdatetime.py index cad6b58..0605aa3 100644 --- a/persiantools/jdatetime.py +++ b/persiantools/jdatetime.py @@ -222,7 +222,31 @@ def days_before_month(month): @classmethod def to_jalali(cls, year, month=None, day=None): - """based on jdf.scr.ir""" + """ + Convert a Gregorian date to a Jalali (Persian) date. + + This method converts a given Gregorian date (or a datetime.date object) to its + corresponding Jalali (Persian) date. If a datetime.date object is provided, + the month and day parameters are automatically extracted. + + Parameters: + year (int or datetime.date): The year of the Gregorian date, or a datetime.date object. + month (int, optional): The month of the Gregorian date. + day (int, optional): The day of the Gregorian date. + + Returns: + JalaliDate: A JalaliDate object representing the corresponding Jalali date. + + Example: + >>> g_date = date(2021, 3, 21) + >>> j_date = JalaliDate.to_jalali(g_date) + >>> print(j_date) + JalaliDate(1400, 1, 1) + + >>> j_date = JalaliDate.to_jalali(2021, 3, 21) + >>> print(j_date) + JalaliDate(1400, 1, 1) + """ if month is None and isinstance(year, date): month = year.month day = year.day @@ -265,7 +289,21 @@ def to_jalali(cls, year, month=None, day=None): return cls(jalali_year, jalali_month, jalali_day) def to_gregorian(self): - """based on jdf.scr.ir""" + """ + Convert a Jalali (Persian) date to a Gregorian date. + + This method converts the current Jalali (Persian) date instance to its + corresponding Gregorian date. + + Returns: + date: A datetime.date object representing the corresponding Gregorian date. + + Example: + >>> j_date = JalaliDate(1400, 1, 1) + >>> g_date = j_date.to_gregorian() + >>> print(g_date) + 2021-03-21 + """ year = self.year + 1595 month = self.month day = self.day @@ -803,6 +841,36 @@ def utctimetuple(self): return self.to_gregorian().utctimetuple() def astimezone(self, tz=None): + """ + Convert the current JalaliDateTime to another timezone. + + This method returns a new JalaliDateTime object representing the same + time instant in a different timezone. The returned object will have + its `tzinfo` attribute set to the new timezone. + + Parameters: + tz (tzinfo, optional): The timezone to convert the JalaliDateTime to. + If `None`, the method will use the system's local timezone. + The `tz` parameter must be an instance of a subclass of `datetime.tzinfo`. + + Returns: + JalaliDateTime: A new JalaliDateTime object with the same time instant in the specified timezone. + + Raises: + TypeError: If the `tz` parameter is not `None` and is not an instance of a subclass of `datetime.tzinfo`. + + Example: + >>> from datetime import timezone, timedelta + >>> jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(timedelta(hours=3))) + >>> jdt_utc = jdt.astimezone(timezone.utc) + >>> print(jdt_utc) + JalaliDateTime(1400, 1, 1, 9, 30, 45, tzinfo=datetime.timezone.utc) + + >>> new_tz = timezone(timedelta(hours=5)) + >>> jdt_new_tz = jdt.astimezone(new_tz) + >>> print(jdt_new_tz) + JalaliDateTime(1400, 1, 1, 17, 30, 45, tzinfo=datetime.timezone(datetime.timedelta(seconds=18000))) + """ return JalaliDateTime(self.to_gregorian().astimezone(tz)) def ctime(self): @@ -910,6 +978,36 @@ def to_jalali( microsecond=None, tzinfo=None, ): + """ + Convert a Gregorian date or datetime to a Jalali (Persian) datetime. + + This method converts a given Gregorian date or datetime to its corresponding + Jalali (Persian) date or datetime. The conversion considers all date and time + components, including year, month, day, hour, minute, second, and microsecond. + + Parameters: + year (int or datetime): The year of the Gregorian date, or a datetime object. + month (int, optional): The month of the Gregorian date. + day (int, optional): The day of the Gregorian date. + hour (int, optional): The hour of the Gregorian datetime. + minute (int, optional): The minute of the Gregorian datetime. + second (int, optional): The second of the Gregorian datetime. + microsecond (int, optional): The microsecond of the Gregorian datetime. + tzinfo (tzinfo, optional): The timezone information. + + Returns: + JalaliDateTime: A JalaliDateTime object representing the corresponding Jalali date and time. + + Example: + >>> g_date = datetime(2021, 3, 21, 15, 30, 45) + >>> j_date = JalaliDateTime.to_jalali(g_date) + >>> print(j_date) + JalaliDateTime(1400, 1, 1, 15, 30, 45) + + >>> j_date = JalaliDateTime.to_jalali(2021, 3, 21, 15, 30, 45) + >>> print(j_date) + JalaliDateTime(1400, 1, 1, 15, 30, 45) + """ if month is None and isinstance(year, dt): month = year.month day = year.day @@ -928,6 +1026,22 @@ def to_jalali( ) def to_gregorian(self): + """ + Convert a Jalali (Persian) datetime to a Gregorian datetime. + + This method converts the current Jalali (Persian) datetime instance to its + corresponding Gregorian datetime. It considers both date and time components, + including year, month, day, hour, minute, second, microsecond, and timezone. + + Returns: + datetime: A datetime.datetime object representing the corresponding Gregorian datetime. + + Example: + >>> j_datetime = JalaliDateTime(1400, 1, 1, 15, 30, 45) + >>> g_datetime = j_datetime.to_gregorian() + >>> print(g_datetime) + 2021-03-21 15:30:45 + """ g_date = super().to_gregorian() return dt.combine( @@ -1138,6 +1252,37 @@ def __base_compare(self, other): ) def _cmp(self, other, allow_mixed=False): + """ + Compare the current JalaliDateTime object with another JalaliDateTime object. + + This method compares two JalaliDateTime objects, taking into account their + timezone offsets. It returns: + - 0 if both objects represent the same point in time. + - 1 if the current object is later than the other. + - -1 if the current object is earlier than the other. + + Parameters: + other (JalaliDateTime): The other JalaliDateTime object to compare with. + allow_mixed (bool, optional): If True, allows comparison between naive and aware datetimes, + returning an arbitrary non-zero value. Defaults to False. + + Returns: + int: 0 if both objects represent the same time, 1 if the current object is later, + -1 if the current object is earlier. + + Raises: + TypeError: If trying to compare naive and aware datetimes when allow_mixed is False. + + Example: + >>> jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + >>> jdt2 = JalaliDateTime(1400, 1, 1, 13, 30, 45, tzinfo=timezone.utc) + >>> jdt1._cmp(jdt2) + -1 + >>> jdt2._cmp(jdt1) + 1 + >>> jdt1._cmp(jdt1) + 0 + """ assert isinstance(other, JalaliDateTime) mytz = self._tzinfo diff --git a/tests/test_digits.py b/tests/test_digits.py index 7bf6ee5..37151ad 100644 --- a/tests/test_digits.py +++ b/tests/test_digits.py @@ -11,6 +11,8 @@ def test_en_to_fa(self): self.assertEqual(digits.en_to_fa("0987654321"), "۰۹۸۷۶۵۴۳۲۱") self.assertEqual(digits.en_to_fa("۰۹۸۷۶۵۴۳۲۱"), "۰۹۸۷۶۵۴۳۲۱") self.assertEqual(digits.en_to_fa("+0987654321 abcd"), "+۰۹۸۷۶۵۴۳۲۱ abcd") + self.assertEqual(digits.en_to_fa(""), "") + self.assertEqual(digits.en_to_fa("abcd"), "abcd") with pytest.raises(TypeError): digits.en_to_fa(12345) @@ -18,6 +20,8 @@ def test_en_to_fa(self): def test_ar_to_fa(self): self.assertEqual(digits.ar_to_fa("٠٩٨٧٦٥٤٣٢١"), "۰۹۸۷۶۵۴۳۲۱") self.assertEqual(digits.ar_to_fa("٠٩٨٧٦٥٤٣٢١"), "۰۹۸۷۶۵۴۳۲۱") + self.assertEqual(digits.ar_to_fa(""), "") + self.assertEqual(digits.ar_to_fa("abcd"), "abcd") orig = "0987٦٥٤٣۲۱" converted = digits.en_to_fa(orig) @@ -27,16 +31,22 @@ def test_ar_to_fa(self): def test_fa_to_en(self): self.assertEqual(digits.fa_to_en("۰۹۸۷۶۵۴۳۲۱"), "0987654321") + self.assertEqual(digits.fa_to_en(""), "") + self.assertEqual(digits.fa_to_en("abcd"), "abcd") def test_fa_to_ar(self): self.assertEqual(digits.fa_to_ar("۰۹۸۷۶۵۴۳۲۱"), "٠٩٨٧٦٥٤٣٢١") self.assertEqual(digits.fa_to_ar(" ۰۹۸۷۶۵۴۳۲۱"), " ٠٩٨٧٦٥٤٣٢١") + self.assertEqual(digits.fa_to_ar(""), "") + self.assertEqual(digits.fa_to_ar("abcd"), "abcd") def test_to_letter(self): + self.assertEqual(digits.to_word(0), "صفر") self.assertEqual(digits.to_word(1), "یک") self.assertEqual(digits.to_word(12), "دوازده") self.assertEqual(digits.to_word(49), "چهل و نه") self.assertEqual(digits.to_word(77), "هفتاد و هفت") + self.assertEqual(digits.to_word(123), "یکصد و بیست و سه") self.assertEqual(digits.to_word(250), "دویست و پنجاه") self.assertEqual(digits.to_word(809), "هشتصد و نه") self.assertEqual(digits.to_word(1001), "یک هزار و یک") @@ -55,6 +65,9 @@ def test_to_letter(self): self.assertEqual(digits.to_word(15.007), "پانزده و هفت هزارم") self.assertEqual(digits.to_word(12519.85), "دوازده هزار و پانصد و نوزده و هشتاد و پنج صدم") self.assertEqual(digits.to_word(123.50), "یکصد و بیست و سه و پنج دهم") + self.assertEqual(digits.to_word(123.456), "یکصد و بیست و سه و چهارصد و پنجاه و شش هزارم") + self.assertEqual(digits.to_word(123.0), "یکصد و بیست و سه") + self.assertEqual(digits.to_word(-123.0), "منفی یکصد و بیست و سه") self.assertEqual( digits.to_word(-0.1554845), "منفی یک میلیون و پانصد و پنجاه و چهار هزار و هشتصد و چهل و پنج ده میلیونیم" @@ -62,3 +75,6 @@ def test_to_letter(self): with pytest.raises(digits.OutOfRangeException): digits.to_word(1000000000000001) + + with pytest.raises(TypeError): + digits.to_word("123") diff --git a/tests/test_jalalidate.py b/tests/test_jalalidate.py index f42e56d..4ea483b 100644 --- a/tests/test_jalalidate.py +++ b/tests/test_jalalidate.py @@ -17,30 +17,38 @@ def test_shamsi_to_gregorian(self): self.assertEqual(JalaliDate(1400, 6, 31).to_gregorian(), date(2021, 9, 22)) self.assertEqual(JalaliDate(1396, 7, 27).to_gregorian(), date(2017, 10, 19)) self.assertEqual(JalaliDate(1397, 11, 29).to_gregorian(), date(2019, 2, 18)) + self.assertEqual(JalaliDate(1398, 12, 29).to_gregorian(), date(2020, 3, 19)) + self.assertEqual(JalaliDate(1399, 10, 11).to_gregorian(), date(2020, 12, 31)) self.assertEqual(JalaliDate(1399, 11, 23).to_gregorian(), date(2021, 2, 11)) + self.assertEqual(JalaliDate(1399, 12, 29).to_gregorian(), date(2021, 3, 19)) self.assertEqual(JalaliDate(1400, 4, 25).to_gregorian(), date(2021, 7, 16)) self.assertEqual(JalaliDate(1400, 12, 20).to_gregorian(), date(2022, 3, 11)) self.assertEqual(JalaliDate(1403, 1, 5).to_gregorian(), date(2024, 3, 24)) self.assertEqual(JalaliDate(1402, 10, 10).to_gregorian(), date(2023, 12, 31)) + self.assertEqual(JalaliDate(1402, 12, 29).to_gregorian(), date(2024, 3, 19)) self.assertEqual(JalaliDate(1403, 10, 11).to_gregorian(), date(2024, 12, 31)) self.assertEqual(JalaliDate(1403, 2, 23).to_gregorian(), date(2024, 5, 12)) self.assertEqual(JalaliDate(1403, 4, 3).to_gregorian(), date(2024, 6, 23)) + self.assertEqual(JalaliDate(1403, 4, 8).to_gregorian(), date(2024, 6, 28)) self.assertEqual(JalaliDate(1391, 12, 30).to_gregorian(), date(2013, 3, 20)) self.assertEqual(JalaliDate(1395, 12, 30).to_gregorian(), date(2017, 3, 20)) self.assertEqual(JalaliDate(1399, 12, 30).to_gregorian(), date(2021, 3, 20)) self.assertEqual(JalaliDate(1403, 12, 30).to_gregorian(), date(2025, 3, 20)) + self.assertEqual(JalaliDate(1366, 10, 11).to_gregorian(), date(1988, 1, 1)) self.assertEqual(JalaliDate(1378, 10, 11).to_gregorian(), date(2000, 1, 1)) self.assertEqual(JalaliDate(1379, 10, 12).to_gregorian(), date(2001, 1, 1)) self.assertEqual(JalaliDate(1390, 10, 11).to_gregorian(), date(2012, 1, 1)) self.assertEqual(JalaliDate(1393, 10, 11).to_gregorian(), date(2015, 1, 1)) self.assertEqual(JalaliDate(1398, 10, 11).to_gregorian(), date(2020, 1, 1)) self.assertEqual(JalaliDate(1399, 10, 12).to_gregorian(), date(2021, 1, 1)) + self.assertEqual(JalaliDate(1400, 10, 11).to_gregorian(), date(2022, 1, 1)) self.assertEqual(JalaliDate(1402, 10, 11).to_gregorian(), date(2024, 1, 1)) self.assertEqual(JalaliDate(1403, 10, 12).to_gregorian(), date(2025, 1, 1)) self.assertEqual(JalaliDate(1367, 1, 1).to_gregorian(), date(1988, 3, 21)) + self.assertEqual(JalaliDate(1388, 1, 1).to_gregorian(), date(2009, 3, 21)) self.assertEqual(JalaliDate(1396, 1, 1).to_gregorian(), date(2017, 3, 21)) self.assertEqual(JalaliDate(1399, 1, 1).to_gregorian(), date(2020, 3, 20)) self.assertEqual(JalaliDate(1400, 1, 1).to_gregorian(), date(2021, 3, 21)) @@ -86,6 +94,7 @@ def test_gregorian_to_shamsi(self): self.assertEqual(JalaliDate.to_jalali(2025, 1, 1), JalaliDate(1403, 10, 12)) self.assertEqual(JalaliDate.to_jalali(1988, 3, 21), JalaliDate(1367, 1, 1)) + self.assertEqual(JalaliDate.to_jalali(2009, 3, 21), JalaliDate(1388, 1, 1)) self.assertEqual(JalaliDate.to_jalali(2019, 3, 21), JalaliDate(1398, 1, 1)) self.assertEqual(JalaliDate.to_jalali(2020, 3, 20), JalaliDate(1399, 1, 1)) self.assertEqual(JalaliDate.to_jalali(2021, 3, 21), JalaliDate(1400, 1, 1)) @@ -314,21 +323,13 @@ def test_operators(self): self.assertTrue(JalaliDate(1367, 2, 14) <= date(1988, 5, 4)) self.assertFalse(JalaliDate(1367, 2, 14) >= JalaliDate(1369, 1, 1)) self.assertTrue(JalaliDate(1397, 11, 29) >= JalaliDate(1397, 11, 10)) + self.assertTrue(JalaliDate(1399, 12, 30) > JalaliDate(1399, 12, 29)) + self.assertTrue(JalaliDate(1399, 12, 29) < JalaliDate(1400, 1, 1)) + self.assertTrue(JalaliDate(1400, 1, 1) == JalaliDate(1400, 1, 1)) + self.assertFalse(JalaliDate(1399, 12, 30) == JalaliDate(1400, 1, 1)) self.assertTrue(JalaliDate(1403, 12, 30) > JalaliDate(1403, 12, 29)) self.assertTrue(JalaliDate(1404, 1, 1) > JalaliDate(1403, 12, 30)) - self.assertEqual(JalaliDate(1395, 3, 21) + timedelta(days=2), JalaliDate(1395, 3, 23)) - self.assertEqual(JalaliDate(1396, 7, 27) + timedelta(days=4), JalaliDate(1396, 8, 1)) - 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(1395, 3, 21) - JalaliDate(1395, 2, 14), timedelta(days=38)) - self.assertEqual(JalaliDate(1397, 12, 1) - JalaliDate(1397, 11, 29), timedelta(hours=48)) - self.assertEqual(JalaliDate(1395, 3, 21) - date(2016, 5, 3), timedelta(days=38)) - self.assertEqual(JalaliDate(1395, 12, 30) - JalaliDate(1395, 1, 1), timedelta(days=365)) - 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.assertFalse(JalaliDate(1367, 2, 14) == (1367, 2, 14)) self.assertFalse(JalaliDate(1367, 2, 14) == "") self.assertTrue(JalaliDate(1367, 2, 14) != 5) @@ -351,6 +352,24 @@ def test_operators(self): with pytest.raises(NotImplementedError): assert JalaliDate(1367, 2, 14) - {1, 2} + def test_arithmetic_operations(self): + self.assertEqual(JalaliDate(1395, 3, 21) + timedelta(days=2), JalaliDate(1395, 3, 23)) + self.assertEqual(JalaliDate(1396, 7, 27) + timedelta(days=4), JalaliDate(1396, 8, 1)) + 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(1395, 3, 21) - JalaliDate(1395, 2, 14), timedelta(days=38)) + self.assertEqual(JalaliDate(1397, 12, 1) - JalaliDate(1397, 11, 29), timedelta(hours=48)) + self.assertEqual(JalaliDate(1395, 3, 21) - date(2016, 5, 3), timedelta(days=38)) + self.assertEqual(JalaliDate(1395, 12, 30) - JalaliDate(1395, 1, 1), timedelta(days=365)) + self.assertEqual(JalaliDate(1399, 1, 1) - JalaliDate(1398, 1, 1), timedelta(days=365)) + self.assertEqual(JalaliDate(1399, 12, 29) + timedelta(days=2), JalaliDate(1400, 1, 1)) + self.assertEqual(JalaliDate(1400, 1, 1) - timedelta(days=1), JalaliDate(1399, 12, 30)) + 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)) + def test_pickle(self): file = open("save.p", "wb") pickle.dump(JalaliDate(1367, 2, 14), file, protocol=2) @@ -373,3 +392,28 @@ def test_hash(self): {j1: "today", j2: "majid1", j3: "majid2"}, {JalaliDate.today(): "today", JalaliDate(1367, 2, 14): "majid2"}, ) + + def test_invalid_dates(self): + with self.assertRaises(ValueError): + JalaliDate(1403, 13, 1) + with self.assertRaises(ValueError): + JalaliDate(1403, 1, 32) + with self.assertRaises(ValueError): + JalaliDate(1403, -1, 1) + + def test_round_trip_conversion(self): + jdate = JalaliDate(1399, 12, 30) + gdate = jdate.to_gregorian() + self.assertEqual(JalaliDate.to_jalali(gdate), jdate) + + gdate = date(2021, 3, 20) + jdate = JalaliDate.to_jalali(gdate) + self.assertEqual(jdate.to_gregorian(), gdate) + + 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): + with self.assertRaises(NotImplementedError): + JalaliDate.strptime("1400-01-01", "%Y-%m-%d") diff --git a/tests/test_jalalidatetime.py b/tests/test_jalalidatetime.py index 870a889..ca7c4d6 100644 --- a/tests/test_jalalidatetime.py +++ b/tests/test_jalalidatetime.py @@ -3,7 +3,7 @@ import time from datetime import date, datetime from datetime import time as _time -from datetime import timedelta +from datetime import timedelta, timezone from unittest import TestCase import pytest @@ -328,3 +328,221 @@ def test_strptime(self): jdt = JalaliDateTime(1374, 4, 8, 16, 28, 3, 227, pytz.utc) self.assertEqual(jdt, JalaliDateTime.strptime(jdt.strftime("%c %f %z %Z"), "%c %f %z %Z")) + + def test_strptime_basic(self): + date_string = "1400-01-01 12:30:45" + fmt = "%Y-%m-%d %H:%M:%S" + jdt = JalaliDateTime.strptime(date_string, fmt) + self.assertEqual(jdt.year, 1400) + self.assertEqual(jdt.month, 1) + self.assertEqual(jdt.day, 1) + self.assertEqual(jdt.hour, 12) + self.assertEqual(jdt.minute, 30) + self.assertEqual(jdt.second, 45) + + def test_strptime_with_timezone(self): + date_string = "1400-01-01 12:30:45 +0330" + fmt = "%Y-%m-%d %H:%M:%S %z" + jdt = JalaliDateTime.strptime(date_string, fmt) + self.assertEqual(jdt.year, 1400) + self.assertEqual(jdt.month, 1) + self.assertEqual(jdt.day, 1) + self.assertEqual(jdt.hour, 12) + self.assertEqual(jdt.minute, 30) + self.assertEqual(jdt.second, 45) + self.assertEqual(jdt.utcoffset(), timedelta(hours=3, minutes=30)) + + def test_strptime_with_locale_fa(self): + date_string = "۱۴۰۰-۰۱-۰۱ ۱۲:۳۰:۴۵" + fmt = "%Y-%m-%d %H:%M:%S" + jdt = JalaliDateTime.strptime(date_string, fmt, locale="fa") + self.assertEqual(jdt.year, 1400) + self.assertEqual(jdt.month, 1) + self.assertEqual(jdt.day, 1) + self.assertEqual(jdt.hour, 12) + self.assertEqual(jdt.minute, 30) + self.assertEqual(jdt.second, 45) + + def test_strptime_invalid_format(self): + date_string = "1400/01/01" + fmt = "%Y-%m-%d" + with self.assertRaises(ValueError): + JalaliDateTime.strptime(date_string, fmt) + + def test_strptime_invalid_locale(self): + date_string = "1400-01-01 12:30:45" + fmt = "%Y-%m-%d %H:%M:%S" + with self.assertRaises(ValueError): + JalaliDateTime.strptime(date_string, fmt, locale="invalid") + + def test_utcnow(self): + now_utc = datetime.utcnow() + jalali_now = JalaliDateTime.utcnow() + gregorian_now = jalali_now.to_gregorian() + + self.assertTrue( + abs(now_utc - gregorian_now) < timedelta(seconds=1), + f"Expected the times to be close. now_utc: {now_utc}, gregorian_now: {gregorian_now}", + ) + + def test_check_tzinfo_arg_valid(self): + JalaliDateTime._check_tzinfo_arg(None) + JalaliDateTime._check_tzinfo_arg(timezone.utc) + + def test_check_tzinfo_arg_invalid(self): + with self.assertRaises(TypeError): + JalaliDateTime._check_tzinfo_arg(123) + + with self.assertRaises(TypeError): + JalaliDateTime._check_tzinfo_arg("InvalidTzinfo") + + def test_combine(self): + jdate = JalaliDate(1367, 2, 14) + time_v = _time(4, 30, 1) + combined = JalaliDateTime.combine(jdate, time_v) + + self.assertEqual(combined.year, 1367) + self.assertEqual(combined.month, 2) + self.assertEqual(combined.day, 14) + self.assertEqual(combined.hour, 4) + self.assertEqual(combined.minute, 30) + self.assertEqual(combined.second, 1) + + with self.assertRaises(TypeError): + JalaliDateTime.combine("InvalidDate", _time(12, 30, 45)) + + jdate = JalaliDate(1400, 1, 1) + with self.assertRaises(TypeError): + JalaliDateTime.combine(jdate, "InvalidTime") + + def test_astimezone_utc(self): + jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(timedelta(hours=3))) + jdt_utc = jdt.astimezone(timezone.utc) + + gregorian_utc = jdt_utc.to_gregorian() + expected_utc = datetime(2021, 3, 21, 9, 30, 45, tzinfo=timezone.utc) + + self.assertEqual(gregorian_utc, expected_utc) + + def test_astimezone_other(self): + jdt = JalaliDateTime(1400, 1, 1, 20, 30, 45, tzinfo=timezone.utc) + new_tz = timezone(timedelta(hours=5)) + jdt_new_tz = jdt.astimezone(new_tz) + + gregorian_new_tz = jdt_new_tz.to_gregorian() + expected_new_tz = datetime(2021, 3, 22, 1, 30, 45, tzinfo=new_tz) # 5 hours added, next day + + self.assertEqual(gregorian_new_tz, expected_new_tz) + + def test_astimezone_invalid(self): + jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + with self.assertRaises(TypeError): + jdt.astimezone("InvalidTimezone") + + def test_isoformat_positive_offset(self): + jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(timedelta(hours=3, minutes=30))) + iso_format = jdt.isoformat() + expected_iso_format = "1400-01-01T12:30:45+03:30" + self.assertEqual(iso_format, expected_iso_format) + + def test_isoformat_negative_offset(self): + jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(-timedelta(hours=4, minutes=45))) + iso_format = jdt.isoformat() + expected_iso_format = "1400-01-01T12:30:45-04:45" + self.assertEqual(iso_format, expected_iso_format) + + def test_isoformat_no_offset(self): + jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45) + iso_format = jdt.isoformat() + expected_iso_format = "1400-01-01T12:30:45" + self.assertEqual(iso_format, expected_iso_format) + + def test_isoformat_utc(self): + jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + iso_format = jdt.isoformat() + expected_iso_format = "1400-01-01T12:30:45+00:00" + self.assertEqual(iso_format, expected_iso_format) + + def test_cmp_naive_vs_aware(self): + jdt_naive = JalaliDateTime(1400, 1, 1, 12, 30, 45) + jdt_aware = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + + with self.assertRaises(TypeError): + jdt_naive._cmp(jdt_aware) + + def test_cmp_aware_vs_naive(self): + jdt_naive = JalaliDateTime(1400, 1, 1, 12, 30, 45) + jdt_aware = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + + with self.assertRaises(TypeError): + jdt_aware._cmp(jdt_naive) + + def test_cmp_aware_with_different_offsets(self): + jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(timedelta(hours=3))) + jdt2 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(timedelta(hours=5))) + + self.assertEqual(jdt1._cmp(jdt2), 1) + self.assertEqual(jdt2._cmp(jdt1), -1) + + def test_cmp_aware_with_same_offset(self): + jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + jdt2 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + + self.assertEqual(jdt1._cmp(jdt2), 0) + + def test_cmp_diff_days(self): + jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + jdt2 = JalaliDateTime(1400, 1, 2, 12, 30, 45, tzinfo=timezone.utc) + + self.assertEqual(jdt1._cmp(jdt2), -1) + self.assertEqual(jdt2._cmp(jdt1), 1) + + def test_cmp_diff_seconds(self): + jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + jdt2 = JalaliDateTime(1400, 1, 1, 12, 30, 46, tzinfo=timezone.utc) + + self.assertEqual(jdt1._cmp(jdt2), -1) + self.assertEqual(jdt2._cmp(jdt1), 1) + + def test_subtract_same_offset(self): + jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 46, tzinfo=timezone.utc) + jdt2 = JalaliDateTime(1400, 1, 1, 10, 30, 45, tzinfo=timezone.utc) + + result = jdt1 - jdt2 + expected = timedelta(hours=2, seconds=1) + + self.assertEqual(result, expected) + + def test_subtract_different_offset(self): + jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(timedelta(hours=3))) + jdt2 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone(timedelta(hours=5))) + + result = jdt1 - jdt2 + expected = timedelta(hours=2) # jdt1 is 2 hours behind jdt2 + + self.assertEqual(result, expected) + + def test_subtract_naive_and_aware(self): + jdt1 = JalaliDateTime(1400, 1, 1, 12, 30, 45) + jdt2 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + + with self.assertRaises(TypeError): + _ = jdt1 - jdt2 + + def test_subtract_with_timedelta(self): + jdt = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + delta = timedelta(days=1, hours=1, minutes=30, seconds=15) + + result = jdt - delta + expected = JalaliDateTime(1399, 12, 30, 11, 0, 30, tzinfo=timezone.utc) + + self.assertEqual(result, expected) + + def test_subtract_different_dates(self): + jdt1 = JalaliDateTime(1400, 1, 2, 12, 30, 45, tzinfo=timezone.utc) + jdt2 = JalaliDateTime(1400, 1, 1, 12, 30, 45, tzinfo=timezone.utc) + + result = jdt1 - jdt2 + expected = timedelta(days=1) + + self.assertEqual(result, expected)