From c4c0f086a3441b9af3d519b3eafdcb764fa6b7a8 Mon Sep 17 00:00:00 2001 From: SunsetDrifter Date: Thu, 8 Oct 2026 10:52:00 +0200 Subject: [PATCH] chore: require Python 3.11+, the oldest supported CPython 3.9 went end-of-life in 2025-10 and 3.10 on 2026-10-01; local development ran on 3.9 while CI ran 3.12, which is how a 3.9-only datetime bug slipped past CI. The floor is now 3.11 (wikilint.MIN_PYTHON), tracking the oldest CPython in security support and bumped each October. - wikilint/__init__.py checks the interpreter before importing the engine, so an older python3 (including via the pre-commit hook) gets a one-line message naming the version found instead of a traceback. - CI runs a 3.11 + 3.14 matrix: the floor and the newest release. - parse_okf_datetime drops its Z/fraction/+HHMM normalization; 3.11's fromisoformat parses those natively. The regression tests stay. - README states the requirement and the macOS /usr/bin/python3 caveat. --- .github/workflows/test.yml | 6 +++++- README.md | 2 ++ tests/test_okf_fields.py | 6 +++--- tests/test_python_floor.py | 42 ++++++++++++++++++++++++++++++++++++++ wiki/wikilint/__init__.py | 28 +++++++++++++++++++++++-- wiki/wikilint/model.py | 16 +++------------ 6 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 tests/test_python_floor.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19de8d8..d1261f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,9 +6,13 @@ on: jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + # The supported floor (wikilint.MIN_PYTHON) and the newest release. + python-version: ["3.11", "3.14"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index 0c9f3a9..6278a98 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ One layering note when comparing bundles across producers: OKF reserves two file ## Install +Requires Python 3.11+ as `python3` (the lint engine and pre-commit hook are stdlib-only, no packages to install). The floor tracks the oldest CPython still in security support and moves up each October; older interpreters get a one-line message naming the version found. On macOS the bundled `/usr/bin/python3` is too old, so put a Homebrew or uv Python first on `PATH`. + ```sh mkdir my-wiki && cd my-wiki && git init cp -r path/to/Scriptorium/wiki/ . diff --git a/tests/test_okf_fields.py b/tests/test_okf_fields.py index 99fd7c5..99f946b 100644 --- a/tests/test_okf_fields.py +++ b/tests/test_okf_fields.py @@ -61,9 +61,9 @@ def test_offset_forms_accepted(self): self.assertIsNotNone(parse_okf_datetime("2026-10-08T14:00:00Z")) self.assertIsNotNone(parse_okf_datetime("2026-10-08T14:00:00+05:30")) - def test_forms_python_39_fromisoformat_rejects(self): - """Review regression: 3.9's fromisoformat refuses these RFC 3339 - forms that 3.11+ accepts; results must not depend on the Python.""" + def test_rfc3339_variants_accepted(self): + """Review regression: fraction lengths and compact offsets must + parse (3.9 rejected them; the 3.11 floor parses them natively).""" for value in ("2026-10-08T14:00:00.5Z", "2026-10-08T14:00:00.123456789Z", "2026-10-08T14:00:00+0000", "2026-10-08T14:00:00.25-0530"): self.assertIsNotNone(parse_okf_datetime(value), value) diff --git a/tests/test_python_floor.py b/tests/test_python_floor.py new file mode 100644 index 0000000..93673ae --- /dev/null +++ b/tests/test_python_floor.py @@ -0,0 +1,42 @@ +"""The engine's Python floor: the oldest CPython still in security support +(3.11 until 2027-10). Older interpreters must get one clear message at +import, before any module that may use newer stdlib behavior loads.""" + +import unittest + +from helpers import REPO + + +class TestPythonFloor(unittest.TestCase): + def test_floor_is_3_11(self): + from wikilint import MIN_PYTHON + self.assertEqual(MIN_PYTHON, (3, 11)) + + def test_old_interpreter_gets_a_clear_message(self): + from wikilint import python_version_error + msg = python_version_error((3, 9, 6)) + self.assertIn("3.11+", msg) + self.assertIn("3.9.6", msg) + + def test_floor_and_newer_pass(self): + from wikilint import python_version_error + self.assertIsNone(python_version_error((3, 11, 0))) + self.assertIsNone(python_version_error((3, 14, 4))) + + def test_guard_runs_before_engine_imports(self): + """The check must fire before cli (and everything it imports) loads, + or an old interpreter could fail on newer stdlib use first.""" + source = (REPO / "wiki" / "wikilint" / "__init__.py").read_text() + self.assertLess(source.index("raise SystemExit"), source.index("from .cli import")) + + def test_readme_states_the_floor(self): + self.assertIn("Python 3.11+", (REPO / "README.md").read_text()) + + def test_ci_tests_floor_and_latest(self): + workflow = next((REPO / ".github" / "workflows").glob("*.yml")).read_text() + self.assertIn('"3.11"', workflow) + self.assertIn('"3.14"', workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/wikilint/__init__.py b/wiki/wikilint/__init__.py index 99172a8..03fb6fd 100644 --- a/wiki/wikilint/__init__.py +++ b/wiki/wikilint/__init__.py @@ -2,6 +2,30 @@ fixes propagate by copy; per-wiki behavior lives entirely in the CONFIG dict and index_entry_extra() defined in the wiki root's lint.py.""" -from .cli import main +import sys -__all__ = ["main"] +# The oldest CPython still in security support; bumped each October as the +# next release reaches end of life. This module must stay parseable by older +# interpreters so they get the message below instead of a traceback. +MIN_PYTHON = (3, 11) + + +def python_version_error(version_info=None): + """A one-line explanation when the interpreter is below MIN_PYTHON.""" + v = tuple(version_info or sys.version_info) + if v[:2] >= MIN_PYTHON: + return None + found = ".".join(str(part) for part in v[:3]) + return ( + f"Scriptorium's lint engine needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+, " + f"found {found}. Install a newer python3 and put it first on PATH." + ) + + +_version_error = python_version_error() +if _version_error: + raise SystemExit(_version_error) + +from .cli import main # noqa: E402 + +__all__ = ["MIN_PYTHON", "main", "python_version_error"] diff --git a/wiki/wikilint/model.py b/wiki/wikilint/model.py index 5b2516e..bd3b989 100644 --- a/wiki/wikilint/model.py +++ b/wiki/wikilint/model.py @@ -49,10 +49,6 @@ # required whitespace after the colon keeps bare URLs (scheme:// has none) # parsing as plain strings. LIST_MAP_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):\s+(\S.*)$") -# Datetime pieces Python 3.9's fromisoformat rejects: a fraction that is not -# exactly 3 or 6 digits, and a colon-less +HHMM offset. -ISO_FRACTION_RE = re.compile(r"\.(\d+)(?=[+-]\d|$)") -ISO_COMPACT_OFFSET_RE = re.compile(r"([+-])(\d{2})(\d{2})$") class Report: @@ -207,18 +203,12 @@ def parse_iso_date(value): def parse_okf_datetime(value): """An OKF timestamp (SPEC ยง5): ISO 8601 datetime with an explicit UTC offset. Date-only and offset-less values return None, as the spec tells - consumers to ignore them. Z, fractions of any length and +HHMM offsets - are normalized first: datetime.fromisoformat accepts them only from - Python 3.11, and results must not depend on the interpreter.""" + consumers to ignore them. Python 3.11+ fromisoformat parses the full + form natively: Z, fractions of any length, +HHMM offsets.""" if not isinstance(value, str) or "T" not in value: return None - text = value.strip() - if text[-1:] in ("Z", "z"): - text = text[:-1] + "+00:00" - text = ISO_FRACTION_RE.sub(lambda m: "." + m.group(1)[:6].ljust(6, "0"), text) - text = ISO_COMPACT_OFFSET_RE.sub(r"\1\2:\3", text) try: - parsed = datetime.fromisoformat(text) + parsed = datetime.fromisoformat(value.strip()) except ValueError: return None return parsed if parsed.tzinfo is not None else None