From 3311db4e16cf63861a17fe174a0a7b3362140ea6 Mon Sep 17 00:00:00 2001 From: Sean Whalen <44679+seanthegeek@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:51:04 -0500 Subject: [PATCH] Drop Python 3.10 and add Python 3.15 to the supported versions and CI (#953) Python 3.10 reaches end of life in October 2026. requires-python is now >=3.11, the unused requires_python key under [build-system] (not read by hatchling, and its <3.15 bound read as excluding 3.15) is removed, and Python 3.15 joins the CI matrix, classifiers, and support tables. Docs, AGENTS.md, and comments that named 3.10 as the floor are updated. Co-authored-by: Claude Sonnet 5.5 --- .github/workflows/python-tests.yml | 2 +- AGENTS.md | 4 ++-- CHANGELOG.md | 6 ++++++ README.md | 3 ++- docs/source/index.md | 3 ++- docs/source/installation.md | 2 +- parsedmarc/__init__.py | 7 ++++--- parsedmarc/cli.py | 6 +++--- parsedmarc/types.py | 9 ++++----- pyproject.toml | 7 +++---- tests/test_cli.py | 9 ++++----- tests/test_init.py | 16 ++++++++-------- 12 files changed, 40 insertions(+), 34 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 83dd8157..673b92a8 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -65,7 +65,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index d7fd89ad..6e9f2612 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ IP address info cached for 4 hours, seen aggregate report IDs cached for 1 hour - Ruff for formatting and linting (configured in `.vscode/settings.json`). Run `ruff check .` and `ruff format --check .` after every code edit, before committing. - Pyright for type checking (configured in `pyproject.toml` `[tool.pyright]`, pinned in the `[build]` extra, enforced in CI). Run `pyright` from the repo root before committing; the whole codebase — library and tests — must stay at zero errors and warnings. Prefer real fixes (narrowing, `Optional` annotations, `TYPE_CHECKING` imports) over `# pyright: ignore[...]`; reserve targeted ignores for deliberate wrong-type tests and version-conditional imports, and never use a bare blanket ignore. - TypedDict for structured data, type hints throughout. -- Python ≥3.10 required. Use modern type-hint syntax: PEP 585 builtins (`list[str]`, `dict[str, Any]`) and PEP 604 unions (`X | Y`, `X | None`) — not `typing.List` / `Union` / `Optional`. Ruff enforces this (`UP006`/`UP007`/`UP035`/`UP045` in `pyproject.toml`). `typing.NotRequired` / `Required` are 3.11+, so for optional TypedDict keys use `total=False` (see `parsedmarc/types.py`). +- Python ≥3.11 required. Use modern type-hint syntax: PEP 585 builtins (`list[str]`, `dict[str, Any]`) and PEP 604 unions (`X | Y`, `X | None`) — not `typing.List` / `Union` / `Optional`. Ruff enforces this (`UP006`/`UP007`/`UP035`/`UP045` in `pyproject.toml`). `typing.NotRequired` / `Required` are available (3.11+), but the existing TypedDicts use `total=False` for optional keys (see `parsedmarc/types.py`); keep one style per module. - Tests live under `tests/` as `tests/test_.py`, one per top-level `parsedmarc/*` module (e.g. `tests/test_init.py` for `parsedmarc/__init__.py`, `tests/test_cli.py` for `parsedmarc/cli.py`). All test classes use `unittest`. Sample reports live in `samples/`. Run with `pytest tests/`; run one file with `pytest tests/test_init.py`. New tests go in the file whose module they exercise — do not reintroduce a monolithic test file. - File path config values must be wrapped with `_expand_path()` in `cli.py`. - Maildir UID checks are intentionally relaxed (warn, don't crash) for Docker compatibility. @@ -133,7 +133,7 @@ Assume every regex in the package runs on content a remote sender controls (mess - **Anchor when the match can only start at a line start** (`^` with `re.MULTILINE`); an unanchored `findall` retries the whole pattern from every offset. - **Prefer `str` methods** (`split`, `partition`, `startswith`) when the format is fixed and a regex adds nothing, and a plain loop when the grammar nests: balanced parentheses cannot be matched by a regex at all, which is why `human_timestamp_to_datetime()` strips RFC 5322 comments with a depth-counting scan. - **Time the pattern on an adversarial input before shipping it**, not just on the samples: a 1 MB string of the repeated opener with no closer, and a 200,000-character whitespace run. A linear pattern finishes in milliseconds; anything that takes seconds is a bug. Add that input as a time-bound regression test with a generous bound (seconds) and cite the measured old timing in the docstring, as the regression tests in `tests/test_init.py` and `tests/test_utils.py` do. -- Python 3.10 is the floor, so possessive quantifiers and atomic groups (3.11+) are not available; fix the pattern's structure instead. +- Possessive quantifiers and atomic groups (3.11+) are available now that 3.11 is the floor, but fix the pattern's structure first: an atomic group papers over a quantifier overlap rather than removing it. ### Credentials must never reach logs or error text diff --git a/CHANGELOG.md b/CHANGELOG.md index 97fbd2aa..e7947b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Changed + +- **Python 3.10 is no longer supported, and Python 3.15 is.** Python 3.10 reached end of life in October 2026; `requires-python` is now `>=3.11`. Python 3.15 is now tested in CI and listed as supported. + ## 11.2.2 ### Security diff --git a/README.md b/README.md index d7d8c57b..34b6e09b 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,9 @@ for RHEL or Debian. | 3.7 | ❌ | End of Life (EOL) | | 3.8 | ❌ | End of Life (EOL) | | 3.9 | ❌ | Used in Debian 11 and RHEL 9, but not supported by project dependencies | -| 3.10 | ✅ | Actively maintained | +| 3.10 | ❌ | End of Life (EOL) | | 3.11 | ✅ | Actively maintained; supported until June 2028 (Debian 12) | | 3.12 | ✅ | Actively maintained; supported until May 2035 (RHEL 10) | | 3.13 | ✅ | Actively maintained; supported until June 2030 (Debian 13) | | 3.14 | ✅ | Supported (requires `imapclient>=3.1.0`) | +| 3.15 | ✅ | Supported | diff --git a/docs/source/index.md b/docs/source/index.md index ba23abc2..32a6ec5e 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -58,11 +58,12 @@ for RHEL or Debian. | 3.7 | ❌ | End of Life (EOL) | | 3.8 | ❌ | End of Life (EOL) | | 3.9 | ❌ | Used in Debian 11 and RHEL 9, but not supported by project dependencies | -| 3.10 | ✅ | Actively maintained | +| 3.10 | ❌ | End of Life (EOL) | | 3.11 | ✅ | Actively maintained; supported until June 2028 (Debian 12) | | 3.12 | ✅ | Actively maintained; supported until May 2035 (RHEL 10) | | 3.13 | ✅ | Actively maintained; supported until June 2030 (Debian 13) | | 3.14 | ✅ | Supported (requires `imapclient>=3.1.0`) | +| 3.15 | ✅ | Supported | ```{toctree} :caption: 'Contents' diff --git a/docs/source/installation.md b/docs/source/installation.md index 809acce2..4e74f971 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -70,7 +70,7 @@ sudo dnf install -y python3 python3-pip python3-devel libxml2-devel libxslt-deve Python 3 installers for Windows and macOS can be found at . -`parsedmarc` requires Python 3.10 or newer. If your distribution's +`parsedmarc` requires Python 3.11 or newer. If your distribution's default `python3` is older, install a newer interpreter (e.g. `python3.12`) and substitute it for `python3` in the commands below. diff --git a/parsedmarc/__init__.py b/parsedmarc/__init__.py index ef59c3e1..894213dd 100644 --- a/parsedmarc/__init__.py +++ b/parsedmarc/__init__.py @@ -1229,10 +1229,11 @@ def _csv_safe(value: Any) -> Any: Two things happen to a string, in this order: - 1. NUL characters are stripped. Python 3.10's ``csv`` writer raises + 1. NUL characters are stripped. Python 3.10's ``csv`` writer raised ``_csv.Error: need to escape, but no escapechar set`` on any field - containing NUL (CPython issue 97503, fixed in 3.11), and the - character is dropped to match ``get_filename_safe_string``. + containing NUL (CPython issue 97503, fixed in 3.11; the floor is now + 3.11), and NUL is still dropped to match + ``get_filename_safe_string``. 2. A string starting with ``=``, ``+``, ``-``, ``@``, tab, or carriage return gets a leading ``'``. Spreadsheet applications evaluate such a cell as a formula, and report text fields come from untrusted diff --git a/parsedmarc/cli.py b/parsedmarc/cli.py index 5c3a7c08..1e62695b 100644 --- a/parsedmarc/cli.py +++ b/parsedmarc/cli.py @@ -118,9 +118,9 @@ else: # dnspython 2.9+ does DNS over HTTPS through httpx2 (installed by its # [doh] extra); 2.7 and 2.8 use httpx, so httpx2 may be absent. It may -# also be present without dnspython having imported it: dnspython 2.9 -# needs Python 3.11, but httpx2 installs on 3.10 and other tools pull it -# in. _RedactUrlCredentialsFilter must recognize httpx2.URL whenever +# also be present without dnspython having imported it: other tools +# install httpx2 too, and an older dnspython never imports it. +# _RedactUrlCredentialsFilter must recognize httpx2.URL whenever # httpx2 can log one, so it is imported here rather than taken from # dns.query (unlike utils.py's _doh_client_class(), which has to match # the library dnspython itself imported). diff --git a/parsedmarc/types.py b/parsedmarc/types.py index 4182660b..e6aefcb7 100644 --- a/parsedmarc/types.py +++ b/parsedmarc/types.py @@ -2,11 +2,10 @@ from __future__ import annotations from typing import Any, Literal, TypedDict -# NOTE: This module targets Python 3.10. -# - PEP 604 unions (A | B) and PEP 585 generics (list[str]) are used; both are -# available in 3.10. -# - No typing.NotRequired / Required (3.11+); for optional TypedDict keys, use -# total=False TypedDicts. +# NOTE: This module targets Python 3.11. +# - PEP 604 unions (A | B) and PEP 585 generics (list[str]) are used. +# - typing.NotRequired / Required are available (3.11+), but this module keeps +# total=False TypedDicts for optional keys; keep one style per module. ReportType = Literal["aggregate", "failure", "smtp_tls"] diff --git a/pyproject.toml b/pyproject.toml index 882f4cea..f7503ba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,6 @@ requires = [ "hatchling>=1.27.0", ] -requires_python = ">=3.10,<3.15" build-backend = "hatchling.build" [project] @@ -29,13 +28,13 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", ] -requires-python = ">=3.10" +requires-python = ">=3.11" # The base install is the parsing core plus a working core CLI: file, # IMAP, Maildir, and mbox input; CSV/JSON, Splunk HEC, webhook, and # syslog output. Every other output and mailbox integration lives in an extra @@ -204,7 +203,7 @@ exclude = [ # effect. # # UP006/UP007/UP035/UP045 enforce modern type-hint syntax: with -# requires-python >=3.10, PEP 585 builtins (list[int]) and PEP 604 unions +# requires-python >=3.11, PEP 585 builtins (list[int]) and PEP 604 unions # (X | Y, X | None) are available, so keep the deprecated typing.List / # Union / Optional spellings out of the codebase. select = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 02c09ee1..71b7c2c3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6072,11 +6072,10 @@ class TestHttpx2LogRedaction(unittest.TestCase): class TestHttpx2UrlClassImport(unittest.TestCase): """GHSA-hjr3-rx4c-24cv: ``_HTTP_URL_CLASSES`` must recognize ``httpx2.URL`` whenever httpx2 is importable, not only when dnspython - imported it. dnspython 2.9 requires Python >= 3.11, so on Python 3.10 - CI installs dnspython 2.8 (which never imports httpx2) while httpx2 - still installs there (hatch in the ``[build]`` extra requires it), so - a constant taken from ``dns.query`` left ``httpx2.URL`` arguments on - the ``httpx2`` logger unmasked there.""" + imported it. dnspython 2.8 never imports httpx2 while httpx2 can + still be installed (hatch in the ``[build]`` extra requires it), so a + constant taken from ``dns.query`` would leave ``httpx2.URL`` arguments + on the ``httpx2`` logger unmasked there.""" def _exec_fresh_cli(self): """Execute parsedmarc.cli's source into a throwaway module and diff --git a/tests/test_init.py b/tests/test_init.py index 45a8f321..09499bb9 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1048,12 +1048,12 @@ class Test(unittest.TestCase): """A NUL character in an SMTP TLS report text field is stripped from CSV output instead of reaching the ``csv`` writer. - Python 3.10's ``csv`` module raises ``_csv.Error: need to escape, + Python 3.10's ``csv`` module raised ``_csv.Error: need to escape, but no escapechar set`` on any field containing NUL (https://github.com/python/cpython/issues/97503, a 3.10 regression - fixed in 3.11). This test fails on unfixed code on 3.10 with that - error, and on unfixed code on 3.11+ because the NUL passes through - to the CSV text uncleaned.""" + fixed in 3.11; the floor is now 3.11). NUL is still dropped to match + ``get_filename_safe_string``, so this test fails on unfixed code + because the NUL passes through to the CSV text uncleaned.""" result = parsedmarc.parse_report_file( "samples/smtp_tls/smtp_tls.json", offline=True ) @@ -2411,12 +2411,12 @@ class Test(unittest.TestCase): """A NUL character in a failure report text field is stripped from CSV output instead of reaching the ``csv`` writer. - Python 3.10's ``csv`` module raises ``_csv.Error: need to escape, + Python 3.10's ``csv`` module raised ``_csv.Error: need to escape, but no escapechar set`` on any field containing NUL (https://github.com/python/cpython/issues/97503, a 3.10 regression - fixed in 3.11). This test fails on unfixed code on 3.10 with that - error, and on unfixed code on 3.11+ because the NUL passes through - to the CSV text uncleaned.""" + fixed in 3.11; the floor is now 3.11). NUL is still dropped to match + ``get_filename_safe_string``, so this test fails on unfixed code + because the NUL passes through to the CSV text uncleaned.""" parsed_report = cast( FailureReport, parsedmarc.parse_report_file(