SuperAPI AI Directory

python-project-best-practice

Coding & development

View repository

Updated 10-15 20:33

About this skill

Python Project Best Practice

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: 0 ok · 2 usage · 3 target exists without --force · 4 template tree and assets/manifest.toml disagree.
  • scripts/audit-python-project.py [path] — read-only scorecard + migration plan.
    • Flags: --format {json,table}, --fail-on {fail,warn,never}, --help.
    • Exit: 0 clean · 3 path is not a directory · 4 findings at or above the threshold.

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 sync deletes what is not in the lock. A manual uv pip install disappears on the next sync with no warning. Change dependencies with uv add / uv remove so pyproject.toml and uv.lock move together.
  • Do not run black and ruff format together. ruff format is 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, missing py.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 sync installs the dev group by default.
  • uv.lock does 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() without logger.remove() prints everything twice. loguru installs a default stderr sink at import. This is the most common loguru bug.
  • diagnose=True puts local variables in tracebacks — a credential leak in production logs. Keep it off outside development.
  • .python-version is read by pyenv as well as uv. A stale value silently changes which interpreter builds the venv. Write it with uv python pin. It is the dev pin; requires-python is the supported range. Keep both in CI.
  • ruff's target-version should be your requires-python floor, not your dev pin, or UP rewrites code to syntax your declared floor cannot run.
  • direnv cannot change PS1. There is no (.venv) prompt even when the env is active — check which python, not the prompt. And .envrc needs direnv allow after every edit.
  • Tyro already has shell completion. Do not add shtab, argcomplete, or a completion subcommand: my-tool --tyro-write-completion zsh <path>. --tyro-print-completion is deprecated (a stray print() corrupts it), the bash completion filename must equal the command name, and no package manager installs completions for you.
  • No [project.scripts] means uv tool install gives the user no command. python -m pkg is not a substitute for something on PATH.
  • marimo notebooks are real .py files, so ruff lints them: without per-file-ignores for notebooks/* (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 cannot import your_package unless the package is in its own dependencies.
  • uv sync does not rebuild a Rust extension after you edit rust/. 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. Use uv sync --reinstall-package <slug> or maturin develop --uv. A compiled module also needs a hand-written .pyi, or type checkers report has no member.
  • Never replace the AGENTS.md↔CLAUDE.md symlink with a real file. Git stores it as mode 120000; 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.txt when 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 .env is an incident, not a cleanup. Rotate every credential first; history rewriting is a later, separately-approved step. Hand off to agent-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