Usage Guide¶
Preen has six commands:
preen new NAME— scaffold a new package from the py-canon copier templatepreen adopt [PATH]— retrofit an existing repo onto the templatepreen update [PATH]— pull the latest template changes (copier update)preen check [PATH]— run conformance checks (detection only)preen fix [CHECK]— apply fixes for detected issuespreen release [X.Y.Z]— guided tag-driven release
Scaffolding: preen new¶
preen new my-package --description "Does a thing" --cli
Runs copier against gh:gojiplus/py-canon and creates my-package/.
Anything you don’t pass as a flag (--org, --description, --cli),
copier prompts for.
Adoption: preen adopt¶
cd my-existing-package
preen adopt
Mines the copier answers from the repo itself, renders the template into a
temp directory, and copies in only the managed files (workflow shims,
conf.py, .copier-answers.yml, py.typed, and — only if absent —
pre-commit config, dependabot config, LICENSE, CITATION.cff). Every canon
workflow shim keeps the with: inputs the repo set and the template knows
nothing about — python-versions, a raised coverage-floor, docs-dir,
run-doctests — and they are listed under Preserved. A managed workflow that differs from the template is backed up as
<name>.bak and raised as a Manual TODO — input preservation covers the
with: block, and everything else in the file gets overwritten. The TODO does
not claim the differing lines are yours: telling a repo’s own customization
from the template moving needs the version the repo last adopted from, so it
reports that the file differed and leaves the .bak for you to diff.
conf.py goes wherever
the repo’s docs actually live: the docs-dir its docs.yml shim declares, or
whatever directory under docs/ already holds a conf.py. If the file it
replaces carried logic the template does not have, the old copy is kept as
conf.py.bak and the overwrite is raised as a Manual TODO with the line count,
rather than reported as a routine write. Rewrites
[tool.ruff], [tool.pyright], [tool.pydoclint] in pyproject.toml
to the standard — preserving repo-specific ruff ignore codes by merging
them into the canon list, and deriving target-version from the repo’s
requires-python floor (falling back to py311) — and deletes legacy
[tool.black], [tool.isort], [tool.flake8], [tool.mypy] sections.
Dependency groups are brought to the template’s shape: test holds pytest and
pytest-cov, dev holds the lint toolchain plus
{ include-group = "test" }, and docs holds the Sphinx stack. That split is
not cosmetic — the reusable CI installs test by name in a clean environment
(uv pip install dist/*.whl --group test), so a repo without one fails its
wheel job. A pytest pin already sitting directly in dev is dropped, since the
include now provides it.
Ends with an adoption report of what was written, skipped, and left for you.
Add --release-migration to convert the build backend to the fleet’s current
uv_build series with an explicit project version. The minimum is the latest
tested release and the upper bound prevents an unreviewed backend-series upgrade.
A legacy dynamic version is recovered from the latest v* tag.
Staying current: preen update¶
preen update
Runs copier update for a repo with a .copier-answers.yml, merging
template changes with conflict markers inline, and prints the changed files.
If the merge leaves conflicts, preen update exits 1 rather than 0, so a
script cannot walk past an unresolved tree. When the conflict is in
pyproject.toml, it also names every [project] key the merge would change,
current value first:
pyproject.toml: the template is offering to replace [project] metadata
version
keep: '0.8.0'
offered: '0.1.0'
dependencies
keep: ['pandas', 'pyarrow>=15']
offered: []
The template renders [project] from the scaffold answers, so its side of the
merge is always the state the repo had on day one. Taking it would publish an
empty wheel under a version the project released long ago. Keep the current
values; the template’s business is the [tool.*] configuration.
Checking: preen check¶
preen check # human-readable report
preen check --strict # exit 1 on critical/important issues (CI)
preen check --only ruff --only template
preen check --explain # why each issue matters
--strict gates on critical and important issues (and check errors);
info-level issues never fail CI. Checks listed in [tool.preen]
skip_checks are skipped unless named in an explicit --only.
Fixing: preen fix¶
preen fix # fix everything, interactively
preen fix ruff --auto # auto-apply ruff fixes
Releasing: preen release¶
preen release # uses project.version
preen release 1.2.0 # tag v1.2.0
preen release --dry-run # show the plan (fully non-interactive)
Runs the checks, walks through any issues (critical issues block; important
ones can be overridden with informed consent), then gates the tag on five
things: the version must be PEP 440-valid, it must match the committed
project.version, the vX.Y.Z tag must not already exist locally or on the
remote, CHANGELOG.md must contain an entry for the version, and the
distributions must build and pass twine check and check-wheel-contents.
That last gate builds into a temporary directory — a few seconds — and runs
before anything irreversible or interactive. Publishing happens on the tag
push, so without it a bad artifact is discovered only once the tag exists,
which is the expensive place to find it. --dry-run runs it too, which makes
the dry run a free build rehearsal. A tool that cannot be fetched (no network)
is skipped rather than treated as a failure; a tool that runs and rejects the
artifact blocks the release.
If there’s no changelog entry but a non-empty [Unreleased] section, preen
offers to rename it to [X.Y.Z] - <date> and commits that rename (only
CHANGELOG.md) before tagging, so the tagged commit contains the entry. It
also offers to bump any file carrying a copy of the version that the tag does
not set — .claude-plugin/plugin.json and CITATION.cff — and includes them
in the same pathspec-limited commit.
Then it creates and pushes the tag; the push triggers the repo’s release
workflow: build, attestations, PyPI trusted publishing, GitHub Release.
The rename commit itself stays local — run git push afterwards so it is
reachable from your branch.
Configuration¶
Optional [tool.preen] section in pyproject.toml:
[tool.preen]
src_layout = true
tests_at_root = true
examples_at_root = true
skip_checks = ["links"]