git clone https://github.com/sebdraven/pyonyphe
uv sync --dev
uv run pre-commit installuv run ruff check . # lint
uv run ruff check --fix . # lint, autofixing what is safe
uv run ruff format . # format
uv run ty check # types
uv run zizmor .github/workflows # audit the CI workflows
uv run pytest # tests
uv run pytest --cov # tests with coverageCI runs exactly these, on Linux, macOS and Windows, for Python 3.10 to 3.13.
Ruff is both the linter and the formatter; there is no black, no isort, no
flake8. The enabled rule families live in [tool.ruff.lint] of
pyproject.toml: pycodestyle, pyflakes, isort, pep8-naming, pyupgrade,
bugbear, comprehensions, simplify, tidy-imports, use-pathlib, bandit,
annotations and the ruff-specific set. Line length is 100.
It runs in three places, and all three must agree:
- locally, via the commands above
- on commit, via the
ruffandruff-formatpre-commit hooks - in CI, as
ruff checkplusruff format --check, which fails the build
If a rule genuinely does not fit, silence it narrowly with a # noqa: RULE
and a reason, or add it to ignore / per-file-ignores with a comment --
never disable a whole family to make one line pass.
zizmor statically analyses .github/workflows for the failure modes specific
to GitHub Actions: template injection, credentials left behind by checkout,
over-broad permissions, mutable action tags. It runs locally, as a
pre-commit hook, and in the lint job.
Two conventions the workflows follow, both enforced by it:
permissions: contents: readat the top of each file, raised per job only where genuinely needed (id-token: writefor trusted publishing,contents: writeto create the release).persist-credentials: falseon everycheckout, so the job token is not written into.git/configwhere any later step could read it.
_specs.pydescribes every endpoint as a pure function returning aSpec. It performs no I/O, which is why the URL-shape tests need no network mock._base.pyholds everything the two transports share: settings, auth headers, request building, status-to-exception mapping, NDJSON parsing.client.pyandasync_client.pyonly know how to send aSpec. Adding an endpoint means one function in_specs.pyand one thin method in each client — never two divergent implementations.
- Add a spec function, with the exact path from https://search.onyphe.io/docs.
- Add a test in
tests/test_specs.pyasserting the path and parameters. - Add the thin method to both clients.
- Add a row to
docs/api.mdand, if it deserves one, a CLI command.
respx mocks httpx at the transport level, so no network access is needed.
tests/conftest.py sandboxes HOME and the working directory, which keeps a
real ~/.onyphe.ini or a local .env from leaking into assertions.
tests/test_integration.py is the exception: it calls the real API, is marked
integration, and is skipped unless ONYPHE_API_KEY is set.
uv run pytest -m 'not integration' # what CI runs
uv run pytest -m integration # real API, consumes creditsNever commit a real API key, not even in a fixture.
- Update
CHANGELOG.md. - Tag:
git tag vX.Y.Z && git push upstream vX.Y.Z. release.ymlruns the tests, builds, and publishes to PyPI through trusted publishing (OIDC — no token stored in the repository).
The version is not written in pyproject.toml: hatch-vcs derives it from
the tag. v3.0.1 builds 3.0.1, v3.1.0rc1 builds a real pre-release. Two
consequences worth remembering:
- Every
checkoutin the workflows usesfetch-depth: 0. A shallow clone has no tags, and the build would silently produce a bogus version. - The Docker build context excludes
.git, so the version is injected through theHATCH_VCS_PRETEND_VERSIONbuild argument. A localdocker buildwithout that argument produces0.0.0.dev0, which is expected.