From ece60b603e90e63b3368f00c5442bef8ab43812c Mon Sep 17 00:00:00 2001 From: "W. H. Wang" Date: Fri, 9 Jan 2026 10:53:30 +0000 Subject: [PATCH 1/3] Drop py3.9 support --- .github/workflows/codspeed.yml | 2 +- .github/workflows/release.yml | 10 +++--- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 2 ++ README.rst | 2 +- poetry.lock | 62 +++------------------------------- pyproject.toml | 5 ++- tox.ini | 2 +- 8 files changed, 18 insertions(+), 69 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 73b5db6c..2626bb76 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: - python-version: "3.9" + python-version: "3.10" - name: Get full Python version id: full-python-version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a18a8140..812d63b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,14 +21,14 @@ jobs: platform: linux - os: windows ls: dir - interpreter: 3.9 3.10 3.11 3.12 3.13 3.14 pypy3.9 pypy3.10 + interpreter: 3.10 3.11 3.12 3.13 3.14 pypy3.10 - os: windows ls: dir target: aarch64 interpreter: 3.11 3.12 3.13 3.14 - os: macos target: aarch64 - interpreter: 3.9 3.10 3.11 3.12 3.13 3.14 pypy3.9 pypy3.10 + interpreter: 3.10 3.11 3.12 3.13 3.14 pypy3.10 - os: ubuntu platform: linux target: aarch64 @@ -44,11 +44,11 @@ jobs: - os: ubuntu platform: linux target: ppc64le - interpreter: 3.9 3.10 3.11 3.12 3.13 3.14 + interpreter: 3.10 3.11 3.12 3.13 3.14 - os: ubuntu platform: linux target: s390x - interpreter: 3.9 3.10 3.11 3.12 3.13 3.14 + interpreter: 3.10 3.11 3.12 3.13 3.14 runs-on: ${{ matrix.os }}-latest steps: @@ -66,7 +66,7 @@ jobs: target: ${{ matrix.target }} manylinux: ${{ matrix.manylinux || 'auto' }} container: ${{ matrix.container }} - args: --release --out dist --interpreter ${{ matrix.interpreter || '3.9 3.10 3.11 3.12 3.13 pypy3.9 pypy3.10' }} ${{ matrix.extra-build-args }} + args: --release --out dist --interpreter ${{ matrix.interpreter || '3.10 3.11 3.12 3.13 pypy3.10' }} ${{ matrix.extra-build-args }} rust-toolchain: stable docker-options: -e CI diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1fbce01f..290960d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,7 @@ jobs: strategy: matrix: os: [Ubuntu, MacOS, Windows] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] defaults: run: shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 81183e4a..a3fe4dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Change Log +- Dropped support for Python 3.9 [#930](https://github.com/python-pendulum/pendulum/pull/930) + ## [3.1.0] - 2025-04-19 ### Added diff --git a/README.rst b/README.rst index 63422f14..e2ae92f2 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ Pendulum Python datetimes made easy. -Supports Python **3.9 and newer**. +Supports Python **3.10 and newer**. .. code-block:: python diff --git a/poetry.lock b/poetry.lock index 8411da19..42ef7517 100644 --- a/poetry.lock +++ b/poetry.lock @@ -208,7 +208,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["benchmark", "test"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -267,31 +267,6 @@ files = [ [package.extras] license = ["ukkonen"] -[[package]] -name = "importlib-metadata" -version = "8.5.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -groups = ["benchmark", "doc"] -markers = "python_version == \"3.9\"" -files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -334,9 +309,6 @@ files = [ {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -525,7 +497,6 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -554,7 +525,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -859,7 +829,6 @@ files = [ [package.dependencies] cffi = ">=1.17.1" -importlib-metadata = {version = ">=8.5.0", markers = "python_version < \"3.10\""} pytest = ">=3.8" rich = ">=13.8.1" @@ -1171,7 +1140,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["benchmark", "build", "dev", "test", "typing"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1258,7 +1227,7 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {benchmark = "python_version < \"3.11\"", dev = "python_version < \"3.11\""} +markers = {benchmark = "python_version == \"3.10\"", dev = "python_version == \"3.10\""} [[package]] name = "tzdata" @@ -1336,31 +1305,10 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] -[[package]] -name = "zipp" -version = "3.21.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["benchmark", "doc"] -markers = "python_version == \"3.9\"" -files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [extras] test = ["time-machine"] [metadata] lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "491ee653cad58327fa9ff27388a6d0402bc8d5202b08bca616042f5d5ae12176" +python-versions = ">=3.10" +content-hash = "f3c800d494fd36b67b21a8c8316f5bba2c20b99c457350e2c80f3103f9a08a74" diff --git a/pyproject.toml b/pyproject.toml index 265145dd..7ca15c99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,13 @@ name = "pendulum" version = "3.2.0.dev0" description = "Python datetimes made easy" readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { text = "MIT License" } authors = [{ name = "Sébastien Eustace", email = "sebastien@eustace.io" }] keywords = ['datetime', 'date', 'time'] classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -72,7 +71,7 @@ include = [ [tool.ruff] fix = true line-length = 88 -target-version = "py39" +target-version = "py310" extend-exclude = [ # External to the project's coding standards: "docs/*", diff --git a/tox.ini b/tox.ini index d45c3e2b..afff59dc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = true -envlist = py37, py38, py39, py310, pypy3 +envlist = py310, py311, py312, py313, pypy3 [testenv] whitelist_externals = poetry From 81e8560690a2be774a25b4ffdaa546885a4efaf8 Mon Sep 17 00:00:00 2001 From: "W. H. Wang" Date: Fri, 9 Jan 2026 15:34:45 +0000 Subject: [PATCH 2/3] Fix lint issues --- clock | 2 +- src/pendulum/__init__.py | 9 ++++----- src/pendulum/datetime.py | 10 +++++----- src/pendulum/duration.py | 4 ++-- src/pendulum/formatting/formatter.py | 2 +- src/pendulum/parsing/__init__.py | 3 +-- src/pendulum/time.py | 10 +++++----- 7 files changed, 19 insertions(+), 21 deletions(-) diff --git a/clock b/clock index 5e026907..2ee7e773 100755 --- a/clock +++ b/clock @@ -192,7 +192,7 @@ translations = {{}} for k, v in d.items(): v = ( self.format_dict(v, tab + 1) - if isinstance(v, (dict, LocaleDataDict)) + if isinstance(v, dict | LocaleDataDict) else repr(v) ) diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index e1d02177..17fae0fc 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -5,7 +5,6 @@ from functools import cache from typing import TYPE_CHECKING from typing import Any -from typing import Union from typing import cast from typing import overload @@ -92,13 +91,13 @@ def _safe_timezone( Creates a timezone instance from a string, Timezone, TimezoneInfo or integer offset. """ - if isinstance(obj, (Timezone, FixedTimezone)): + if isinstance(obj, Timezone | FixedTimezone): return obj if obj is None or obj == "local": return local_timezone() - if isinstance(obj, (int, float)): + if isinstance(obj, int | float): obj = int(obj * 60 * 60) elif isinstance(obj, _datetime.tzinfo): # zoneinfo @@ -117,7 +116,7 @@ def _safe_timezone( obj = int(offset.total_seconds()) - obj = cast("Union[str, int]", obj) + obj = cast("str | int", obj) return timezone(obj) @@ -227,7 +226,7 @@ def instance( """ Create a DateTime/Date/Time instance from a datetime/date/time native one. """ - if isinstance(obj, (DateTime, Date, Time)): + if isinstance(obj, DateTime | Date | Time): return obj if isinstance(obj, _datetime.date) and not isinstance(obj, _datetime.datetime): diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index e6f19eb2..56de66c9 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -6,9 +6,7 @@ from typing import TYPE_CHECKING from typing import Any -from typing import Callable from typing import ClassVar -from typing import Optional from typing import cast from typing import overload @@ -42,7 +40,9 @@ if TYPE_CHECKING: - from typing_extensions import Literal + from collections.abc import Callable + from typing import Literal + from typing_extensions import Self from typing_extensions import SupportsIndex @@ -268,7 +268,7 @@ def offset_hours(self) -> float | None: @property def timezone(self) -> Timezone | FixedTimezone | None: - if not isinstance(self.tzinfo, (Timezone, FixedTimezone)): + if not isinstance(self.tzinfo, Timezone | FixedTimezone): return None return self.tzinfo @@ -1006,7 +1006,7 @@ def nth_of(self, unit: str, nth: int, day_of_week: WeekDay) -> Self: if unit not in ["month", "quarter", "year"]: raise ValueError(f'Invalid unit "{unit}" for first_of()') - dt = cast("Optional[Self]", getattr(self, f"_nth_of_{unit}")(nth, day_of_week)) + dt = cast("Self | None", getattr(self, f"_nth_of_{unit}")(nth, day_of_week)) if not dt: raise PendulumException( f"Unable to find occurrence {nth}" diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index 721401df..d52a268a 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -381,7 +381,7 @@ def __floordiv__(self, other: timedelta) -> int: ... def __floordiv__(self, other: int) -> Self: ... def __floordiv__(self, other: int | timedelta) -> int | Duration: - if not isinstance(other, (int, timedelta)): + if not isinstance(other, int | timedelta): return NotImplemented usec = self._to_microseconds() @@ -407,7 +407,7 @@ def __truediv__(self, other: timedelta) -> float: ... def __truediv__(self, other: float) -> Self: ... def __truediv__(self, other: int | float | timedelta) -> Self | float: - if not isinstance(other, (int, float, timedelta)): + if not isinstance(other, int | float | timedelta): return NotImplemented usec = self._to_microseconds() diff --git a/src/pendulum/formatting/formatter.py b/src/pendulum/formatting/formatter.py index 51c90128..b09977d0 100644 --- a/src/pendulum/formatting/formatter.py +++ b/src/pendulum/formatting/formatter.py @@ -6,7 +6,6 @@ from re import Match from typing import TYPE_CHECKING from typing import Any -from typing import Callable from typing import ClassVar from typing import cast @@ -16,6 +15,7 @@ if TYPE_CHECKING: + from collections.abc import Callable from collections.abc import Sequence from pendulum import Timezone diff --git a/src/pendulum/parsing/__init__.py b/src/pendulum/parsing/__init__.py index b66cf1a2..20ebed40 100644 --- a/src/pendulum/parsing/__init__.py +++ b/src/pendulum/parsing/__init__.py @@ -9,7 +9,6 @@ from datetime import datetime from datetime import time from typing import Any -from typing import Optional from typing import cast from dateutil import parser @@ -90,7 +89,7 @@ def _normalize( return parsed if isinstance(parsed, time): - now = cast("Optional[datetime]", options["now"]) or datetime.now() + now = cast("datetime | None", options["now"]) or datetime.now() return datetime( now.year, diff --git a/src/pendulum/time.py b/src/pendulum/time.py index fb3150fa..07a90918 100644 --- a/src/pendulum/time.py +++ b/src/pendulum/time.py @@ -5,7 +5,6 @@ from datetime import time from datetime import timedelta from typing import TYPE_CHECKING -from typing import Optional from typing import cast from typing import overload @@ -21,7 +20,8 @@ if TYPE_CHECKING: - from typing_extensions import Literal + from typing import Literal + from typing_extensions import Self from typing_extensions import SupportsIndex @@ -174,7 +174,7 @@ def __sub__(self, other: time) -> pendulum.Duration: ... def __sub__(self, other: datetime.timedelta) -> Time: ... def __sub__(self, other: time | datetime.timedelta) -> pendulum.Duration | Time: - if not isinstance(other, (Time, time, timedelta)): + if not isinstance(other, Time | time | timedelta): return NotImplemented if isinstance(other, timedelta): @@ -197,7 +197,7 @@ def __rsub__(self, other: time) -> pendulum.Duration: ... def __rsub__(self, other: datetime.timedelta) -> Time: ... def __rsub__(self, other: time | datetime.timedelta) -> pendulum.Duration | Time: - if not isinstance(other, (Time, time)): + if not isinstance(other, Time | time): return NotImplemented if isinstance(other, time): @@ -284,7 +284,7 @@ def replace( minute, second, microsecond, - tzinfo=cast("Optional[datetime.tzinfo]", tzinfo), + tzinfo=cast("datetime.tzinfo | None", tzinfo), fold=fold, ) return self.__class__( From 4f03ef84146140d9ea4b2682dcb4f089185a10bd Mon Sep 17 00:00:00 2001 From: "W. H. Wang" Date: Mon, 12 Jan 2026 00:21:26 +0000 Subject: [PATCH 3/3] Upgrade ruff hook and fix lint issues --- .pre-commit-config.yaml | 2 +- clock | 2 +- src/pendulum/__init__.py | 6 +++--- src/pendulum/datetime.py | 2 +- src/pendulum/duration.py | 4 ++-- src/pendulum/time.py | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98ac5c45..f611b1f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.2 + rev: v0.14.11 hooks: - id: ruff - id: ruff-format diff --git a/clock b/clock index 2ee7e773..5e026907 100755 --- a/clock +++ b/clock @@ -192,7 +192,7 @@ translations = {{}} for k, v in d.items(): v = ( self.format_dict(v, tab + 1) - if isinstance(v, dict | LocaleDataDict) + if isinstance(v, (dict, LocaleDataDict)) else repr(v) ) diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 17fae0fc..16ae0865 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -91,13 +91,13 @@ def _safe_timezone( Creates a timezone instance from a string, Timezone, TimezoneInfo or integer offset. """ - if isinstance(obj, Timezone | FixedTimezone): + if isinstance(obj, (Timezone, FixedTimezone)): return obj if obj is None or obj == "local": return local_timezone() - if isinstance(obj, int | float): + if isinstance(obj, (int, float)): obj = int(obj * 60 * 60) elif isinstance(obj, _datetime.tzinfo): # zoneinfo @@ -226,7 +226,7 @@ def instance( """ Create a DateTime/Date/Time instance from a datetime/date/time native one. """ - if isinstance(obj, DateTime | Date | Time): + if isinstance(obj, (DateTime, Date, Time)): return obj if isinstance(obj, _datetime.date) and not isinstance(obj, _datetime.datetime): diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 56de66c9..da89b13d 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -268,7 +268,7 @@ def offset_hours(self) -> float | None: @property def timezone(self) -> Timezone | FixedTimezone | None: - if not isinstance(self.tzinfo, Timezone | FixedTimezone): + if not isinstance(self.tzinfo, (Timezone, FixedTimezone)): return None return self.tzinfo diff --git a/src/pendulum/duration.py b/src/pendulum/duration.py index d52a268a..721401df 100644 --- a/src/pendulum/duration.py +++ b/src/pendulum/duration.py @@ -381,7 +381,7 @@ def __floordiv__(self, other: timedelta) -> int: ... def __floordiv__(self, other: int) -> Self: ... def __floordiv__(self, other: int | timedelta) -> int | Duration: - if not isinstance(other, int | timedelta): + if not isinstance(other, (int, timedelta)): return NotImplemented usec = self._to_microseconds() @@ -407,7 +407,7 @@ def __truediv__(self, other: timedelta) -> float: ... def __truediv__(self, other: float) -> Self: ... def __truediv__(self, other: int | float | timedelta) -> Self | float: - if not isinstance(other, int | float | timedelta): + if not isinstance(other, (int, float, timedelta)): return NotImplemented usec = self._to_microseconds() diff --git a/src/pendulum/time.py b/src/pendulum/time.py index 07a90918..e5bf22aa 100644 --- a/src/pendulum/time.py +++ b/src/pendulum/time.py @@ -174,7 +174,7 @@ def __sub__(self, other: time) -> pendulum.Duration: ... def __sub__(self, other: datetime.timedelta) -> Time: ... def __sub__(self, other: time | datetime.timedelta) -> pendulum.Duration | Time: - if not isinstance(other, Time | time | timedelta): + if not isinstance(other, (Time, time, timedelta)): return NotImplemented if isinstance(other, timedelta): @@ -197,7 +197,7 @@ def __rsub__(self, other: time) -> pendulum.Duration: ... def __rsub__(self, other: datetime.timedelta) -> Time: ... def __rsub__(self, other: time | datetime.timedelta) -> pendulum.Duration | Time: - if not isinstance(other, Time | time): + if not isinstance(other, (Time, time)): return NotImplemented if isinstance(other, time):