An opinionated baseline for Python projects that both people and agents work in. Two entry points: scaffold a new project (Workflow A), or score and modernize an existing one (Workflow B). The same conventions drive both.
The bias throughout: every operation has one canonical, discoverable, non-interactive command. That is what makes a project workable by an agent, and it happens to be what makes it pleasant for humans.
When to use
- "Start a new Python project / package / CLI tool"
- "Set up pyproject.toml", "which should I use, uv or poetry / pip-tools?"
- "How do I make this installable so someone can just run it?"
- "Modernize this repo" — setup.py, requirements.txt, poetry, conda, flat layout
- Choosing between ruff/black, mypy/ty, extras/dependency-groups, just/taskipy
- Adding a CLI, notebooks, an HTTP surface, or a Rust extension to a Python repo
When NOT to use
These skills own their territory. Delegate rather than restate:
| Topic | Skill |
|---|---|
--help / --dry-run / --print-config / exit-code design and verification |
verifiable-surfaces |
| marimo dual-mode notebooks, sweeps, tracking | marimo-batch-mlflow; format rules in marimo-notebook; widgets in anywidget |
| Production FastAPI internals | fastapi-ai-patterns, fastapi-ai-scaffold |
| Docs site, GitHub Pages, llms.txt | mkdocs-site-bootstrap |
| Releasing a compiled Go/Rust binary | cli-release-distribution (Python distribution stays here) |
| pre-commit setup, secret remediation | agent-history-hygiene |
| TDD discipline itself | engineering-fundamentals/tdd |
| Building an MCP server | mcp-builder (read references/api-and-services.md first — usually you want a subcommand) |
Also not for: a one-file script (use a PEP 723 header and stop), or a maintenance-only repo where a migration buys nothing.
Authoritative sources
Fetch the page rather than guessing — this toolchain moves fast, and several of these projects change CLI surface between minor versions.
| Thing | Where |
|---|---|
| uv | https://docs.astral.sh/uv/ · CLI reference https://docs.astral.sh/uv/reference/cli/ |
| ruff rules | https://docs.astral.sh/ruff/rules/ |
| ty | https://github.com/astral-sh/ty (0.0.x — check the version before quoting behavior) |
| Tyro | https://brentyi.github.io/tyro/ · completion https://brentyi.github.io/tyro/tab_completion/ |
| loguru | https://loguru.readthedocs.io/ |
| pydantic-settings | https://docs.pydantic.dev/latest/concepts/pydantic_settings/ |
| PyO3 / maturin | https://pyo3.rs/ · https://www.maturin.rs/ |
| Packaging specs | https://packaging.python.org/ · PEP 735 (dependency groups), PEP 723 (script deps) |
The canonical layout
pyproject.toml [project] + [dependency-groups] + tool config. One file.
uv.lock committed. Pins YOUR dev env, never your users'.
.python-version dev interpreter (uv python pin). Not the same as requires-python.
Justfile every supported operation. `just --list` is the surface.
AGENTS.md the agent contract. CLAUDE.md is a symlink to it.
README.md getting started for humans: uv sync, just check.
.envrc / .env.example direnv activation; the tracked contract for .env (which is ignored).
src/my_tool/ src layout: tests import the INSTALLED package
core.py domain logic. stdlib logging. no side effects at import.
_log.py loguru setup. called by entry points only.
settings.py pydantic-settings: defaults < .env < environment.
cli/ one frozen dataclass per subcommand; run() -> exit code
py.typed without this your annotations are invisible to consumers
tests/ mirrors src/ module for module
scripts/ repo helpers, NOT importable from the package
notebooks/ marimo notebooks. import the package; nothing imports them.
.agents/skills/my-tool/ the package's own skill, shipped with the code
.github/workflows/ci.yml runs the same gate as `just check`
Two arrows to keep straight: notebooks/ and scripts/ may import the
package; the package imports neither.
Decision: which profile?
Start at the smallest row that fits and add later — profiles are additive, so "agentic later" costs nothing now.
| Profile | Use when | Adds | Read |
|---|---|---|---|
minimal |
a library nobody runs from a shell yet | package, tests, Justfile, CI, AGENTS.md | uv-and-pyproject, quality-gates |
cli (default) |
almost everything | Tyro CLI, loguru, pydantic-settings, [project.scripts] |
+ tyro-cli, logging-and-config |
lib |
others will import it | publishing metadata, py.typed |
+ uv-and-pyproject §Distribution |
api |
it serves HTTP | FastAPI app, /docs, /openapi.json |
+ api-and-services |
ml |
experiments and notebooks | marimo notebook that is also a CLI | + notebooks-and-widgets |
rust |
a profiled, CPU-bound hot loop | maturin backend, PyO3 crate, .pyi stubs |
+ rust-pyo3 |
Companion skills to recommend, by profile, are in
references/agent-interface.md and in the
scaffolder's recommended_skills[].
Workflow A — new project from zero
1. Decide the profile and the name
Ask only what you cannot infer: what the project does, and whether it needs a
CLI, HTTP, notebooks, or Rust. The slug is hyphen-case (churn-scorer); the
package is the underscore form (churn_scorer). Everything else has a default.
2. Generate
# preview first — writes nothing
uv run skills/local/python-project-best-practice/scripts/new-python-project.py \
--dry-run --profile cli ./my-tool
uv run .../new-python-project.py --profile cli --owner <gh-user> \
--description "One line." ./my-tool
JSON summary on stdout (project, package, profile, files[],
recommended_skills[], next_steps[]); progress on stderr.
3. Verify it actually works
cd ./my-tool
uv sync
just docs-sync # fills the CLI block in AGENTS.md from --help
just check # fmt + lint + types + tests + docs-drift
just check must be green before you hand the project over. If it is not,
that is a bug in this skill's template — fix the template, not the generated
copy.
4. Offer the companion skills
Print the install line and let the user choose:
npx skills@latest add daviddwlee84/agent-skills/skills
Do not vendor copies into the new repo — copies have no update path.
Workflow B — modernize an existing project
1. Audit, read-only
uv run skills/local/python-project-best-practice/scripts/audit-python-project.py \
--format table /path/to/repo
26 checks with evidence and a fix hint, plus an ordered migration_plan[].
Exit 4 when something fails. It never writes, and there is deliberately no
--fix.
2. Show the user the plan and agree on scope
Nobody asked you to rewrite their build system. Confirm which rungs to do now.
3. One rung per pull request
The ladder is ordered by dependency: environment → layout → dependency declaration → lint/format → types → tests → task runner + CI → console script → logging layering → agent contract → secrets. Each rung ends with the test suite green.
Read references/legacy-refactor.md before
the first rung. The traps that ruin these migrations — uv add -r requirements.txt flattening the dependency graph, a formatting commit
destroying git blame, a tracked .env needing rotation before any history
rewrite — are all there.
Available scripts
scripts/new-python-project.py <target-dir>— scaffold from the bundled template tree. Offline; writes nothing outside the target.- Flags:
--profile {minimal,cli,lib,api,ml,rust},--name,--description,--author,--owner,--python-floor,--python-pin,--dry-run,--force,--no-git,--help. - Output: JSON on stdout, progress on stderr.
- Exit:
0ok ·2usage ·3target exists without--force·4template tree andassets/manifest.tomldisagree.
- Flags:
scripts/audit-python-project.py [path]— read-only scorecard + migration plan.- Flags:
--format {json,table},--fail-on {fail,warn,never},--help. - Exit:
0clean ·3path is not a directory ·4findings at or above the threshold.
- Flags:
Both are PEP 723 uv run scripts with inline dependencies — no environment
setup needed before calling them.
Bundled assets
assets/project/— the template tree. Every file ends.tmpl; the generator strips the suffix, substitutes placeholders, and drops# __IF:profile,...__ … # __END__blocks that the chosen profile does not want.assets/manifest.toml— maps each destination path to the profiles that get it. A file not listed here is never copied, and the generator fails (exit 4) if the manifest and the tree disagree in either direction.
Reference files
Load on demand — do not read them all up front.
| File | Load when |
|---|---|
uv-and-pyproject.md |
writing pyproject.toml, lockfile policy, build backends, interpreter pinning, workspaces, publishing |
tyro-cli.md |
building or splitting a CLI, subcommands, completion, config objects |
quality-gates.md |
configuring ruff, a type checker, pytest, coverage, pre-commit, CI |
logging-and-config.md |
wiring loguru, pydantic-settings, .env, direnv |
notebooks-and-widgets.md |
adding notebooks/, dual-mode notebooks, packaged widgets |
api-and-services.md |
the project serves HTTP, or someone asks "should this be an MCP?" |
rust-pyo3.md |
adding a compiled extension, or a Rust edit appears to do nothing |
agent-interface.md |
writing AGENTS.md, the package's own skill, the docs-drift gate |
legacy-refactor.md |
Workflow B — reading the scorecard, ordering the migration |
Gotchas
uv syncdeletes what is not in the lock. A manualuv pip installdisappears on the next sync with no warning. Change dependencies withuv add/uv removesopyproject.tomlanduv.lockmove together.- Do not run black and
ruff formattogether.ruff formatis a black reimplementation; two formatters fight over magic trailing commas and quoting and every commit reformats the last one's output. Pick ruff. - A flat layout imports your source directory, not the installed package.
Missing
__init__.py, missingpy.typed, unshipped package data — all pass the test suite and break the user's install.src/is what closes that gap. [dependency-groups]is not[project.optional-dependencies]. An extra is published and installable by your users; a PEP 735 group is local-only. Your test runner in an extra is a defect you cannot remove without a release.uv syncinstalls thedevgroup by default.uv.lockdoes not constrain your users. Installed as a dependency, your package is resolved from[project.dependencies]and the lock is ignored. Commit it anyway — it pins your dev and CI environment, which is the point.- loguru in a library hijacks the importing program's logging. Library
modules use
logging.getLogger(__name__); only entry points call_log.configure(). Symptom: "my app's log format changed after I added a dependency." logger.add()withoutlogger.remove()prints everything twice. loguru installs a default stderr sink at import. This is the most common loguru bug.diagnose=Trueputs local variables in tracebacks — a credential leak in production logs. Keep it off outside development..python-versionis read by pyenv as well as uv. A stale value silently changes which interpreter builds the venv. Write it withuv python pin. It is the dev pin;requires-pythonis the supported range. Keep both in CI.ruff'starget-versionshould be yourrequires-pythonfloor, not your dev pin, orUPrewrites code to syntax your declared floor cannot run.- direnv cannot change
PS1. There is no(.venv)prompt even when the env is active — checkwhich python, not the prompt. And.envrcneedsdirenv allowafter every edit. - Tyro already has shell completion. Do not add shtab, argcomplete, or a
completionsubcommand:my-tool --tyro-write-completion zsh <path>.--tyro-print-completionis deprecated (a strayprint()corrupts it), the bash completion filename must equal the command name, and no package manager installs completions for you. - No
[project.scripts]meansuv tool installgives the user no command.python -m pkgis not a substitute for something onPATH. - marimo notebooks are real
.pyfiles, so ruff lints them: withoutper-file-ignoresfornotebooks/*(E402,F401,B018) the gate fails and the formatter can reorder cell contents. - A PEP 723 script runs in its own environment. A helper in
scripts/with an inline header cannotimport your_packageunless the package is in its owndependencies. uv syncdoes not rebuild a Rust extension after you editrust/. It sees the package as installed, does nothing, and you keep running the stale binary — including the print statements you just added to debug it. Useuv sync --reinstall-package <slug>ormaturin develop --uv. A compiled module also needs a hand-written.pyi, or type checkers reporthas no member.- Never replace the
AGENTS.md↔CLAUDE.mdsymlink with a real file. Git stores it as mode120000; two real files drift and only one is right. A Windows checkout without symlink support materializes a text file containing the path — that is the corruption, not the fix. - Do not
uv add -r requirements.txtwhen migrating. A pip freeze is a flattened graph; that command promotes every transitive pin to a direct dependency. Add the packages you actually import and let the resolver work. - uv does not supply non-Python system libraries. Some scientific stacks still need conda or OS packages. Say which ones in the README instead of pretending the migration is complete.
- A tracked
.envis an incident, not a cleanup. Rotate every credential first; history rewriting is a later, separately-approved step. Hand off toagent-history-hygiene.
Related skills
verifiable-surfaces (CLI surface design — assumed by this layout) ·
project-knowledge-harness · agent-history-hygiene ·
mkdocs-site-bootstrap · git-workflow · marimo-batch-mlflow ·
fastapi-ai-patterns · cli-release-distribution · mcp-builder