diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ecaacb5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: test + +on: + push: + branches: [master] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + # Builds the wheel, installs it, byte-compiles the tree and imports every + # module. See scripts/verify_import.py. + - run: make verify + + # Fail if the committed client is out of date with the committed schema — i.e. + # someone changed openapi.json without re-running the generator. Regenerates + # from the committed schema, not the live one, so this stays a check on the + # commit rather than a second copy of the sync job. + generate-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + # The generator's post_hooks shell out to ruff. Without it on PATH they + # are skipped with a warning, and --fail-on-warning then fails the build. + - run: uv tool install ruff + - run: make generate + - name: Check for drift + run: | + if ! git diff --quiet; then + echo "::error::Generated client is out of date. Run 'make generate' and commit the result." + git diff --stat + exit 1 + fi diff --git a/Makefile b/Makefile index 4826e63..c156795 100644 --- a/Makefile +++ b/Makefile @@ -22,14 +22,17 @@ generate: openapi.json --overwrite \ --fail-on-warning python3 scripts/mark_deprecated.py openapi.json $(PACKAGE) + # Committed too, but recreated here because generate removes the package + # first. Without it type checkers ignore every annotation in the SDK. + touch $(PACKAGE)/py.typed -# Python has no compile step, so we install the wheel and import it. That at -# least runs every generated module once. +# Python has no compile step, so we install the wheel and exercise it. See +# scripts/verify_import.py for why importing the package alone isn't enough. verify: uv build uv venv --clear .venv-verify uv pip install --quiet --python .venv-verify dist/*.whl - .venv-verify/bin/python -c "import incident_io; from incident_io.api.incidents_v2 import incidents_v2_list; print('import ok')" + .venv-verify/bin/python scripts/verify_import.py clean: rm -rf $(PACKAGE) dist build .venv-verify .ruff_cache diff --git a/incident_io/py.typed b/incident_io/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/scripts/verify_import.py b/scripts/verify_import.py new file mode 100644 index 0000000..2a4d347 --- /dev/null +++ b/scripts/verify_import.py @@ -0,0 +1,39 @@ +"""Import every generated module, and byte-compile the ones that can't be imported. + +Run against the installed wheel, not the working tree, so this checks what we would +actually publish. + +`import incident_io` only pulls in the client and, transitively, the model graph — about +four fifths of the package. Every module under `incident_io.api` is left out, because +`api/__init__.py` imports nothing and each operation lives in its own module. Those are +the modules callers actually reach for, and a wheel ships source rather than bytecode, so +a name error in one of them would go out undetected. +""" + +import compileall +import importlib +import pkgutil +import sys + +import incident_io + +package_root = incident_io.__path__[0] + +# Catches syntax errors anywhere in the tree, including any module that import alone would +# never reach. quiet=1 prints errors but not filenames. +if not compileall.compile_dir(package_root, quiet=1, force=True): + sys.exit("verify: byte-compilation failed") + + +def _raise(name: str) -> None: + """walk_packages swallows import errors while discovering subpackages unless it is + given an onerror. Swallowing them is exactly the failure this script exists to catch.""" + raise ImportError(f"failed to import {name}") + + +count = 0 +for module in pkgutil.walk_packages(incident_io.__path__, "incident_io.", onerror=_raise): + importlib.import_module(module.name) + count += 1 + +print(f"verify: imported {count} modules")