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 <noreply@anthropic.com>
This commit is contained in:
Sean WhalenandClaude Sonnet 5.5 authored and GitHub committed 2026-11-10 12:51:04 -05:00
1 parent 5c20eb8016
commit 3311db4e16
12 files changed
+40 -34

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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_<module>.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
+6
View File
@@ -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
+2 -1
View File
@@ -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 |
+2 -1
View File
@@ -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'
+1 -1
View File
@@ -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
<https://www.python.org/downloads/>.
`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.
+4 -3
View File
@@ -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
+3 -3
View File
@@ -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).
+4 -5
View File
@@ -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"]
+3 -4
View File
@@ -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 = [
+4 -5
View File
@@ -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
+8 -8
View File
@@ -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(