wads.ci_local

Do locally what the wads CI would have done: the same [tool.wads.ci], no Actions minutes.

wads ci-local reads the repo’s pyproject.toml through wads.ci_config.CIConfig, the object the reusable workflow’s setup job reads, and runs in order:

  1. lint: ruff format --check (reported, never fatal: CI reformats in place and moves on), then ruff check <project>, both through uvx so the repo’s own [tool.ruff] applies exactly as in CI. Black and mypy too, when enabled.

  2. tests: for each python_versions entry, a fresh uv venv in a temporary directory, uv pip install -e .[extras], then pytest with the arguments actions/run-tests-uv builds (coverage, --doctest-modules and its option flags, one --ignore per excluded path, then pytest_args).

  3. build: uv build into the temporary directory, never the repo’s dist/, so a stale artifact can never be uploaded.

With publish=True it does what the publish job does in place of the plain build: format in place, bump the version with isee (as CI does), build, uv publish, commit, tag and push. Before any of that it refuses on a dirty tree, a branch other than the default, a branch behind its remote, or missing PyPI credentials.

Known differences from CI: the developer’s environment passes through to the commands (minus PyPI credentials, which only the upload step sees), where CI exports only the variables [tool.wads.ci.env] declares; [tool.wads.ops.*] system packages are not installed; and lint runs on the tree as it is, where CI formats first.

Every command goes through one runner seam. The default runs subprocesses; tests pass a fake. The default also refuses upload and push steps while pytest is running, so no test can ever spend or publish.

>>> steps = plan_ci_local(".", config=CIConfig({"project": {"name": "pkg"}}))
>>> [s.name for s in steps][:3]
['format check (ruff)', 'lint (ruff)', 'python 3.10: venv']

Functions

ci_local([repo, publish, dry_run])

Do locally what wads CI would have done, from the repo's [tool.wads.ci] config.

plan_ci_local([repo, publish, config, platform])

Everything wads ci-local would run for repo, in order, without running it.

pytest_argv(config)

The pytest arguments actions/run-tests-uv builds, in its order.

render_plan(steps)

The plan as numbered lines, flagging non-blocking and side-effecting steps.

resolve_pypi_token(*[, environ, pypirc])

Find a PyPI API token, most specific source first.

run_ci_local([repo, publish, config, ...])

Run plan_ci_local()'s steps, stop at the first blocking failure, and report.

subprocess_runner(argv, *, cwd, env[, ...])

The default runner: really run argv, streaming its output.

uv_build_args(*[, sdist, wheel])

uv build selection flags for [tool.wads.ci.build].

venv_python(venv, *[, platform])

Path of the interpreter inside a venv directory.

Classes

CILocalReport(project, publish, ...[, ...])

The outcome of a wads ci-local run, rendered the way CI would summarise it.

RunResult(returncode[, stdout])

What a runner reports back: the exit code, and stdout when it was captured.

Step(name[, argv, check, description, ...])

One thing CI would do: a command (argv) or an in-process check.

class wads.ci_local.CILocalReport(project, publish, trigger_mode, python_versions, outcomes=<factory>, notes=<factory>, values=<factory>)[source]

Bases: object

The outcome of a wads ci-local run, rendered the way CI would summarise it.

property failure: Step | None

The blocking step that failed, if any.

class wads.ci_local.RunResult(returncode, stdout='')[source]

Bases: object

What a runner reports back: the exit code, and stdout when it was captured.

class wads.ci_local.Step(name, argv=(), check=None, description='', blocking=True, side_effect='', capture='', env=())[source]

Bases: object

One thing CI would do: a command (argv) or an in-process check.

argv and env values may hold <<workdir>>, <<version>>, <<default_branch>> or <<pypi_token>>, filled in at run time. A non-blocking failure is reported without stopping the run. capture names the context value that receives the command’s last stdout line. A check takes the run context and returns an error message, or None when it passes.

wads.ci_local.ci_local(repo='.', *, publish=False, dry_run=False)[source]

Do locally what wads CI would have done, from the repo’s [tool.wads.ci] config.

Lint, tests (a fresh uv venv per configured Python) and build. With –publish, the publish job instead: format, bump version, build, upload to PyPI, commit, tag and push, refusing on a dirty tree, off the default branch, or without PyPI credentials. With –dry-run, print the plan and run nothing.

wads.ci_local.plan_ci_local(repo='.', *, publish=False, config=None, platform='posix')[source]

Everything wads ci-local would run for repo, in order, without running it.

Return type:

list[Step]

wads.ci_local.pytest_argv(config)[source]

The pytest arguments actions/run-tests-uv builds, in its order.

Return type:

list[str]

>>> pytest_argv(CIConfig({"project": {"name": "pkg"}}))
['--cov=pkg', '--cov-report=term-missing', '--doctest-modules',
 '-o', 'doctest_optionflags=ELLIPSIS IGNORE_EXCEPTION_DETAIL',
 '--ignore=examples', '--ignore=scrap', '-v', '--tb=short']
wads.ci_local.render_plan(steps)[source]

The plan as numbered lines, flagging non-blocking and side-effecting steps.

Return type:

str

wads.ci_local.resolve_pypi_token(*, environ=None, pypirc=None)[source]

Find a PyPI API token, most specific source first. Returns (token, source).

  1. $PYPI_PASSWORD, the secret name CI reads;

  2. $UV_PUBLISH_TOKEN, what uv publish itself reads;

  3. ~/.pypirc, section [pypi]: password, when username is __token__ (or absent) or the password is a pypi- token.

Token-only, like CI. The token itself is never printed.

>>> resolve_pypi_token(environ={"PYPI_PASSWORD": "pypi-x", "UV_PUBLISH_TOKEN": "pypi-y"})
('pypi-x', '$PYPI_PASSWORD')
>>> resolve_pypi_token(environ={}, pypirc="/nonexistent/.pypirc")
(None, '')
Return type:

tuple[Optional[str], str]

wads.ci_local.run_ci_local(repo='.', *, publish=False, config=None, runner=<function subprocess_runner>, environ=None, out=None, keep_workdir=False)[source]

Run plan_ci_local()’s steps, stop at the first blocking failure, and report.

Return type:

CILocalReport

wads.ci_local.subprocess_runner(argv, *, cwd, env, capture=False, side_effect='')[source]

The default runner: really run argv, streaming its output.

Refuses outward side effects (upload, push) while pytest is running.

Return type:

RunResult

wads.ci_local.uv_build_args(*, sdist=True, wheel=True)[source]

uv build selection flags for [tool.wads.ci.build].

Return type:

list[str]

>>> uv_build_args()
[]
>>> uv_build_args(sdist=False)
['--wheel']
>>> uv_build_args(wheel=False)
['--sdist']
wads.ci_local.venv_python(venv, *, platform='posix')[source]

Path of the interpreter inside a venv directory.

Return type:

str

>>> venv_python("v", platform="posix")
'v/bin/python'
>>> venv_python("v", platform="nt")
'v/Scripts/python.exe'