Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/ .
Expand Down
6 changes: 3 additions & 3 deletions tests/test_okf_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions tests/test_python_floor.py
Original file line number Diff line number Diff line change
@@ -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()
28 changes: 26 additions & 2 deletions wiki/wikilint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
16 changes: 3 additions & 13 deletions wiki/wikilint/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading