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
99 changes: 99 additions & 0 deletions .github/scripts/api_compat.py
Original file line number Diff line number Diff line change
@@ -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())
87 changes: 86 additions & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
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"
53 changes: 51 additions & 2 deletions .github/workflows/pypi-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
42 changes: 42 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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]
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://w3id.org/biolink/vocab/Gene>} 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
```
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ packages = ["pyshex"]
dev = [
"pytest",
"coverage",
"packaging",
]

[tool.pytest.ini_options]
Expand All @@ -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"]
Expand Down
29 changes: 29 additions & 0 deletions tests/test_contract/README.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file added tests/test_contract/__init__.py
Empty file.
Loading
Loading