diff --git a/.github/scripts/api_compat.py b/.github/scripts/api_compat.py new file mode 100644 index 0000000..f975acc --- /dev/null +++ b/.github/scripts/api_compat.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Fail when the pyshex API breaks relative to the previous release without a matching version bump. + +Uses griffe (https://mkdocstrings.github.io/griffe/) to diff the code against the previous +``v*`` release tag. A breaking change passes only if one of these holds: + +* ``--new-version`` bumps the major version, or the minor version while the major is 0; +* ``ALLOW_BREAKING=1`` is set. CI sets it on pull requests labelled ``breaking-change``. + +Run it locally with: + + uv run --no-project --with 'griffe>=2,<3' python .github/scripts/api_compat.py +""" +import argparse +import os +import re +import subprocess +import sys + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout.strip() + + +def previous_release(exclude: str | None) -> str | None: + tags = git("tag", "--merged", "HEAD", "--sort=-v:refname", "--list", "v[0-9]*").split() + return next((t for t in tags if t != exclude), None) + + +def major_minor(version: str) -> tuple[int, int]: + m = re.match(r"v?(\d+)\.(\d+)", version) + if not m: + sys.exit(f"cannot parse version {version!r}") + return int(m[1]), int(m[2]) + + +def allows_breaking(old: str, new: str) -> bool: + (old_major, old_minor), (new_major, new_minor) = major_minor(old), major_minor(new) + return new_major > old_major or (new_major == old_major == 0 and new_minor > old_minor) + + +# "Attribute value was changed" compares the right-hand side of assignments, including +# instance attributes set in __init__ (e.g. `self.x = URIRef(x)` -> `self.x = x`). +# It reports refactorings, not interface changes, so it is not treated as breaking. +IGNORED_KINDS = {"ATTRIBUTE_CHANGED_VALUE"} + + +def find_breakages(against: str) -> list: + import griffe + + old = griffe.load_git("pyshex", ref=against, repo=".") + new = griffe.load("pyshex", search_paths=["."]) + style = griffe.ExplanationStyle.GITHUB if os.environ.get("GITHUB_ACTIONS") else griffe.ExplanationStyle.ONE_LINE + breakages, ignored = [], 0 + for breakage in griffe.find_breaking_changes(old, new): + if breakage.kind.name in IGNORED_KINDS: + ignored += 1 + continue + breakages.append(breakage) + print(breakage.explain(style=style)) + if ignored: + print(f"({ignored} attribute-value change(s) ignored)") + return breakages + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--new-version", help="version being released, e.g. the release tag v0.10.0") + parser.add_argument("--against", help="git ref to compare with (default: previous v* tag)") + args = parser.parse_args() + + against = args.against or previous_release(exclude=args.new_version) + if against is None: + print("No previous release tag found; nothing to compare against.") + return 0 + + print(f"Comparing the pyshex API with {against}", flush=True) + breakages = find_breakages(against) + if not breakages: + print("No breaking API changes.") + return 0 + + if args.new_version and allows_breaking(against, args.new_version): + print(f"Breaking changes accepted: {args.new_version} is a breaking-change release after {against}.") + return 0 + if os.environ.get("ALLOW_BREAKING") == "1": + print("Breaking changes accepted because ALLOW_BREAKING=1 (pull request labelled 'breaking-change').") + return 0 + print( + f"\nThe changes above break the API released in {against}. Either restore compatibility, or, if the break " + "is intended, label the pull request 'breaking-change', record it in ChangeLog, and release it as a new " + "major version (a new minor version while PyShEx is 0.x).", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a20207c..377df54 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -6,6 +6,10 @@ on: branches: - master pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + schedule: + # Weekly, to catch breakage from new upstream releases and new Python versions + - cron: "17 5 * * 1" workflow_dispatch: jobs: @@ -26,6 +30,30 @@ jobs: - name: Run codespell run: tox -e codespell + policy: + # LinkML's constraints: backward-compatible API, current Python support, no unexpected heavy dependencies. + # Runs the same hooks as pre-commit, so a local commit that passes will pass here. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + - name: Install dependencies + run: uv sync --group dev + - name: Pre-commit hooks (lockfile, contract and policy tests) + run: uvx pre-commit run --all-files --show-diff-on-failure + - name: API compatibility with the previous release + env: + ALLOW_BREAKING: ${{ contains(github.event.pull_request.labels.*.name, 'breaking-change') && '1' || '0' }} + run: uv run --no-project --with 'griffe>=2,<3' python .github/scripts/api_compat.py + test: needs: - quality-checks @@ -80,4 +108,61 @@ jobs: name: codecov-results-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml - fail_ci_if_error: false \ No newline at end of file + fail_ci_if_error: false + + downstream: + # Install this PyShEx with the latest releases of its known PyPI clients in ONE resolution + # (proves our pins stay compatible with theirs), then run the clients against it. + needs: + - quality-checks + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.14"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # tags give the real version; linkml needs pyshex >= 0.9.0 + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: ${{ env.UV_VERSION }} + python-version: ${{ matrix.python-version }} + - name: Install PyShEx with linkml and jupyter-rdfify + run: | + uv venv "$RUNNER_TEMP/downstream" + uv pip install --python "$RUNNER_TEMP/downstream" . linkml jupyter-rdfify pytest + uv pip list --python "$RUNNER_TEMP/downstream" | grep -Ei '^(pyshex|linkml|jupyter-rdfify|rdflib) ' + - name: Run contract tests against the installed packages + # Copy the tests out of the checkout so they import the installed pyshex, not the source tree + run: | + cp -r tests/test_contract "$RUNNER_TEMP/test_contract" + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/downstream/bin/python" -m pytest -p no:cacheprovider -rs test_contract + + python-next: + # Early warning for the next CPython release (pre-releases). Does not block merging. + needs: + - quality-checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # tags give the real version; linkml needs pyshex >= 0.9.0 + - name: Install uv + # Latest uv: older releases do not know about new CPython pre-releases + uses: astral-sh/setup-uv@v7 + - name: Test on the next Python + id: next + continue-on-error: true + run: | + uv python install 3.15 + uv venv -p 3.15 "$RUNNER_TEMP/next" + uv pip install --python "$RUNNER_TEMP/next" . pytest + cp -r tests/test_contract "$RUNNER_TEMP/test_contract" + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/next/bin/python" -m pytest -p no:cacheprovider test_contract + - name: Report next-Python failure as a warning + if: steps.next.outcome == 'failure' + run: echo "::warning title=Next Python::PyShEx tests fail on the upcoming Python release; see the 'Test on the next Python' step" diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index 2c083d2..d30ea01 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -34,9 +34,58 @@ jobs: path: dist/ + verify: + # Refuse to publish a release that would break existing users (see tests/test_contract/README.md) + name: Verify distributions 🔍 + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.14"] + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + + - name: Download dist + uses: actions/download-artifact@v7 + with: + name: dist + path: dist/ + + - name: Check package metadata + run: uvx twine check --strict dist/* + + - name: Dependency policy, with no exceptions for non-PyPI sources + # Published wheels resolve every dependency from PyPI, whatever [tool.uv.sources] says + env: + PYSHEX_RELEASE_CHECK: "1" + run: uv run --group dev pytest -p no:cacheprovider tests/test_policy + + - name: No breaking API change unless this is a breaking-change release + run: uv run --no-project --with 'griffe>=2,<3' python .github/scripts/api_compat.py --new-version "${{ github.event.release.tag_name }}" + + - name: Contract tests against the built wheel + # Run from outside the checkout so the tests import the installed wheel + run: | + uv venv "$RUNNER_TEMP/verify" + uv pip install --python "$RUNNER_TEMP/verify" dist/*.whl pytest + cp -r tests/test_contract "$RUNNER_TEMP/test_contract" + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/verify/bin/shexeval" --help > /dev/null + "$RUNNER_TEMP/verify/bin/python" -m pytest -p no:cacheprovider test_contract + + publish-testpypi: name: Publish to TestPyPI - needs: build + needs: verify if: github.event.release.prerelease == true runs-on: ubuntu-latest @@ -60,7 +109,7 @@ jobs: publish-pypi: name: Publish to PyPI - needs: build + needs: verify if: github.event.release.prerelease == false runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2d3f744 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +# Maintenance guardrails for PyShEx. Install once with: +# uvx pre-commit install +# This installs both the pre-commit and pre-push hooks listed below. +# Run the hooks on demand with: +# uvx pre-commit run --all-files # commit-time hooks +# uvx pre-commit run --all-files --hook-stage pre-push # API diff against the last release +default_install_hook_types: [pre-commit, pre-push] +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=1024] + + - repo: local + hooks: + - id: uv-lock-check + name: uv.lock matches pyproject.toml + entry: uv lock --check + language: system + files: ^(pyproject\.toml|uv\.lock)$ + pass_filenames: false + + - id: contract-and-policy-tests + name: API contract, client and dependency/Python policy tests + entry: uv run --frozen pytest -q -p no:cacheprovider tests/test_contract tests/test_policy + language: system + files: ^(pyshex/|pyproject\.toml$|uv\.lock$|tests/test_contract/|tests/test_policy/|\.github/workflows/) + pass_filenames: false + + - id: api-compat + name: no breaking API changes since the last release (griffe) + entry: uv run --no-project --with griffe>=2,<3 python .github/scripts/api_compat.py + language: system + files: ^pyshex/ + pass_filenames: false + stages: [pre-push] diff --git a/README.md b/README.md index 4dbbb52..bbe684d 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,25 @@ docker build -t pyshex docker docker run --rm -it pyshex -gn '' -ss -ut -pr -sq 'select distinct ?item where{?item a } LIMIT 1' http://graphdb.dumontierlab.com/repositories/ncats-red-kg https://github.com/biolink/biolink-model/raw/master/shex/biolink-modelnc.shex ``` + +## Maintenance guardrails + +PyShEx is a dependency of [LinkML](https://github.com/linkml/linkml), which asks that +PyPI releases don't break compatibility, keep up with current Python versions, +and don't add unexpected heavyweight dependencies. Automated checks enforce this: + +* `tests/test_contract` freezes the public API and the call patterns of known clients + (linkml and jupyter-rdfify). See its README for what to do when a test fails. +* `tests/test_policy` checks the runtime dependency allowlist and Python version support. +* `.github/scripts/api_compat.py` uses griffe to diff the API against the previous release. + A breaking change needs the `breaking-change` label on the pull request + and a major version bump (minor while 0.x) at release time. +* The `downstream` CI job installs this tree with the latest linkml and jupyter-rdfify + and runs their usage against it. The release workflow re-runs the contract tests + on the built wheel before publishing. + +Install the git hooks once: + +```shell +uvx pre-commit install +``` diff --git a/pyproject.toml b/pyproject.toml index 06560aa..4cc2c65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ packages = ["pyshex"] dev = [ "pytest", "coverage", + "packaging", ] [tool.pytest.ini_options] @@ -83,7 +84,7 @@ skip = [ [tool.tox] requires = ["tox>=4"] -env_list = ["lint", "py{310,311,312,313}"] +env_list = ["lint", "py{310,311,312,313,314}"] [tool.tox.env_run_base] allowlist_externals = ["uv"] diff --git a/tests/test_contract/README.md b/tests/test_contract/README.md new file mode 100644 index 0000000..1ca630b --- /dev/null +++ b/tests/test_contract/README.md @@ -0,0 +1,29 @@ +# Contract tests + +These tests pin down what PyShEx promises to the code that depends on it. +They are self-contained (no network, no files outside this directory), +so CI also runs them against the built wheel before a release is published. + +| File | What it protects | +|---|---| +| `test_public_api.py` | Signatures, exports and CLI options of the public API. | +| `test_client_linkml.py` | The call patterns [linkml](https://github.com/linkml/linkml) uses. | +| `test_client_jupyter_rdfify.py` | The call patterns [jupyter-rdfify](https://pypi.org/project/jupyter-rdfify/) 1.0.4 uses. | +| `test_downstream_packages.py` | The real downstream packages, when they are installed (CI `downstream` job). | + +## When a test here fails + +A failure means a PyPI release built from this tree could break existing users. + +* If the change was accidental, fix the code, not the test. +* If the break is intentional, bump the version accordingly + (major, or minor while PyShEx is < 1.0), record it in `ChangeLog`, + and update the expectation in the same pull request so reviewers see it. + +Adding new optional parameters at the end of a signature, or exporting new names, +is compatible and does not require changing these tests. + +## Other clients on PyPI + +The PyPI project `ontology` (0.1.0) appears in a search for PyShEx but is an empty +placeholder with no dependencies, so it imposes no constraints. diff --git a/tests/test_contract/__init__.py b/tests/test_contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_contract/test_client_jupyter_rdfify.py b/tests/test_contract/test_client_jupyter_rdfify.py new file mode 100644 index 0000000..372460c --- /dev/null +++ b/tests/test_contract/test_client_jupyter_rdfify.py @@ -0,0 +1,76 @@ +"""Call patterns that jupyter-rdfify 1.0.4 (https://pypi.org/project/jupyter-rdfify/) uses. + +It declares ``pyshex>=0.8.0`` and its ``%%rdf shex`` cell magic (``jupyter-rdfify/shex.py``) does: + + loader = SchemaLoader() # pyshex.utils.schema_loader + evaluator = ShExEvaluator() # from pyshex import ShExEvaluator, no arguments + schema = loader.loads(prefix + cell) # ShExC text, prefixes stored in a separate cell + for r in evaluator.evaluate(graph, schema, start=start, focus=focus): # start/focus may be None + r.start, r.focus, r.result, r.reason + +The same evaluator instance is reused across cells. A parse error may either raise +or return None: jupyter-rdfify wraps ``loads`` in try/except and also checks for None. +""" +import pytest +from rdflib import Graph + +from pyshex import ShExEvaluator +from pyshex.utils.schema_loader import SchemaLoader + +PREFIX_CELL = """PREFIX ex: +PREFIX xsd: +""" +SHAPE_CELL = """start = @ex:Person +ex:Person CLOSED { ex:name xsd:string ; ex:age xsd:integer ? } +""" +TTL = """@prefix ex: . +ex:alice ex:name "Alice" ; ex:age 42 . +ex:bob ex:name 17 . +""" +EX = "http://example.org/" + + +@pytest.fixture(scope="module") +def evaluator() -> ShExEvaluator: + return ShExEvaluator() + + +@pytest.fixture(scope="module") +def schema(): + schema = SchemaLoader().loads(PREFIX_CELL + "\n" + SHAPE_CELL) + assert schema is not None + return schema + + +@pytest.fixture(scope="module") +def graph() -> Graph: + return Graph().parse(data=TTL, format="turtle") + + +def summarize(results): + return {(str(r.focus), str(r.start), r.result) for r in results} + + +def test_no_start_no_focus_evaluates_all_subjects(evaluator, schema, graph): + results = evaluator.evaluate(graph, schema, start=None, focus=None) + assert summarize(results) == {(EX + "alice", EX + "Person", True), (EX + "bob", EX + "Person", False)} + failed = [r for r in results if not r.result] + assert failed and isinstance(failed[0].reason, str) and failed[0].reason + + +def test_explicit_start_and_focus(evaluator, schema, graph): + results = evaluator.evaluate(graph, schema, start=EX + "Person", focus=EX + "alice") + assert summarize(results) == {(EX + "alice", EX + "Person", True)} + + +def test_focus_only_uses_schema_start(evaluator, schema, graph): + results = evaluator.evaluate(graph, schema, start=None, focus=EX + "bob") + assert summarize(results) == {(EX + "bob", EX + "Person", False)} + + +def test_parse_error_is_reported_as_none_or_exception(): + try: + schema = SchemaLoader().loads(PREFIX_CELL + "ex:Broken { ex:name ") + except Exception: + return + assert schema is None diff --git a/tests/test_contract/test_client_linkml.py b/tests/test_contract/test_client_linkml.py new file mode 100644 index 0000000..08a96f4 --- /dev/null +++ b/tests/test_contract/test_client_linkml.py @@ -0,0 +1,97 @@ +"""Call patterns that linkml (https://github.com/linkml/linkml) uses. + +linkml declares ``pyshex >= 0.9.0`` and ``rdflib >= 7.6.0`` and supports Python >= 3.10. +Its usages, reproduced here with a small schema in the style of linkml's ShExGenerator: + +* ``tests/linkml/test_generators/test_shexgen.py``: + ``evaluate(g, shexstr, focus=node)`` for every subject ``node`` (a URIRef) in the graph, + without a start shape. It must return, not raise. +* ``tests/linkml/test_notebooks/input/examples.py``: + ``r = evaluate(g, shex, start=, focus=)`` then ``r[0]`` / ``r[1]``. +* ``tests/linkml/test_scripts/test_gen_shex.py``: + ``ShExEvaluator(g, str(shex_file), focus, start).evaluate(debug=False)`` with positional + arguments, then ``r.result`` / ``r.reason`` on each result. +""" +from rdflib import Graph, URIRef + +from pyshex import ShExEvaluator +from pyshex.evaluate import evaluate + +# Shaped like ShExGenerator output: BASE, prefixed IRIs, CLOSED/EXTRA shapes, rdf:type constraint. +SHEX = """BASE +PREFIX rdf: +PREFIX xsd: +PREFIX schema: +PREFIX linkml: + +linkml:String xsd:string +linkml:Integer xsd:integer + + CLOSED { + ( $ ( schema:name @linkml:String ; + schema:age @linkml:Integer ? ; + @ * + ) ; + rdf:type [ schema:Person ] ? + ) +} + + CLOSED { + ( $ ( & ; + rdf:type [ schema:Person ] ? ; + @ + + ) ; + rdf:type [ ] ? + ) +} +""" + +TTL = """@prefix schema: . +@prefix m: . +@prefix p: . + +p:42 a schema:Person ; schema:name "Joe Smith" ; schema:age 42 . +p:43 a schema:Person ; schema:name "Jane" ; m:friend p:42 . +""" + +PERSON = "http://example.org/model/Person" +FRIENDLY = "http://example.org/model/FriendlyPerson" +JOE = "http://example.org/people/42" +JANE = "http://example.org/people/43" + + +def graph() -> Graph: + return Graph().parse(data=TTL, format="turtle") + + +def test_evaluate_with_string_iris_returns_indexable_pair(): + g = graph() + r = evaluate(g, SHEX, start=PERSON, focus=JOE) + assert r[0] is True, r[1] + assert isinstance(r[1], str) + + r = evaluate(g, SHEX, start=FRIENDLY, focus=JOE) + assert r[0] is False + assert isinstance(r[1], str) and r[1] + + +def test_evaluate_every_subject_without_start_does_not_raise(): + g = graph() + nodes = {s for s, _, _ in g} + assert nodes + for node in nodes: + assert isinstance(node, URIRef) + conforms, reason = evaluate(g, SHEX, focus=node) + assert conforms is False # this schema declares no start shape + assert reason == "No starting shape" + + +def test_positional_evaluator_with_schema_file(tmp_path): + shex_file = tmp_path / "model.shex" + shex_file.write_text(SHEX, encoding="utf-8") + results = ShExEvaluator(graph(), str(shex_file), JANE, FRIENDLY).evaluate(debug=False) + assert all(r.result for r in results), [r.reason for r in results if not r.result] + + results = ShExEvaluator(graph(), str(shex_file), JOE, FRIENDLY).evaluate(debug=False) + assert not all(r.result for r in results) + assert all(isinstance(r.reason, str) for r in results) diff --git a/tests/test_contract/test_downstream_packages.py b/tests/test_contract/test_downstream_packages.py new file mode 100644 index 0000000..67bd464 --- /dev/null +++ b/tests/test_contract/test_downstream_packages.py @@ -0,0 +1,109 @@ +"""Run the real downstream packages against this PyShEx, when they are installed. + +These tests skip in the normal dev environment. The CI ``downstream`` job installs +PyShEx from this tree together with the latest linkml and jupyter-rdfify in a single +resolution, which also proves their dependency pins remain compatible with ours. +""" +import argparse +import importlib +import importlib.util +import sys +import types + +import pytest +from rdflib import Graph + +LINKML_SCHEMA = """ +id: http://example.org/model +name: model +prefixes: + linkml: https://w3id.org/linkml/ + ex: http://example.org/model/ +default_prefix: ex +default_range: string +imports: + - linkml:types +classes: + Person: + slots: [name, age] +slots: + name: + required: true + age: + range: integer +""" + +TTL = """@prefix ex: . +@prefix p: . +p:42 a ex:Person ; ex:name "Joe" ; ex:age 42 . +p:43 a ex:Person ; ex:age "not a number" . +""" + + +def test_linkml_shexgen_output_validates_with_pyshex(): + pytest.importorskip("linkml") + from linkml.generators.shexgen import ShExGenerator + + from pyshex.evaluate import evaluate + + shex = ShExGenerator(LINKML_SCHEMA).serialize(collections=False) + g = Graph().parse(data=TTL, format="turtle") + ok, reason = evaluate(g, shex, start="http://example.org/model/Person", focus="http://example.org/people/42") + assert ok, reason + ok, _ = evaluate(g, shex, start="http://example.org/model/Person", focus="http://example.org/people/43") + assert not ok + + +def load_jupyter_rdfify_submodule(name: str): + """Import a submodule of jupyter-rdfify without executing its package __init__. + + The distribution's import name is ``jupyter-rdfify`` (with a hyphen), and its __init__ + pulls in IPython display code and the ``cgi`` module, which Python 3.13 removed. + Only the ShEx module matters to PyShEx. + """ + spec = importlib.util.find_spec("jupyter-rdfify") + if spec is None: + pytest.skip("jupyter-rdfify is not installed") + pkg_name = "_jupyter_rdfify_under_test" + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = list(spec.submodule_search_locations) + sys.modules[pkg_name] = pkg + return importlib.import_module(f"{pkg_name}.{name}") + + +class RecordingLogger: + def __init__(self): + self.lines = [] + + def out(self, msg, verbose=False, *_): + self.lines.append(msg) + + def print(self, msg): + self.lines.append(msg) + + +def test_jupyter_rdfify_shex_module(): + shex_module = load_jupyter_rdfify_submodule("shex") + subparsers = argparse.ArgumentParser().add_subparsers() + logger = RecordingLogger() + module = shex_module.ShexModule("shex", subparsers, logger, "ShEx module", "ShEx") + store = { + "rdfshapes": {}, + "rdfgraphs": {"g": Graph().parse(data=TTL, format="turtle")}, + } + + def cell(action, cell=None, **kw): + params = argparse.Namespace(action=action, cell=cell, label=None, graph=None, focus=None, start=None) + vars(params).update(kw) + module.handle(params, store) + + cell("prefix", "PREFIX ex: \nPREFIX xsd: ") + cell("parse", "ex:Person { ex:name xsd:string ; ex:age xsd:integer ? }", label="s") + assert "s" in store["rdfshapes"], logger.lines + cell("validate", label="s", graph="g", start="http://example.org/model/Person", + focus="http://example.org/people/42") + assert "PASSED!" in logger.lines, logger.lines + cell("validate", label="s", graph="g", start="http://example.org/model/Person", + focus="http://example.org/people/43") + assert any(line.startswith("FAILED!") for line in logger.lines), logger.lines diff --git a/tests/test_contract/test_public_api.py b/tests/test_contract/test_public_api.py new file mode 100644 index 0000000..5176e5d --- /dev/null +++ b/tests/test_contract/test_public_api.py @@ -0,0 +1,218 @@ +"""Freeze the public API of PyShEx so that PyPI releases stay backward compatible. + +Compatible evolution passes: appending parameters that have defaults, adding new +keyword-only parameters, adding new exports, or adding new CLI options. +Everything else fails: removing or renaming a name, reordering parameters, making +an optional parameter required, changing a default, or dropping a CLI option. +See README.md in this directory for what to do when a test here fails. +""" +import importlib +import inspect +from importlib import metadata + +import pytest + +REQUIRED = inspect.Parameter.empty +ANY_DEFAULT = object() # parameter must stay optional, but its default value may change +VAR_POS = "*" +VAR_KW = "**" + +# name -> expected leading parameters, as (name, default) pairs, or VAR_POS/VAR_KW markers. +SIGNATURES = { + "pyshex.shex_evaluator:ShExEvaluator.__init__": [ + ("self", REQUIRED), ("rdf", None), ("schema", None), ("focus", None), ("start", None), + ("rdf_format", "turtle"), ("debug", False), ("debug_slurps", False), ("over_slurp", None), + ("output_sink", None), + ], + "pyshex.shex_evaluator:ShExEvaluator.evaluate": [ + ("self", REQUIRED), ("rdf", None), ("shex", None), ("focus", None), ("start", None), + ("rdf_format", None), ("debug", None), ("debug_slurps", None), ("over_slurp", None), + ("output_sink", None), + ], + "pyshex.shex_evaluator:evaluate_cli": [("argv", None), ("prog", None)], + "pyshex.shex_evaluator:genargs": [("prog", None)], + "pyshex.evaluate:evaluate": [ + ("g", REQUIRED), ("schema", REQUIRED), ("focus", REQUIRED), ("start", None), ("debug_trace", False), + ], + "pyshex.utils.schema_loader:SchemaLoader.__init__": [ + ("self", REQUIRED), ("base_location", None), ("redirect_location", None), ("schema_type_suffix", None), + ], + "pyshex.utils.schema_loader:SchemaLoader.load": [ + ("self", REQUIRED), ("schema_file", REQUIRED), ("schema_location", None), + ], + "pyshex.utils.schema_loader:SchemaLoader.loads": [("self", REQUIRED), ("schema_txt", REQUIRED)], + "pyshex.utils.schema_loader:SchemaLoader.location_rewrite": [("self", REQUIRED), ("schema_location", REQUIRED)], + "pyshex.prefixlib:PrefixLibrary.__init__": [("self", REQUIRED), ("schema", None), VAR_KW], + "pyshex.prefixlib:PrefixLibrary.add_shex": [("self", REQUIRED), ("schema", REQUIRED)], + "pyshex.prefixlib:PrefixLibrary.add_rdf": [("self", REQUIRED), ("rdf", REQUIRED), ("format", "turtle")], + "pyshex.prefixlib:PrefixLibrary.add_bindings_to": [("self", REQUIRED), ("g", REQUIRED)], + "pyshex.prefixlib:PrefixLibrary.add_to_object": [("self", REQUIRED), ("target", REQUIRED), ("override", False)], + "pyshex.prefixlib:PrefixLibrary.nsname": [("self", REQUIRED), ("uri", REQUIRED)], + "pyshex.user_agent:SlurpyGraphWithAgent": [("endpoint", REQUIRED), VAR_POS], + "pyshex.user_agent:SPARQLWrapperWithAgent.__init__": [ + ("self", REQUIRED), ("endpoint", REQUIRED), ("updateEndpoint", None), ("returnFormat", None), + ("defaultGraph", None), ("agent", ANY_DEFAULT), + ], +} + +# Keyword-only parameters that callers may pass by name: name -> {param: default} +KEYWORD_ONLY = { + "pyshex.user_agent:SlurpyGraphWithAgent": {"persistent_bnodes": False, "agent": None, "gdb_slurper": False}, +} + +EXPORTS = { + "pyshex": ["ShExEvaluator", "PrefixLibrary", "standard_prefixes", "known_prefixes"], + "pyshex.shex_evaluator": ["ShExEvaluator", "EvaluationResult", "evaluate_cli", "genargs"], + "pyshex.evaluate": ["evaluate"], + "pyshex.utils.schema_loader": ["SchemaLoader"], + "pyshex.prefixlib": ["PrefixLibrary", "standard_prefixes", "known_prefixes"], + "pyshex.user_agent": ["UserAgent", "SlurpyGraphWithAgent", "SPARQLWrapperWithAgent"], + "pyshex.shapemap_structure_and_language.p3_shapemap_structure": [ + "START", "START_TYPE", "FixedShapeMap", "ShapeAssociation", + ], +} + +CLI_OPTIONS = [ + "--format", "--start", "--usetype", "--startpredicate", "--focus", "--allsubjects", "--debug", "--slurper", + "--gdbslurper", "--flattener", "--sparql", "--stoponerror", "--stopafter", "--printsparql", + "--printsparqlresults", "--graphname", "--persistbnodes", "--useragent", + "-f", "-s", "-ut", "-sp", "-fn", "-A", "-d", "-ss", "-ssg", "-cf", "-sq", "-se", "-ps", "-pr", "-gn", "-pb", +] + + +def resolve(target: str): + module_name, _, attr_path = target.partition(":") + obj = importlib.import_module(module_name) + for part in attr_path.split(".") if attr_path else []: + obj = getattr(obj, part) + return obj + + +def signature_problems(func, expected) -> list[str]: + actual = list(inspect.signature(func).parameters.values()) + problems = [] + for i, exp in enumerate(expected): + if i >= len(actual): + problems.append(f"parameter #{i} {exp!r} was removed") + continue + act = actual[i] + if exp in (VAR_POS, VAR_KW): + kind = inspect.Parameter.VAR_POSITIONAL if exp == VAR_POS else inspect.Parameter.VAR_KEYWORD + if act.kind != kind: + problems.append(f"parameter #{i} should be {exp}, found {act}") + continue + name, default = exp + if act.name != name: + problems.append(f"parameter #{i} was renamed or moved: expected {name!r}, found {act.name!r}") + elif act.kind not in (inspect.Parameter.POSITIONAL_OR_KEYWORD,): + problems.append(f"parameter {name!r} is no longer positional-or-keyword ({act.kind.description})") + elif default is REQUIRED: + pass # a required parameter may become optional + elif act.default is REQUIRED: + problems.append(f"parameter {name!r} became required") + elif default is not ANY_DEFAULT and act.default != default: + problems.append(f"default of {name!r} changed from {default!r} to {act.default!r}") + for act in actual[len(expected):]: + if act.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + if act.default is REQUIRED: + problems.append(f"new parameter {act.name!r} must have a default") + return problems + + +@pytest.mark.parametrize("target", sorted(SIGNATURES)) +def test_signature_is_backward_compatible(target): + problems = signature_problems(resolve(target), SIGNATURES[target]) + assert not problems, f"{target}: " + "; ".join(problems) + + +@pytest.mark.parametrize("target", sorted(KEYWORD_ONLY)) +def test_keyword_parameters_still_accepted(target): + params = inspect.signature(resolve(target)).parameters + for name, default in KEYWORD_ONLY[target].items(): + assert name in params, f"{target}: keyword parameter {name!r} was removed" + assert params[name].default == default, f"{target}: default of {name!r} changed" + + +@pytest.mark.parametrize("module_name", sorted(EXPORTS)) +def test_exports_still_present(module_name): + module = importlib.import_module(module_name) + missing = [name for name in EXPORTS[module_name] if not hasattr(module, name)] + assert not missing, f"{module_name} no longer exports {missing}" + + +def test_top_level_evaluator_is_the_real_class(): + import pyshex + from pyshex.shex_evaluator import ShExEvaluator + + assert pyshex.ShExEvaluator is ShExEvaluator + + +@pytest.mark.parametrize("prop", ["rdf", "schema", "focus", "foci", "start"]) +def test_evaluator_properties(prop): + from pyshex.shex_evaluator import ShExEvaluator + + assert isinstance(inspect.getattr_static(ShExEvaluator, prop), property) + + +def test_evaluation_result_fields(): + from pyshex.shex_evaluator import EvaluationResult + + assert issubclass(EvaluationResult, tuple) + assert EvaluationResult._fields[:4] == ("result", "focus", "start", "reason") + + +def test_prefix_library_deprecated_alias_kept(): + from pyshex.prefixlib import PrefixLibrary + + assert callable(PrefixLibrary.add_bindings) + + +def test_cli_options_still_accepted(): + from pyshex.shex_evaluator import genargs + + parser = genargs() + known = {opt for action in parser._actions for opt in action.option_strings} + missing = sorted(set(CLI_OPTIONS) - known) + assert not missing, f"shexeval no longer accepts {missing}" + positionals = [a.dest for a in parser._actions if not a.option_strings] + assert positionals == ["rdf", "shex"] + + +def test_console_script_entry_point(): + try: + dist = metadata.distribution("PyShEx") + except metadata.PackageNotFoundError: # running from a source tree that was never installed + pytest.skip("PyShEx distribution metadata not installed") + scripts = {ep.name: ep.value for ep in dist.entry_points if ep.group == "console_scripts"} + assert scripts.get("shexeval") == "pyshex.shex_evaluator:evaluate_cli" + + +SHEX = """PREFIX ex: +PREFIX xsd: +start = @ex:Person +ex:Person { ex:name xsd:string } +""" +TTL = """@prefix ex: . +ex:alice ex:name "Alice" . +ex:bob ex:name 17 . +""" + + +@pytest.mark.parametrize("focus, expected_rc", [("http://example.org/alice", 0), ("http://example.org/bob", 1)]) +def test_cli_exit_codes(tmp_path, capsys, focus, expected_rc): + from pyshex.shex_evaluator import evaluate_cli + + (tmp_path / "s.shex").write_text(SHEX, encoding="utf-8") + (tmp_path / "d.ttl").write_text(TTL, encoding="utf-8") + rc = evaluate_cli([str(tmp_path / "d.ttl"), str(tmp_path / "s.shex"), "-fn", focus]) + assert rc == expected_rc + + +def test_cli_requires_a_focus(tmp_path, capsys): + from pyshex.shex_evaluator import evaluate_cli + + (tmp_path / "s.shex").write_text(SHEX, encoding="utf-8") + (tmp_path / "d.ttl").write_text(TTL, encoding="utf-8") + assert evaluate_cli([str(tmp_path / "d.ttl"), str(tmp_path / "s.shex")]) == 4 + assert evaluate_cli([str(tmp_path / "d.ttl"), str(tmp_path / "s.shex"), "-A"]) == 1 diff --git a/tests/test_policy/README.md b/tests/test_policy/README.md new file mode 100644 index 0000000..2b6a70e --- /dev/null +++ b/tests/test_policy/README.md @@ -0,0 +1,19 @@ +# Repository policy tests + +These tests enforce the maintenance constraints LinkML asked for: + +1. PyPI releases must not break compatibility (see `tests/test_contract`). +2. PyShEx should keep up with current Python releases (`test_python_support.py`). +3. No unexpected heavyweight dependencies (`test_dependencies.py`). + +They read `pyproject.toml`, `uv.lock` and the CI workflow, so they only run from a +source checkout. They need `tomllib`, so they skip on Python 3.10; pre-commit and +the CI `policy` job run them on a current Python. + +Adding a dependency, or accepting a new transitive one, is allowed. It just has to be +a deliberate, reviewed edit to the allowlist in `test_dependencies.py`. + +A runtime dependency may temporarily come from a git or path source (`[tool.uv.sources]`) +if it is listed in `ALLOWED_NON_PYPI_SOURCES`. The release workflow runs these tests with +`PYSHEX_RELEASE_CHECK=1`, which allows none, because published wheels always resolve +dependencies from PyPI. diff --git a/tests/test_policy/__init__.py b/tests/test_policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_policy/_repo.py b/tests/test_policy/_repo.py new file mode 100644 index 0000000..159dadd --- /dev/null +++ b/tests/test_policy/_repo.py @@ -0,0 +1,22 @@ +"""Helpers to locate and parse repository files for the policy tests.""" +from pathlib import Path + +import pytest + +tomllib = pytest.importorskip("tomllib", reason="policy tests need Python >= 3.11") + +ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = ROOT / "pyproject.toml" +LOCKFILE = ROOT / "uv.lock" +TEST_WORKFLOW = ROOT / ".github" / "workflows" / "main.yaml" + +if not PYPROJECT.exists(): + pytest.skip("not running from a PyShEx source checkout", allow_module_level=True) + + +def pyproject() -> dict: + return tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + + +def lockfile() -> dict: + return tomllib.loads(LOCKFILE.read_text(encoding="utf-8")) diff --git a/tests/test_policy/test_dependencies.py b/tests/test_policy/test_dependencies.py new file mode 100644 index 0000000..c7aaff1 --- /dev/null +++ b/tests/test_policy/test_dependencies.py @@ -0,0 +1,184 @@ +"""Keep the runtime dependency footprint small and deliberate. + +LinkML installs PyShEx for every user, so anything PyShEx pulls in lands in every +LinkML environment. These tests fail when the set of runtime packages changes, on any +platform or Python version the lockfile covers, until someone reviews and edits the +allowlist below. +""" +import ast +import os +import re +from importlib import metadata + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +from tests.test_policy._repo import ROOT, lockfile, pyproject + +# Every package that can be installed at runtime, with why it is acceptable. +# Changing this list is a reviewable decision: explain the new dependency in the PR. +ALLOWED_RUNTIME_PACKAGES = { + # direct dependencies + "cfgraph": "RDF collection flattening graph (pure Python, tiny)", + "chardet": "declared direct dependency", + "pyshexc": "ShExC parser", + "rdflib-shim": "rdflib compatibility shim", + "requests": "HTTP fetching of schemas and data", + "shexjsg": "ShExJ object model", + "sparqlslurper": "SPARQL-backed graph", + "sparqlwrapper": "SPARQL endpoint client", + "urllib3": "declared direct dependency", + # transitive + "antlr4-python3-runtime": "parser runtime for pyshexc and pyjsg", + "certifi": "via requests", + "charset-normalizer": "via requests", + "idna": "via requests", + "isodate": "via rdflib on Python < 3.11", + "jsonasobj": "via pyshexc and pyjsg", + "pyjsg": "via pyshexc and shexjsg", + "pyparsing": "via rdflib", + "rdflib": "via cfgraph, rdflib-shim, sparqlslurper", + "rdflib-jsonld": "via rdflib-shim", +} + +# Distributions that pyshex imports directly but only gets transitively. +# Each is a latent risk: if the intermediate package drops it, pyshex breaks. +# Prefer declaring them in pyproject.toml; do not add to this list. +IMPORTED_BUT_UNDECLARED = {"rdflib", "pyjsg", "jsonasobj"} + +# Distributions declared in pyproject.toml that pyshex never imports. +DECLARED_BUT_NOT_IMPORTED = {"urllib3"} + +# Runtime packages temporarily locked from somewhere other than PyPI, with why. +# Tolerated on pull requests; the release workflow sets PYSHEX_RELEASE_CHECK=1, which +# refuses all of them, because a published wheel always resolves these from PyPI. +ALLOWED_NON_PYPI_SOURCES = { + "pyshexc": "EXTENDS grammar (PR #105) from a fork; release it to PyPI and drop [tool.uv.sources]", +} + +UPPER_BOUND_OPERATORS = {"<", "<=", "==", "===", "~="} + + +def direct_requirements() -> list[Requirement]: + return [Requirement(r) for r in pyproject()["project"]["dependencies"]] + + +def locked_runtime_closure() -> set[str]: + packages = {canonicalize_name(p["name"]): p for p in lockfile()["package"]} + seen: set[str] = set() + todo = [canonicalize_name(d["name"]) for d in packages["pyshex"].get("dependencies", [])] + while todo: + name = todo.pop() + if name in seen: + continue + seen.add(name) + todo += [canonicalize_name(d["name"]) for d in packages[name].get("dependencies", [])] + return seen + + +def test_runtime_closure_matches_allowlist(): + closure = locked_runtime_closure() + allowed = {canonicalize_name(n) for n in ALLOWED_RUNTIME_PACKAGES} + new = sorted(closure - allowed) + gone = sorted(allowed - closure) + assert not new, ( + f"New runtime dependencies {new} would be installed for every LinkML user. " + "If they are intended and lightweight, add them to ALLOWED_RUNTIME_PACKAGES with a reason." + ) + assert not gone, f"{gone} are no longer runtime dependencies; remove them from ALLOWED_RUNTIME_PACKAGES." + + +def test_runtime_packages_come_from_pypi(): + """Git, path or URL sources (e.g. [tool.uv.sources]) only apply to development installs. + + A published wheel always resolves its dependencies from PyPI, so CI would be testing + different code from what users get. + """ + packages = {canonicalize_name(p["name"]): p for p in lockfile()["package"]} + off_registry = { + name: packages[name]["source"] + for name in sorted(locked_runtime_closure()) + if "registry" not in packages[name].get("source", {}) + } + releasing = os.environ.get("PYSHEX_RELEASE_CHECK") == "1" + allowed = set() if releasing else {canonicalize_name(n) for n in ALLOWED_NON_PYPI_SOURCES} + unexpected = {name: src for name, src in off_registry.items() if name not in allowed} + assert not unexpected, ( + f"Runtime dependencies not locked from PyPI: {unexpected}. " + + ("A release must not ship these: " + "; ".join(ALLOWED_NON_PYPI_SOURCES.get(n, "") for n in unexpected) + if releasing else "Release the needed version to PyPI and depend on it instead.") + ) + stale = {canonicalize_name(n) for n in ALLOWED_NON_PYPI_SOURCES} - set(off_registry) + assert not stale, f"{sorted(stale)} now come from PyPI; remove them from ALLOWED_NON_PYPI_SOURCES" + + +def test_no_extras_pull_in_hidden_dependencies(): + assert not pyproject()["project"].get("optional-dependencies"), ( + "Extras are fine, but add their packages to the allowlist review first." + ) + + +def test_direct_dependencies_have_no_upper_bounds(): + """A library that caps versions forces downstream resolvers into conflicts (e.g. with linkml's rdflib>=7.6).""" + capped = [str(r) for r in direct_requirements() if any(s.operator in UPPER_BOUND_OPERATORS for s in r.specifier)] + assert not capped, f"Runtime dependencies must not be capped or pinned: {capped}" + + +def test_runtime_dependencies_are_not_dev_tools(): + dev_tools = {"pytest", "coverage", "tox", "black", "ruff", "codespell", "pre-commit", "griffe"} + runtime = {canonicalize_name(r.name) for r in direct_requirements()} + assert not runtime & dev_tools + + +def imported_top_level_modules() -> set[str]: + names = set() + for path in (ROOT / "pyshex").rglob("*.py"): + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))): + if isinstance(node, ast.Import): + names |= {alias.name.split(".")[0] for alias in node.names} + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names.add(node.module.split(".")[0]) + return names + + +def imported_distributions() -> set[str]: + import sys + + module_to_dists = metadata.packages_distributions() + dists = set() + for module in imported_top_level_modules() - set(sys.stdlib_module_names) - {"pyshex"}: + found = module_to_dists.get(module) + assert found, f"pyshex imports {module!r}, which no installed distribution provides" + dists |= {canonicalize_name(d) for d in found} + return dists + + +def test_imports_are_declared_dependencies(): + declared = {canonicalize_name(r.name) for r in direct_requirements()} + undeclared = imported_distributions() - declared + assert undeclared <= IMPORTED_BUT_UNDECLARED, ( + f"pyshex imports {sorted(undeclared - IMPORTED_BUT_UNDECLARED)} without declaring them in pyproject.toml" + ) + fixed = IMPORTED_BUT_UNDECLARED - undeclared + assert not fixed, f"{sorted(fixed)} are now declared or unused; remove them from IMPORTED_BUT_UNDECLARED" + + +def test_declared_dependencies_are_used(): + declared = {canonicalize_name(r.name) for r in direct_requirements()} + unused = declared - imported_distributions() + assert unused <= DECLARED_BUT_NOT_IMPORTED, f"Declared but never imported: {sorted(unused)}" + fixed = DECLARED_BUT_NOT_IMPORTED - unused + assert not fixed, f"{sorted(fixed)} are now imported or removed; update DECLARED_BUT_NOT_IMPORTED" + + +def test_lockfile_declares_same_requirements_as_pyproject(): + locked = {canonicalize_name(p["name"]): p for p in lockfile()["package"]}["pyshex"]["metadata"]["requires-dist"] + locked_names = {canonicalize_name(r["name"]) for r in locked} + assert locked_names == {canonicalize_name(r.name) for r in direct_requirements()}, "run `uv lock`" + + +def test_no_dependency_on_heavy_frameworks(): + """Belt and braces: names that must never appear in the runtime closure.""" + heavy = re.compile(r"^(numpy|pandas|scipy|torch|tensorflow|jax|pyarrow|polars|matplotlib|ipython|jupyter.*|" + r"notebook|pydantic|sqlalchemy|django|flask|fastapi|lxml|networkx|openai|anthropic)$") + assert not [n for n in locked_runtime_closure() if heavy.match(n)] diff --git a/tests/test_policy/test_python_support.py b/tests/test_policy/test_python_support.py new file mode 100644 index 0000000..8d987f7 --- /dev/null +++ b/tests/test_policy/test_python_support.py @@ -0,0 +1,90 @@ +"""Keep supported Python versions consistent everywhere and current with CPython releases.""" +import datetime +import json +import os +import re +import urllib.request + +import pytest +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +from tests.test_policy._repo import TEST_WORKFLOW, pyproject + +CLASSIFIER = re.compile(r"^Programming Language :: Python :: (3\.\d+)$") + + +def classifier_versions() -> list[str]: + found = [m.group(1) for c in pyproject()["project"]["classifiers"] if (m := CLASSIFIER.match(c))] + return sorted(found, key=Version) + + +def ci_matrix_versions() -> list[str]: + text = TEST_WORKFLOW.read_text(encoding="utf-8") + m = re.search(r"^\s*python-version:\s*\[([^\]]*)\]", text, re.MULTILINE) + assert m, f"no python-version matrix in {TEST_WORKFLOW}" + return sorted((v.strip().strip("\"'") for v in m.group(1).split(",")), key=Version) + + +def test_classifiers_match_ci_matrix(): + assert classifier_versions() == ci_matrix_versions(), ( + "Every advertised Python version must be tested in CI, and vice versa" + ) + + +def test_requires_python_floor_matches_oldest_classifier(): + spec = SpecifierSet(pyproject()["project"]["requires-python"]) + oldest = classifier_versions()[0] + assert Version(oldest) in spec + below = f"3.{Version(oldest).minor - 1}" + assert Version(below) not in spec, f"requires-python admits {below}, which is neither advertised nor tested" + + +def test_requires_python_has_no_upper_cap(): + """Caps like <3.14 stop LinkML users from installing on a new Python even when it works.""" + spec = SpecifierSet(pyproject()["project"]["requires-python"]) + assert not [s for s in spec if s.operator in {"<", "<=", "==", "~="}] + + +def test_versions_are_contiguous(): + minors = [Version(v).minor for v in classifier_versions()] + assert minors == list(range(minors[0], minors[-1] + 1)), "gaps in supported Python versions" + + +def test_tooling_targets_match(): + project = pyproject() + wanted = {f"py3{Version(v).minor}" for v in classifier_versions()} + black = set(project.get("tool", {}).get("black", {}).get("target-version", [])) + assert not black or black == wanted, "tool.black.target-version out of sync with classifiers" + envs = project.get("tool", {}).get("tox", {}).get("env_list", []) + tox = {f"py3{v}" for e in envs for v in re.findall(r"3(1\d)", e)} + assert not tox or tox == {f"py3{Version(v).minor}" for v in classifier_versions()}, ( + "tool.tox.env_list out of sync with classifiers" + ) + + +# How long after a CPython release PyShEx may go without supporting it. +# Dependencies with compiled extensions often need a few weeks to publish wheels. +NEW_PYTHON_GRACE = datetime.timedelta(days=90) + + +def network_disabled() -> bool: + """Same convention as tests/__init__.py: SKIP_EXTERNAL_URLS=false/0/no/empty means enabled.""" + return os.environ.get("SKIP_EXTERNAL_URLS", "").lower() not in ("", "0", "false", "no") + + +@pytest.mark.skipif(network_disabled(), reason="network disabled") +def test_newest_cpython_release_is_supported(): + """Every stable CPython (per endoflife.date) released more than NEW_PYTHON_GRACE ago must be supported.""" + try: + with urllib.request.urlopen("https://endoflife.date/api/python.json", timeout=10) as resp: + cycles = json.load(resp) + except Exception as e: # offline, rate limited, ... + pytest.skip(f"could not reach endoflife.date: {e}") + cutoff = (datetime.date.today() - NEW_PYTHON_GRACE).isoformat() + released = [c["cycle"] for c in cycles if c.get("releaseDate", "9999") <= cutoff and c["cycle"].startswith("3.")] + newest = max(released, key=Version) + assert Version(classifier_versions()[-1]) >= Version(newest), ( + f"Python {newest} has been out for more than {NEW_PYTHON_GRACE.days} days; add it to the classifiers " + "and the CI matrix" + ) diff --git a/uv.lock b/uv.lock index a58da0e..3551369 100644 --- a/uv.lock +++ b/uv.lock @@ -412,6 +412,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "packaging" }, { name = "pytest" }, ] @@ -431,6 +432,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "coverage" }, + { name = "packaging" }, { name = "pytest" }, ]