From fa1a4f2b02b4ab34d7a29938509a0425a1811b1a Mon Sep 17 00:00:00 2001 From: Bartosz Sokorski Date: Wed, 23 Apr 2025 12:22:00 +0200 Subject: [PATCH 01/17] Deprecate __version__ attribute (#897) --- src/pendulum/__init__.py | 20 ++++++++++++++++++-- src/pendulum/__version__.py | 4 ---- 2 files changed, 18 insertions(+), 6 deletions(-) delete mode 100644 src/pendulum/__version__.py diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 4656aaa0..c62fd217 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -2,11 +2,11 @@ import datetime as _datetime +from typing import Any from typing import Union from typing import cast from typing import overload -from pendulum.__version__ import __version__ from pendulum.constants import DAYS_PER_WEEK from pendulum.constants import HOURS_PER_DAY from pendulum.constants import MINUTES_PER_HOUR @@ -343,6 +343,23 @@ def interval( travel_to = _traveller.travel_to travel_back = _traveller.travel_back + +def __getattr__(name: str) -> Any: + if name == "__version__": + import importlib.metadata + import warnings + + warnings.warn( + "The '__version__' attribute is deprecated and will be removed in" + " Pendulum 3.4. Use 'importlib.metadata.version(\"pendulum\")' instead.", + DeprecationWarning, + stacklevel=2, + ) + return importlib.metadata.version("pendulum") + + raise AttributeError(name) + + __all__ = [ "DAYS_PER_WEEK", "HOURS_PER_DAY", @@ -364,7 +381,6 @@ def interval( "Time", "Timezone", "WeekDay", - "__version__", "date", "datetime", "duration", diff --git a/src/pendulum/__version__.py b/src/pendulum/__version__.py deleted file mode 100644 index e3a529d4..00000000 --- a/src/pendulum/__version__.py +++ /dev/null @@ -1,4 +0,0 @@ -from __future__ import annotations - - -__version__ = "3.1.0" From 91d0c1e40acbf2e9c7f6570becd45ea836e33e50 Mon Sep 17 00:00:00 2001 From: Bartosz Sokorski Date: Wed, 23 Apr 2025 16:31:01 +0200 Subject: [PATCH 02/17] Post-release version upgrade (#896) --- pyproject.toml | 2 +- rust/Cargo.lock | 2 +- rust/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index efaa7d3e..705f1359 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pendulum" -version = "3.1.0" +version = "3.2.0.dev0" description = "Python datetimes made easy" readme = "README.rst" requires-python = ">=3.9" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5362be87..5fb725ae 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "_pendulum" -version = "3.1.0" +version = "3.2.0-dev0" dependencies = [ "pyo3", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 95f0997d..f3353018 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "_pendulum" -version = "3.1.0" +version = "3.2.0-dev0" edition = "2021" [lib] From 71e37f6d1728d814a7a2cd6d2f2362bf97a04088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0ediv=C3=BD?= <6774676+eumiro@users.noreply.github.com> Date: Thu, 24 Apr 2025 02:05:49 +0200 Subject: [PATCH 03/17] Optimize usage of re. methods (#741) --- src/pendulum/formatting/formatter.py | 5 ++--- src/pendulum/locales/locale.py | 2 +- src/pendulum/parsing/__init__.py | 2 +- src/pendulum/parsing/iso8601.py | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/pendulum/formatting/formatter.py b/src/pendulum/formatting/formatter.py index da05617d..51c90128 100644 --- a/src/pendulum/formatting/formatter.py +++ b/src/pendulum/formatting/formatter.py @@ -377,8 +377,7 @@ def parse( """ escaped_fmt = re.escape(fmt) - tokens = self._FROM_FORMAT_RE.findall(escaped_fmt) - if not tokens: + if not self._FROM_FORMAT_RE.search(escaped_fmt): raise ValueError("The given time string does not match the given format") if not locale: @@ -406,7 +405,7 @@ def parse( lambda m: self._replace_tokens(m.group(0), loaded_locale), escaped_fmt ) - if not re.search("^" + pattern + "$", time): + if not re.fullmatch(pattern, time): raise ValueError(f"String does not match format {fmt}") def _get_parsed_values(m: Match[str]) -> Any: diff --git a/src/pendulum/locales/locale.py b/src/pendulum/locales/locale.py index 12b87f81..d396ecbc 100644 --- a/src/pendulum/locales/locale.py +++ b/src/pendulum/locales/locale.py @@ -48,7 +48,7 @@ def load(cls, locale: str | Locale) -> Locale: @classmethod def normalize_locale(cls, locale: str) -> str: - m = re.match("([a-z]{2})[-_]([a-z]{2})", locale, re.I) + m = re.fullmatch("([a-z]{2})[-_]([a-z]{2})", locale, re.I) if m: return f"{m.group(1).lower()}_{m.group(2).lower()}" else: diff --git a/src/pendulum/parsing/__init__.py b/src/pendulum/parsing/__init__.py index a7bad31d..4ad27efe 100644 --- a/src/pendulum/parsing/__init__.py +++ b/src/pendulum/parsing/__init__.py @@ -140,7 +140,7 @@ def _parse_common(text: str, **options: Any) -> datetime | date | time: :param text: The string to parse. """ - m = COMMON.match(text) + m = COMMON.fullmatch(text) has_date = False year = 0 month = 1 diff --git a/src/pendulum/parsing/iso8601.py b/src/pendulum/parsing/iso8601.py index 101eb3b3..908cdc75 100644 --- a/src/pendulum/parsing/iso8601.py +++ b/src/pendulum/parsing/iso8601.py @@ -97,7 +97,7 @@ def parse_iso8601( if parsed is not None: return parsed - m = ISO8601_DT.match(text) + m = ISO8601_DT.fullmatch(text) if not m: raise ParserError("Invalid ISO 8601 string") @@ -264,7 +264,7 @@ def parse_iso8601( def _parse_iso8601_duration(text: str, **options: str) -> Duration | None: - m = ISO8601_DURATION.match(text) + m = ISO8601_DURATION.fullmatch(text) if not m: return None From 1034b181d2e0447d7b0cf43109420d09c3becce4 Mon Sep 17 00:00:00 2001 From: Dimosthenis Kaponis Date: Thu, 24 Apr 2025 03:12:25 +0300 Subject: [PATCH 04/17] fix: pendulum.parse is not marked as exported (#693) --- src/pendulum/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index c62fd217..ba9a0cb9 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -29,7 +29,7 @@ from pendulum.helpers import week_ends_at from pendulum.helpers import week_starts_at from pendulum.interval import Interval -from pendulum.parser import parse +from pendulum.parser import parse as parse from pendulum.testing.traveller import Traveller from pendulum.time import Time from pendulum.tz import UTC From 98268671cf2cd5a24c21cc34690e06f87bb668cd Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Thu, 24 Apr 2025 05:15:47 -0400 Subject: [PATCH 05/17] Fix pendulum.parse('now', tz='...') ignoring the time zone (#701) --- src/pendulum/parser.py | 2 +- tests/test_parsing.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pendulum/parser.py b/src/pendulum/parser.py index e79002b2..833bae3c 100644 --- a/src/pendulum/parser.py +++ b/src/pendulum/parser.py @@ -46,7 +46,7 @@ def _parse( """ # Handling special cases if text == "now": - return pendulum.now() + return pendulum.now(tz=options.get("tz", UTC)) parsed = base_parse(text, **options) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index f01653a7..34673c40 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -128,8 +128,13 @@ def test_parse_interval() -> None: def test_parse_now() -> None: - dt = pendulum.parse("now") + assert pendulum.parse("now").timezone_name == "UTC" + assert ( + pendulum.parse("now", tz="America/Los_Angeles").timezone_name + == "America/Los_Angeles" + ) + dt = pendulum.parse("now", tz="local") assert dt.timezone_name == "America/Toronto" mock_now = pendulum.yesterday() From fc386be2623f711364c599df3e208eceb4dfa23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0ediv=C3=BD?= <6774676+eumiro@users.noreply.github.com> Date: Thu, 24 Apr 2025 11:17:44 +0200 Subject: [PATCH 06/17] Use pathlib.Path to read unix tz (#742) --- src/pendulum/tz/local_timezone.py | 99 +++++++++++-------------------- 1 file changed, 35 insertions(+), 64 deletions(-) diff --git a/src/pendulum/tz/local_timezone.py b/src/pendulum/tz/local_timezone.py index d2082f8c..9418b05d 100644 --- a/src/pendulum/tz/local_timezone.py +++ b/src/pendulum/tz/local_timezone.py @@ -7,8 +7,8 @@ import warnings from contextlib import contextmanager +from pathlib import Path from typing import TYPE_CHECKING -from typing import cast from pendulum.tz.exceptions import InvalidTimezone from pendulum.tz.timezone import UTC @@ -165,86 +165,57 @@ def _get_unix_timezone(_root: str = "/") -> Timezone: # Now look for distribution specific configuration files # that contain the timezone name. - tzpath = os.path.join(_root, "etc/timezone") - if os.path.isfile(tzpath): - with open(tzpath, "rb") as tzfile: - tzfile_data = tzfile.read() - - # Issue #3 was that /etc/timezone was a zoneinfo file. - # That's a misconfiguration, but we need to handle it gracefully: - if tzfile_data[:5] != b"TZif2": - etctz = tzfile_data.strip().decode() - # Get rid of host definitions and comments: - if " " in etctz: - etctz, dummy = etctz.split(" ", 1) - if "#" in etctz: - etctz, dummy = etctz.split("#", 1) - - return Timezone(etctz.replace(" ", "_")) + tzpath = Path(_root) / "etc" / "timezone" + if tzpath.is_file(): + tzfile_data = tzpath.read_bytes() + # Issue #3 was that /etc/timezone was a zoneinfo file. + # That's a misconfiguration, but we need to handle it gracefully: + if not tzfile_data.startswith(b"TZif2"): + etctz = tzfile_data.strip().decode() + # Get rid of host definitions and comments: + etctz, _, _ = etctz.partition(" ") + etctz, _, _ = etctz.partition("#") + return Timezone(etctz.replace(" ", "_")) # CentOS has a ZONE setting in /etc/sysconfig/clock, # OpenSUSE has a TIMEZONE setting in /etc/sysconfig/clock and # Gentoo has a TIMEZONE setting in /etc/conf.d/clock # We look through these files for a timezone: - zone_re = re.compile(r'\s*ZONE\s*=\s*"') - timezone_re = re.compile(r'\s*TIMEZONE\s*=\s*"') - end_re = re.compile('"') + zone_re = re.compile(r'\s*(TIME)?ZONE\s*=\s*"([^"]+)?"') for filename in ("etc/sysconfig/clock", "etc/conf.d/clock"): - tzpath = os.path.join(_root, filename) - if not os.path.isfile(tzpath): - continue - - with open(tzpath) as tzfile: - data = tzfile.readlines() - - for line in data: - # Look for the ZONE= setting. - match = zone_re.match(line) - if match is None: - # No ZONE= setting. Look for the TIMEZONE= setting. - match = timezone_re.match(line) - - if match is not None: - # Some setting existed - line = line[match.end() :] - etctz = line[ - : cast( - "re.Match[str]", - end_re.search(line), - ).start() - ] - - parts = list(reversed(etctz.replace(" ", "_").split(os.path.sep))) - tzpath_parts: list[str] = [] - while parts: - tzpath_parts.insert(0, parts.pop(0)) - - with contextlib.suppress(InvalidTimezone): - return Timezone(os.path.join(*tzpath_parts)) + tzpath = Path(_root) / filename + if tzpath.is_file(): + data = tzpath.read_text().splitlines() + for line in data: + # Look for the ZONE= or TIMEZONE= setting. + match = zone_re.match(line) + if match: + etctz = match.group(2) + parts = list(reversed(etctz.replace(" ", "_").split(os.path.sep))) + tzpath_parts: list[str] = [] + while parts: + tzpath_parts.insert(0, parts.pop(0)) + with contextlib.suppress(InvalidTimezone): + return Timezone(os.path.sep.join(tzpath_parts)) # systemd distributions use symlinks that include the zone name, # see manpage of localtime(5) and timedatectl(1) - tzpath = os.path.join(_root, "etc", "localtime") - if os.path.isfile(tzpath) and os.path.islink(tzpath): - parts = list( - reversed(os.path.realpath(tzpath).replace(" ", "_").split(os.path.sep)) - ) + tzpath = Path(_root) / "etc" / "localtime" + if tzpath.is_file() and tzpath.is_symlink(): + parts = [p.replace(" ", "_") for p in reversed(tzpath.resolve().parts)] tzpath_parts: list[str] = [] # type: ignore[no-redef] while parts: tzpath_parts.insert(0, parts.pop(0)) with contextlib.suppress(InvalidTimezone): - return Timezone(os.path.join(*tzpath_parts)) + return Timezone(os.path.sep.join(tzpath_parts)) # No explicit setting existed. Use localtime for filename in ("etc/localtime", "usr/local/etc/localtime"): - tzpath = os.path.join(_root, filename) - - if not os.path.isfile(tzpath): - continue - - with open(tzpath, "rb") as f: - return Timezone.from_file(f) + tzpath = Path(_root) / filename + if tzpath.is_file(): + with tzpath.open("rb") as f: + return Timezone.from_file(f) warnings.warn( "Unable not find any timezone configuration, defaulting to UTC.", stacklevel=1 From d40ab96a3bd732bd37bc43855599ed0027f32127 Mon Sep 17 00:00:00 2001 From: Jayanth <83123167+gjaynir0508@users.noreply.github.com> Date: Sun, 22 Jun 2025 17:50:18 +0530 Subject: [PATCH 07/17] feat: add Hindi (hi) localization support (#902) * feat: add Hindi (hi) localization support * fix: formatting of tests/localization/test_hi.py to use double quote strings --- src/pendulum/locales/hi/__init__.py | 0 src/pendulum/locales/hi/custom.py | 21 +++ src/pendulum/locales/hi/locale.py | 221 ++++++++++++++++++++++++++++ tests/localization/test_hi.py | 69 +++++++++ 4 files changed, 311 insertions(+) create mode 100644 src/pendulum/locales/hi/__init__.py create mode 100644 src/pendulum/locales/hi/custom.py create mode 100644 src/pendulum/locales/hi/locale.py create mode 100644 tests/localization/test_hi.py diff --git a/src/pendulum/locales/hi/__init__.py b/src/pendulum/locales/hi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pendulum/locales/hi/custom.py b/src/pendulum/locales/hi/custom.py new file mode 100644 index 00000000..f8b06ff7 --- /dev/null +++ b/src/pendulum/locales/hi/custom.py @@ -0,0 +1,21 @@ +""" +hi custom locale file. +""" + +translations = { + "units": {"few_second": "कुछ सेकंड"}, + # Relative time + "ago": "{} पहले", + "from_now": "{} में", + "after": "{0} बाद", + "before": "{0} पहले", + # Date formats + "date_formats": { + "LTS": "h:mm:ss A", + "LT": "h:mm A", + "L": "MM/DD/YYYY", + "LL": "MMMM D, YYYY", + "LLL": "MMMM D, YYYY h:mm A", + "LLLL": "dddd, MMMM D, YYYY h:mm A", + }, +} diff --git a/src/pendulum/locales/hi/locale.py b/src/pendulum/locales/hi/locale.py new file mode 100644 index 00000000..e5383dba --- /dev/null +++ b/src/pendulum/locales/hi/locale.py @@ -0,0 +1,221 @@ +from pendulum.locales.hi.custom import translations as custom_translations + + +""" +hi locale file. + +It has been generated automatically and must not be modified directly. +""" + + +locale = { + 'plural': lambda n: 'one' if ((n == n and ((n == 0))) or (n == n and ((n == 1)))) else 'other', + 'ordinal': lambda n: 'few' if (n == n and ((n == 4))) else 'many' if (n == n and ((n == 6))) else 'one' if (n == n and ((n == 1))) else 'two' if (n == n and ((n == 2) or (n == 3))) else 'other', + 'translations': { + 'days': { + 'abbreviated': { + 0: 'सोम', + 1: 'मंगल', + 2: 'बुध', + 3: 'गुरु', + 4: 'शुक्र', + 5: 'शनि', + 6: 'रवि', + }, + 'narrow': { + 0: 'सो', + 1: 'मं', + 2: 'बु', + 3: 'गु', + 4: 'शु', + 5: 'श', + 6: 'र', + }, + 'short': { + 0: 'सो', + 1: 'मं', + 2: 'बु', + 3: 'गु', + 4: 'शु', + 5: 'श', + 6: 'र', + }, + 'wide': { + 0: 'सोमवार', + 1: 'मंगलवार', + 2: 'बुधवार', + 3: 'गुरुवार', + 4: 'शुक्रवार', + 5: 'शनिवार', + 6: 'रविवार', + }, + }, + 'months': { + 'abbreviated': { + 1: 'जन॰', + 2: 'फ़र॰', + 3: 'मार्च', + 4: 'अप्रैल', + 5: 'मई', + 6: 'जून', + 7: 'जुल॰', + 8: 'अग॰', + 9: 'सित॰', + 10: 'अक्तू॰', + 11: 'नव॰', + 12: 'दिस॰', + }, + 'narrow': { + 1: 'ज', + 2: 'फ़', + 3: 'मा', + 4: 'अ', + 5: 'म', + 6: 'जू', + 7: 'जु', + 8: 'अ', + 9: 'सि', + 10: 'अ', + 11: 'न', + 12: 'दि', + }, + 'wide': { + 1: 'जनवरी', + 2: 'फ़रवरी', + 3: 'मार्च', + 4: 'अप्रैल', + 5: 'मई', + 6: 'जून', + 7: 'जुलाई', + 8: 'अगस्त', + 9: 'सितंबर', + 10: 'अक्तूबर', + 11: 'नवंबर', + 12: 'दिसंबर', + }, + }, + 'units': { + 'year': { + 'one': '{0} वर्ष', + 'other': '{0} वर्ष', + }, + 'month': { + 'one': '{0} महीना', + 'other': '{0} महीने', + }, + 'week': { + 'one': '{0} सप्ताह', + 'other': '{0} सप्ताह', + }, + 'day': { + 'one': '{0} दिन', + 'other': '{0} दिन', + }, + 'hour': { + 'one': '{0} घंटा', + 'other': '{0} घंटे', + }, + 'minute': { + 'one': '{0} मिनट', + 'other': '{0} मिनट', + }, + 'second': { + 'one': '{0} सेकंड', + 'other': '{0} सेकंड', + }, + 'microsecond': { + 'one': '{0} माइक्रोसेकंड', + 'other': '{0} माइक्रोसेकंड', + }, + }, + 'relative': { + 'year': { + 'future': { + 'other': '{0} वर्ष में', + 'one': '{0} वर्ष में', + }, + 'past': { + 'other': '{0} वर्ष पहले', + 'one': '{0} वर्ष पहले', + }, + }, + 'month': { + 'future': { + 'other': '{0} माह में', + 'one': '{0} माह में', + }, + 'past': { + 'other': '{0} माह पहले', + 'one': '{0} माह पहले', + }, + }, + 'week': { + 'future': { + 'other': '{0} सप्ताह में', + 'one': '{0} सप्ताह में', + }, + 'past': { + 'other': '{0} सप्ताह पहले', + 'one': '{0} सप्ताह पहले', + }, + }, + 'day': { + 'future': { + 'other': '{0} दिन में', + 'one': '{0} दिन में', + }, + 'past': { + 'other': '{0} दिन पहले', + 'one': '{0} दिन पहले', + }, + }, + 'hour': { + 'future': { + 'other': '{0} घंटे में', + 'one': '{0} घंटे में', + }, + 'past': { + 'other': '{0} घंटे पहले', + 'one': '{0} घंटे पहले', + }, + }, + 'minute': { + 'future': { + 'other': '{0} मिनट में', + 'one': '{0} मिनट में', + }, + 'past': { + 'other': '{0} मिनट पहले', + 'one': '{0} मिनट पहले', + }, + }, + 'second': { + 'future': { + 'other': '{0} सेकंड में', + 'one': '{0} सेकंड में', + }, + 'past': { + 'other': '{0} सेकंड पहले', + 'one': '{0} सेकंड पहले', + }, + }, + }, + 'day_periods': { + "midnight": "मध्यरात्रि", + "am": "AM", + "noon": "दोपहर", + "pm": "PM", + "morning1": "सुबह में", + "afternoon1": "दोपहर में", + "evening1": "शाम में", + "night1": "रात में", + }, + 'week_data': { + 'min_days': 1, + 'first_day': 0, + 'weekend_start': 5, + 'weekend_end': 6, + }, + }, + 'custom': custom_translations +} diff --git a/tests/localization/test_hi.py b/tests/localization/test_hi.py new file mode 100644 index 00000000..9abcd8b4 --- /dev/null +++ b/tests/localization/test_hi.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pendulum + + +locale = "hi" + + +def test_diff_for_humans(): + with pendulum.travel_to(pendulum.datetime(2025, 6, 4), freeze=True): + diff_for_humans() + + +def diff_for_humans(): + d = pendulum.now().subtract(seconds=1) + assert d.diff_for_humans(locale=locale) == "कुछ सेकंड पहले" + + d = pendulum.now().subtract(seconds=2) + assert d.diff_for_humans(locale=locale) == "कुछ सेकंड पहले" + + d = pendulum.now().subtract(seconds=21) + assert d.diff_for_humans(locale=locale) == "21 सेकंड पहले" + + d = pendulum.now().subtract(minutes=1) + assert d.diff_for_humans(locale=locale) == "1 मिनट पहले" + + d = pendulum.now().subtract(minutes=2) + assert d.diff_for_humans(locale=locale) == "2 मिनट पहले" + + d = pendulum.now().subtract(hours=1) + assert d.diff_for_humans(locale=locale) == "1 घंटे पहले" + + d = pendulum.now().subtract(hours=2) + assert d.diff_for_humans(locale=locale) == "2 घंटे पहले" + + d = pendulum.now().subtract(days=1) + assert d.diff_for_humans(locale=locale) == "1 दिन पहले" + + d = pendulum.now().subtract(days=2) + assert d.diff_for_humans(locale=locale) == "2 दिन पहले" + + d = pendulum.now().subtract(weeks=1) + assert d.diff_for_humans(locale=locale) == "1 सप्ताह पहले" + + d = pendulum.now().subtract(weeks=2) + assert d.diff_for_humans(locale=locale) == "2 सप्ताह पहले" + + d = pendulum.now().subtract(months=1) + assert d.diff_for_humans(locale=locale) == "1 माह पहले" + + d = pendulum.now().subtract(months=2) + assert d.diff_for_humans(locale=locale) == "2 माह पहले" + + d = pendulum.now().subtract(years=1) + assert d.diff_for_humans(locale=locale) == "1 वर्ष पहले" + + d = pendulum.now().subtract(years=2) + assert d.diff_for_humans(locale=locale) == "2 वर्ष पहले" + + d = pendulum.now().add(seconds=1) + assert d.diff_for_humans(locale=locale) == "कुछ सेकंड में" + + d = pendulum.now().add(seconds=1) + d2 = pendulum.now() + assert d.diff_for_humans(d2, locale=locale) == "कुछ सेकंड बाद" + assert d2.diff_for_humans(d, locale=locale) == "कुछ सेकंड पहले" + + assert d.diff_for_humans(d2, True, locale=locale) == "कुछ सेकंड" + assert d2.diff_for_humans(d.add(seconds=1), True, locale=locale) == "कुछ सेकंड" From c8068a7944593b640ee1bf7a5ca7bef63bf7a7c2 Mon Sep 17 00:00:00 2001 From: Meow Date: Sun, 13 Oct 2024 16:41:27 +0800 Subject: [PATCH 08/17] Make sure Interval can be deepcopy-ed, fix #850 --- src/pendulum/interval.py | 9 +++++++++ tests/interval/test_behavior.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/pendulum/interval.py b/src/pendulum/interval.py index 747367c1..c5e0713a 100644 --- a/src/pendulum/interval.py +++ b/src/pendulum/interval.py @@ -1,11 +1,13 @@ from __future__ import annotations +import copy import operator from datetime import date from datetime import datetime from datetime import timedelta from typing import TYPE_CHECKING +from typing import Any from typing import Generic from typing import TypeVar from typing import cast @@ -409,3 +411,10 @@ def __eq__(self, other: object) -> bool: def __ne__(self, other: object) -> bool: return not self.__eq__(other) + + def __deepcopy__(self, memo: dict[int, Any]) -> Self: + return self.__class__( + copy.deepcopy(self.start, memo), + copy.deepcopy(self.end, memo), + self._absolute, + ) diff --git a/tests/interval/test_behavior.py b/tests/interval/test_behavior.py index 96a1f426..d2ee0c9b 100644 --- a/tests/interval/test_behavior.py +++ b/tests/interval/test_behavior.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import pickle from datetime import timedelta @@ -65,3 +66,18 @@ def test_inequality(): assert interval1 != interval2 assert interval1 != interval3 + + +def test_deepcopy(): + dt1 = pendulum.datetime(2016, 11, 18) + dt2 = pendulum.datetime(2016, 11, 20) + + interval = dt2 - dt1 + + interval2 = copy.deepcopy(interval) + + assert interval == interval2 + # make sure it's a deep copy + assert interval is not interval2 + assert interval.start is not interval2.start + assert interval.end is not interval2.end From 4de9ee8b4924b541e842d9da75d16b955bcf0728 Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Sun, 15 Jun 2025 10:52:15 +0100 Subject: [PATCH 09/17] Make empty durations an error in pure-Python parser Some of Debian's test runners noticed that the pydantic-extra-types tests are failing on 32-bit architectures: ______________________ test_invalid_zero_duration_string _______________________ def test_invalid_zero_duration_string(): """'P' is not a valid ISO 8601 duration and should raise a validation error.""" > with pytest.raises(ValidationError): E Failed: DID NOT RAISE tests/test_pendulum_dt.py:447: Failed Debian currently has pendulum 3.0.0, which disabled the Rust extensions if `struct.calcsize("P") == 4`, and the Rust and Python parsers disagree about how to handle an empty duration: the Rust parser reports an error, while the Python parser returns `Duration()`. 3.1.0 removes that particular limitation on using Rust extensions on 32-bit architectures, but the parser discrepancy still seems to be present. I don't have access to the full text of the standard, but Wikipedia's summary says 'However, at least one element must be present, thus "P" is not a valid representation for a duration of 0 seconds', so I think the Rust parser is correct. Adjust the Python parser to match. --- src/pendulum/parsing/iso8601.py | 2 +- tests/parsing/test_parse_iso8601.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pendulum/parsing/iso8601.py b/src/pendulum/parsing/iso8601.py index 908cdc75..c65d249e 100644 --- a/src/pendulum/parsing/iso8601.py +++ b/src/pendulum/parsing/iso8601.py @@ -265,7 +265,7 @@ def parse_iso8601( def _parse_iso8601_duration(text: str, **options: str) -> Duration | None: m = ISO8601_DURATION.fullmatch(text) - if not m: + if not m or (not m.group("w") and not m.group("ymd") and not m.group("hms")): return None years = 0 diff --git a/tests/parsing/test_parse_iso8601.py b/tests/parsing/test_parse_iso8601.py index c15b9bd9..ed2d3988 100644 --- a/tests/parsing/test_parse_iso8601.py +++ b/tests/parsing/test_parse_iso8601.py @@ -90,7 +90,7 @@ def test_parse_iso8601(text: str, expected: date) -> None: assert parse_iso8601(text) == expected -def test_parse_ios8601_invalid(): +def test_parse_iso8601_invalid(): # Invalid month with pytest.raises(ValueError): parse_iso8601("20161306T123456") @@ -193,7 +193,7 @@ def test_parse_ios8601_invalid(): ("P2Y30M4DT5H6M7S", (2, 30, 0, 4, 5, 6, 7, 0)), ], ) -def test_parse_ios8601_duration( +def test_parse_iso8601_duration( text: str, expected: tuple[int, int, int, int, int, int, int, int] ) -> None: parsed = parse_iso8601(text) @@ -208,3 +208,9 @@ def test_parse_ios8601_duration( parsed.remaining_seconds, parsed.microseconds, ) == expected + + +def test_parse_iso8601_duration_invalid(): + # Must include at least one element + with pytest.raises(ValueError): + parse_iso8601("P") From b45e22d86626c1c8dff65e00380067197fc2aa04 Mon Sep 17 00:00:00 2001 From: Radu Chindris Date: Fri, 15 Nov 2024 17:10:43 +0200 Subject: [PATCH 10/17] fix parsing invalid interval string --- src/pendulum/parsing/__init__.py | 4 ++++ tests/parsing/test_parsing_duration.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/pendulum/parsing/__init__.py b/src/pendulum/parsing/__init__.py index 4ad27efe..b66cf1a2 100644 --- a/src/pendulum/parsing/__init__.py +++ b/src/pendulum/parsing/__init__.py @@ -212,6 +212,10 @@ def _parse_iso8601_interval(text: str) -> _Interval: raise ParserError("Invalid interval") first, last = text.split("/") + + if not first or not last: + raise ParserError("Invalid interval.") + start = end = duration = None if first[:1] == "P": diff --git a/tests/parsing/test_parsing_duration.py b/tests/parsing/test_parsing_duration.py index ab8b9920..d6a0b73d 100644 --- a/tests/parsing/test_parsing_duration.py +++ b/tests/parsing/test_parsing_duration.py @@ -293,6 +293,14 @@ def test_parse_duration_invalid(): parse("P1Dasdfasdf") +def test_parse_interval_invalid(): + with pytest.raises(ParserError): + parse("/no_start") + + with pytest.raises(ParserError): + parse("no_end/") + + def test_parse_duration_fraction_only_allowed_on_last_component(): with pytest.raises(ParserError): parse("P2Y3M4DT5.5H6M7S") From 58b935d4b4b8a533ae1905a26a913c4ea6781bb8 Mon Sep 17 00:00:00 2001 From: Jesse Harwin Date: Mon, 20 May 2024 11:48:09 -0700 Subject: [PATCH 11/17] Fixed pluralization in test --- tests/duration/test_in_words.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/duration/test_in_words.py b/tests/duration/test_in_words.py index c0a1a1fe..3f97703b 100644 --- a/tests/duration/test_in_words.py +++ b/tests/duration/test_in_words.py @@ -62,7 +62,7 @@ def test_separator(): def test_subseconds(): pi = pendulum.duration(microseconds=123456) - assert pi.in_words() == "0.12 second" + assert pi.in_words() == "0.12 seconds" def test_subseconds_with_seconds(): From 51b67619aead9691636b2363758685e8f1e6e76c Mon Sep 17 00:00:00 2001 From: Jesse Harwin Date: Mon, 20 May 2024 11:51:37 -0700 Subject: [PATCH 12/17] Added test for extra decimal places for subsecond strings --- tests/duration/test_in_words.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/duration/test_in_words.py b/tests/duration/test_in_words.py index 3f97703b..191ba844 100644 --- a/tests/duration/test_in_words.py +++ b/tests/duration/test_in_words.py @@ -65,9 +65,14 @@ def test_subseconds(): assert pi.in_words() == "0.12 seconds" +def test_subseconds_with_n_digits(): + pi = pendulum.duration(microseconds=123456) + + assert pi.in_words(seconds_n_decimal=3) == "0.123 seconds" + + def test_subseconds_with_seconds(): pi = pendulum.duration(seconds=12, microseconds=123456) - assert pi.in_words() == "12 seconds" From 063eea7e7c15b4ecdbd26e5ad1acd487933a73b5 Mon Sep 17 00:00:00 2001 From: Jesse Harwin Date: Mon, 20 May 2024 12:02:39 -0700 Subject: [PATCH 13/17] Fixed pluralization bug and added arbitrary decimal places on subseconds --- src/pendulum/duration.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index 2958ee66..f1e7bd44 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -238,7 +238,12 @@ def in_minutes(self) -> int: def in_seconds(self) -> int: return int(self.total_seconds()) - def in_words(self, locale: str | None = None, separator: str = " ") -> str: + def in_words( + self, + locale: str | None = None, + separator: str = " ", + seconds_n_decimal: int = 2, + ) -> str: """ Get the current interval in words in the current locale. @@ -246,6 +251,9 @@ def in_words(self, locale: str | None = None, separator: str = " ") -> str: :param locale: The locale to use. Defaults to current locale. :param separator: The separator to use between each unit + :param kwargs: Additional keyword arguments. + - seconds_n_decimal (int): The number of decimal places to use for seconds if no other time units are present. Defaults to 2. + """ intervals = [ ("year", self.years), @@ -273,9 +281,10 @@ def in_words(self, locale: str | None = None, separator: str = " ") -> str: if not parts: count: int | str = 0 - if abs(self.microseconds) > 0: - unit = f"units.second.{loaded_locale.plural(1)}" - count = f"{abs(self.microseconds) / 1e6:.2f}" + unit = f"units.second.{loaded_locale.plural(0)}" + if self.microseconds != 0: + microseconds = abs(self.microseconds) / 1e6 + count = f"{round(microseconds, ndigits=seconds_n_decimal)}" else: unit = f"units.microsecond.{loaded_locale.plural(0)}" translation = loaded_locale.translation(unit) From 95d75fcec5f6d8e699e4174c1467e5d54157beaf Mon Sep 17 00:00:00 2001 From: Solipsistmonkey <103457994+Solipsistmonkey@users.noreply.github.com> Date: Mon, 20 May 2024 12:39:21 -0700 Subject: [PATCH 14/17] Update src/pendulum/duration.py Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> --- src/pendulum/duration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index f1e7bd44..e650e407 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -284,7 +284,7 @@ def in_words( unit = f"units.second.{loaded_locale.plural(0)}" if self.microseconds != 0: microseconds = abs(self.microseconds) / 1e6 - count = f"{round(microseconds, ndigits=seconds_n_decimal)}" + count = str({round(microseconds, ndigits=seconds_n_decimal)}) else: unit = f"units.microsecond.{loaded_locale.plural(0)}" translation = loaded_locale.translation(unit) From ebe7e791a876d9ea9ee1cbf0b77c35367f3ccc17 Mon Sep 17 00:00:00 2001 From: Ash Berlin-Taylor Date: Wed, 16 Jul 2025 14:31:30 +0100 Subject: [PATCH 15/17] Apply suggestions from code review Separating out fix from adding seoncds_n_decimal --- src/pendulum/duration.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index e650e407..ea9ae008 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -242,7 +242,6 @@ def in_words( self, locale: str | None = None, separator: str = " ", - seconds_n_decimal: int = 2, ) -> str: """ Get the current interval in words in the current locale. @@ -251,9 +250,6 @@ def in_words( :param locale: The locale to use. Defaults to current locale. :param separator: The separator to use between each unit - :param kwargs: Additional keyword arguments. - - seconds_n_decimal (int): The number of decimal places to use for seconds if no other time units are present. Defaults to 2. - """ intervals = [ ("year", self.years), @@ -281,10 +277,11 @@ def in_words( if not parts: count: int | str = 0 - unit = f"units.second.{loaded_locale.plural(0)}" + unit: str if self.microseconds != 0: + unit = f"units.second.{loaded_locale.plural(0)}" microseconds = abs(self.microseconds) / 1e6 - count = str({round(microseconds, ndigits=seconds_n_decimal)}) + count = f"{abs(self.microseconds) / 1e6:.2f}" else: unit = f"units.microsecond.{loaded_locale.plural(0)}" translation = loaded_locale.translation(unit) From 2ab00f3343521ddadae3f24ead9fd84bd517fc6a Mon Sep 17 00:00:00 2001 From: Ash Berlin-Taylor Date: Wed, 16 Jul 2025 14:37:06 +0100 Subject: [PATCH 16/17] Apply suggestions from code review --- src/pendulum/duration.py | 8 +------- tests/duration/test_in_words.py | 6 ------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index ea9ae008..f1bc7c9b 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -238,11 +238,7 @@ def in_minutes(self) -> int: def in_seconds(self) -> int: return int(self.total_seconds()) - def in_words( - self, - locale: str | None = None, - separator: str = " ", - ) -> str: +def in_words(self, locale: str | None = None, separator: str = " ") -> str: """ Get the current interval in words in the current locale. @@ -277,10 +273,8 @@ def in_words( if not parts: count: int | str = 0 - unit: str if self.microseconds != 0: unit = f"units.second.{loaded_locale.plural(0)}" - microseconds = abs(self.microseconds) / 1e6 count = f"{abs(self.microseconds) / 1e6:.2f}" else: unit = f"units.microsecond.{loaded_locale.plural(0)}" diff --git a/tests/duration/test_in_words.py b/tests/duration/test_in_words.py index 191ba844..13982c33 100644 --- a/tests/duration/test_in_words.py +++ b/tests/duration/test_in_words.py @@ -65,12 +65,6 @@ def test_subseconds(): assert pi.in_words() == "0.12 seconds" -def test_subseconds_with_n_digits(): - pi = pendulum.duration(microseconds=123456) - - assert pi.in_words(seconds_n_decimal=3) == "0.123 seconds" - - def test_subseconds_with_seconds(): pi = pendulum.duration(seconds=12, microseconds=123456) assert pi.in_words() == "12 seconds" From 2adcc024e99e238c6612f377a8bc240a75895324 Mon Sep 17 00:00:00 2001 From: Ash Berlin-Taylor Date: Thu, 17 Jul 2025 14:18:07 +0100 Subject: [PATCH 17/17] Update src/pendulum/duration.py --- src/pendulum/duration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index f1bc7c9b..721401df 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -238,7 +238,7 @@ def in_minutes(self) -> int: def in_seconds(self) -> int: return int(self.total_seconds()) -def in_words(self, locale: str | None = None, separator: str = " ") -> str: + def in_words(self, locale: str | None = None, separator: str = " ") -> str: """ Get the current interval in words in the current locale.