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(