Development
Contributions are welcome. The short version: fork, branch off develop,
make sure uv run poe all passes (including 100% coverage), open a PR.
Quick start
git clone https://github.com/thorsten-klein/denver.git
cd denver
uv sync --group dev
uv run poe all # lint + format + mypy + test, in one go
Or run each stage on its own:
uv run poe pre-commit # pre-commit run --all-files
uv run poe lint # ruff check .
uv run poe format # ruff format .
uv run poe mypy # mypy
uv run poe test # pytest, with coverage
poe pre-commit runs the hooks in .pre-commit-config.yaml with
--all-files rather than over a staged set: they’re cheap enough that
there’s no reason to check less than the whole tree, and it means the gate
says the same thing no matter what happens to be staged when you run it.
The fixing hooks (trailing whitespace, end-of-file, line endings) repair the
file in place and then exit non-zero, so the first run after a violation
aborts poe all with the fix already applied — on next re-run it will pass.
uv run poe clean removes build artifacts (dist/, build/, *.egg-info,
htmlcov/, .coverage, coverage.xml); uv run poe build cleans then
builds the wheel/sdist (uv build). uv run poe pyinstaller is separate:
it freezes the standalone executable (scripts/create-python-exe.sh --output dist) – see “Prebuilt Binary” in Install
for what that executable is. Kept out of build on purpose: publish.yml
runs poe build and uploads everything under dist/ to PyPI as-is, and
the executable is a GitHub release asset (built by release-binary.yml),
not a PyPI artifact.
Test suite
Everything lives under tests/, run with pytest (pyproject.toml’s
[tool.pytest.ini_options] points it at src/ via pythonpath, so import denver / import providers work without installing the package first).
Nothing here touches a real tool. tests/conftest.py provides three
fakes, requested as fixtures:
run_recorder— replacessubprocess.run. Records every call (.commands()for joined-string matching,.argvs()for real argv-list matching — prefer the latter for flag/value-adjacency checks) and returns a configurable canned response (.responses["substring"] = FakeProc(...)or a callable). Abash -c ...call passes through to the realsubprocess.rununless explicitly overridden, soContext.source()behavior stays real even while every other tool is mocked.which— replacesshutil.which. A dict{name: path-or-None}; anything not in the dict resolves to/usr/bin/<name>by default.exec_recorder— replacesos.execvpe(whatContext.exec()uses to hand off to the final command). Capturesfile/args/envinstead of actually replacing the process.
Golden-file tests (tests/test_golden_show_config.py) are the one place
that is end-to-end: for every env under examples/ tracked in git, it runs
the real --show-config-full resolution against the real denver.yml and
compares the output to a checked-in snapshot in tests/golden/, with this
checkout’s own absolute path normalized to <REPO> and the zephyr
provider’s workspace-root lookups faked (both documented in that test
module). --show-config-full, not the (now minimal-by-default) plain
--show-config: the golden files exist to catch a resolver dropping or
changing a default, which a minimal render would just hide by omitting the
key entirely. If you change something that legitimately changes a real
env’s resolved config (a provider default, a merge rule, …), regenerate
the affected golden file(s) rather than hand-editing them — run
--show-config-full for the env, apply the same <REPO> substitution, and
diff the result against what changed.
Coverage
pyproject.toml’s [tool.coverage.report] sets fail_under = 100.
Python has no compiler to catch a branch nobody ever exercises — 100%
coverage is the substitute for that safety net, not a vanity metric.
A branch that’s real but that coverage.py can’t reliably trace (rare — one
known case: continue as a for loop’s last statement, under Python 3.9)
is marked # pragma: no cover with a comment explaining why it’s excluded
rather than restructured to dodge the tool. Don’t reach for # pragma: no cover to paper over a genuinely untested branch — it’s an escape hatch for
a tooling limitation, not a way to hit 100% faster.
Linting / formatting / types
ruffdoes both linting and formatting (pyproject.toml’s[tool.ruff]/[tool.ruff.lint]/[tool.ruff.format]).examples/andbak/are excluded — they’re project-specific conan recipes and scratch, not denver’s own package. Tests are exempt from a few rules ([tool.ruff.lint.per-file-ignores]): unused fixture/mock-callback arguments are idiomatic pytest, and a test’s own name is its documentation, so docstrings aren’t required there.mypytype-checkssrc/only ([tool.mypy]).conan_scripts/’s own sibling-module imports (get_rrev,DenverConanFile) and the optionalconandependency (test-only, see theconan-toolsdependency group) both needignore_missing_importsoverrides — see the comments next to each inpyproject.tomlfor why.
Adding a new provider
This is for contributing a provider into denver itself — one generic enough that other projects would want it too. A provider specific to your own project (an internal build tool, a deploy step) doesn’t need any of this or a fork: see “Extension providers” in Configuration instead.
Subclass
Provider(src/denver_providers/base.py): setname,KEYS(everydenver.ymlkey your section understands), andkindif it’s a wrapper (default:setup).Implement
resolve_defaults(cls, ctx, cfg, config)— a classmethod, and the only place your provider computes a default (a PATH lookup, a conventional value, …). No I/O side effects beyond path resolution/existence checks anddie()ing on a bad config — this runs for--show-configtoo, not just a real run, so it must never guess differently depending on whether setup() will actually run afterward.Implement
setup(self, ctx)(a setup provider) and/orwrap(self, ctx, cmd)(a wrapper provider) — this is where the real work (and any real I/O) happens, readingself.config_section(ctx)for the already-resolved config.Register it: add
"<name>": YourProvidertoPROVIDERSinsrc/denver_providers/__init__.py.Write tests: unit tests driving your provider directly against a fake
Context(make_contextfixture) withrun_recorder/whichmocking whatever it shells out to — see any existingtests/test_providers_*.pyfor the pattern. If a bundled example env exercises the new provider, the golden-file test picks it up automatically once you regenerate its snapshot.Document it: add
doc/providers/<name>.md(key reference + design notes), link it fromdoc/README.md’s provider table and the top-levelREADME.md, and point the module docstring at the new page.
CI
.github/workflows/ci.yml runs on every push to develop and every pull
request, in three jobs:
lint —
pre-commit,ruff format --check,ruff check,mypy.test —
uv run poe teston Python 3.9, 3.10, 3.11 and 3.13: the floor denver declares support for (pyproject.toml’srequires-python) through the newest available, so a change that only works at one end doesn’t slip through. 3.9/3.10 also exercise the one behavioural split in denver’s own code –tomllib(denver.tomlsupport) is stdlib only from 3.11, so those two legs are what actually runs with it absent, not just a monkeypatched test. Coverage and test results are uploaded to Codecov from the 3.13 run.build — builds the wheel/sdist, then installs it into a clean venv and runs
denver --helpplus a real--show-configagainst a bundled example, with-c denver-version=nullto drop that example’s pin (a dev-versioned wheel can never satisfy one naming an untagged release — see “Releasing”). That installed-mode smoke test exists because a checkout has a siblingsrc/layout that an installed wheel doesn’t — exactly the gap that once let aDENVER_DIRbug through (seedenver.py’s_default_denver_dir()docstring).
Releasing
Versions come from git tags via setuptools-scm — a released artifact has
no version string in a file. Cutting a release is therefore just tagging:
git tag 1.1.0
git push origin 1.1.0
.github/workflows/publish.yml triggers on any *.*.* tag: it builds the
distribution with uv run poe build and publishes it to
PyPI via trusted publishing (OIDC,
no API token stored in the repo), through the pypi GitHub environment.
Before tagging: make sure CI is green on the commit being tagged, that
README.md/doc/ describe what actually ships, and that
SUPPORTED_CONFIG_VERSION in src/denver.py still matches the denver.yml
schema — it must be bumped together with any breaking schema change, so an
older denver rejects a newer file instead of misreading it.
A denver.yml states which denver tool version it needs with
denver-version: (see Configuration), so a file
using a brand-new feature names the release that first shipped it. When an
example under examples/ is changed to rely on something unreleased, its
denver-version: names the version about to be tagged — a pin for a release
that does not exist yet.
DEV_VERSION in src/denver.py is what keeps that working. A checkout’s
tags necessarily lag behind its content: right after the feature lands,
git describe still reports the previous release, so the example would
refuse to run from source until the tag existed. scm_version() therefore
reports an untagged tree against DEV_VERSION instead, carrying the commit
suffix over (1.1.0-17-gabc1234 — seventeen commits into developing 1.1.0,
not a claim to be the release). So:
Bump
DEV_VERSIONas soon as a cycle starts, i.e. at the first commit past the release tag. Two tests intests/test_dev_version.pyenforce it:test_examples_run_from_a_checkoutfails if any example’s merged pin isn’t satisfied by what this checkout reports, andtest_dev_version_keeps_up_with_the_release_tagsfails as soon as there are commits past the newest tag andDEV_VERSIONstill names that tag — which catches a forgotten bump before any pin has moved to expose it.Tag exactly that number. Once the tag is pushed,
git describeovertakesDEV_VERSIONand it stops having any effect until the next bump, so a value left stale can only understate an untagged tree — never overstate a released one.Sitting exactly on a tag is never re-based: that tree really is that release, whatever
DEV_VERSIONsays.
Known limitations
denver has exactly one runtime dependency: PyYAML
denver.yml/denver.yaml is denver’s default config format, parsed with
PyYAML — a required dependency (pyproject.toml’s [project] dependencies),
and the reason the floor can be as low as requires-python = ">=3.9".
denver.toml is supported too, but only where tomllib is importable
(stdlib only from Python 3.11): on an older interpreter it just isn’t there,
and load_config_file() says so with a clear error instead of guessing (see
Configuration).
Both formats need to work inside a wrapper’s re-invoked process too
(reinvoke_command() in src/denver.py builds ["python3", <this file>, ...], a bare command resolved against the container’s PATH for a
docker-wrapped env — an interpreter denver cannot pip-install anything into
ahead of time). PyYAML is always there because pip installed it as a real
dependency of the denver-tool distribution the image’s own python3
resolves against; tomllib is there or not purely based on that
interpreter’s own version, same as on the host. Keep it that way when
contributing: reaching for a second runtime dependency for denver’s own
config handling is what would invalidate this, and turns a documented
feature into a real compatibility matrix.