Every reliable Python CLI starts with a foundation you rarely think about again: a
clean pyproject.toml, a reproducible dependency lockfile, an isolated environment,
and a release process that won't surprise you at 2 a.m. This track walks you from an
empty directory to a packaged tool that installs the same way on every machine.
If you're starting a brand-new CLI, read the guides in the order below. If you're hardening an existing project, jump straight to the topic you're fighting with today.
What "set up properly" actually buys you
Project setup has a reputation as ceremony. It is worth being specific about what the ceremony prevents, because each piece maps to a failure people hit in practice.
A declared dependency list means a colleague can install your tool without you being in the
room. A lockfile means they get the same versions you tested against, six months from now,
on a different operating system. An isolated environment means installing your tool cannot
break another one. A console entry point means users type mytool instead of remembering a
path to a script. And a release process means the version on PyPI matches the git tag,
which is the difference between a bug report you can reproduce and one you cannot.
Almost all of it lives in one standardised file. Since PEP 621, pyproject.toml describes the
build backend, the metadata, the dependencies and the commands the package exposes — and every
modern tool reads the same tables. That standardisation is why the choice between uv and Poetry
is far less consequential than it looks: they are two ways of driving the same declarative
file, not two competing project formats.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mytool"
version = "1.4.0"
requires-python = ">=3.11"
dependencies = ["typer>=0.12", "httpx>=0.27"]
[project.scripts]
mytool = "mytool.cli:app"
Twelve lines, and the project is installable, runnable as a command, and buildable into a wheel by any backend-agnostic tool. Everything else in this track is about doing those twelve lines well and keeping them honest over time.
What this track covers
Choosing and driving your toolchain
- uv for Python CLI dependency management
— fast lockfile resolution, environment creation, and PEP 621
pyproject.tomlworkflows with the tool that has become the default for new projects. Includes a head-to-head: uv init vs poetry init for CLI tools, and how uv tool install compares with pipx for installing tools globally. - Poetry workflows for CLI development — lockfile management, dependency groups, and publishing to PyPI when your team is already standardized on Poetry. Expose your command with Poetry entry points and scripts.
Isolation and reproducibility
- Python CLI environment isolation best practices
— use
venv,uv, andpyenvto keep dependencies conflict-free and execution reproducible across development and CI. Then go deeper on managing virtual environments for cross-platform CLIs across Linux, macOS, and Windows.
Scaffolding, quality gates, and releases
- CLI project scaffolding with Cookiecutter — generate production-ready project skeletons so every new tool starts with the same layout, packaging, and conventions.
- Pre-commit hooks for CLI projects
— wire up
ruff,mypy, andpytestgates that run before every commit, with a step-by-step setup guide for Python CLI repos. - Managing CLI versioning & changelogs
— semantic versioning,
bump-my-version, and automating changelogs with Conventional Commits usinggit-cliff.
Packaging and distribution
- Packaging Python CLIs for distribution — turn a project into an installable command with console entry points, then ship it: build wheels and sdists, install and distribute with pipx, and publish to PyPI.
Choosing a dependency manager in practice
Two tools dominate new Python CLI projects, and both write the same standard file. The difference is the loop you live in.
uv is a single fast binary that handles environments, resolution, locking, running and
interpreter installation:
uv init mytool && cd mytool
uv add typer httpx # writes pyproject.toml, resolves, updates uv.lock, syncs .venv
uv add --dev pytest ruff mypy # a dev group nobody ships
uv run mytool --help # no activation step, ever
Poetry covers the same ground with a longer history and a plugin ecosystem:
poetry new mytool && cd mytool
poetry add typer httpx
poetry add --group dev pytest ruff mypy
poetry run mytool --help
The practical differences are speed and interpreter management. A re-lock that takes seconds in Poetry takes well under a second in uv, and uv will download and manage the interpreter itself, which removes a separate pyenv step from onboarding. Neither of those matters much on the first day and both matter on the two-hundredth.
What should not drive the choice is fear of lock-in. Because the dependency metadata lives in
the standard [project] tables, switching is a matter of swapping which lockfile you commit and
which commands your README and CI use. Start with uv on a fresh project; stay on Poetry if your
team already leans on its plugins. The
head-to-head comparison
covers what each init actually generates.
One thing worth deciding early either way: how contributors run the tool during development. An
editable install (uv sync or poetry install both do this for the project itself) means the
console script points at your working tree, so edits take effect without re-installing. That is
what makes mytool --help in a terminal the same thing your users will run.
Quality gates that pay for themselves
The cheapest bug is the one that never reaches review. A small set of automated gates, running before a commit exists, removes an entire category of review comments — formatting, unused imports, a stray debugger, a file that should never have been committed.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9 # an exact tag, never a branch
hooks:
- id: ruff-format
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-added-large-files
Two rules keep this from becoming a nuisance. Pin every rev to an exact tag, so a hook cannot
change under you between commits and turn a green branch red for reasons unrelated to your work.
And keep the pre-commit stage genuinely fast — formatting, linting, whitespace, size guards.
Type checking and the test suite belong in CI or a manual stage; a hook that takes thirty seconds
is a hook people bypass, and a bypassed hook protects nothing.
The gate that actually enforces the standard is the CI job running the same configuration over the whole repository:
- run: pre-commit run --all-files
Local hooks are a convenience anyone can skip with git commit -n; the CI run is the one that
cannot be skipped, and it also catches drift in files nobody has touched for months. The
step-by-step setup
walks through both halves, including keeping tool configuration in pyproject.toml so the two
runs cannot disagree.
Making the next project free
Everything above is perhaps an hour of work. Doing it from memory on every new tool is where the
hour becomes a day, and where projects quietly diverge — one has src/, one does not; one pins
its hooks, one tracks main.
A project template fixes the layout in place. Cookiecutter renders a directory tree from a set of answers:
cookiecutter gh:yourorg/python-cli-template
# project_name [My CLI]: deploy-tool
# package_name [deploy_tool]:
# python_version [3.12]:
What comes out should already have the src/ layout, the four pyproject.toml tables, a dev
dependency group, the pre-commit configuration, a test that imports the package, and a CI
workflow that installs from the lockfile. The
scaffolding guide
covers the template layout and the generation hooks that validate answers before anything is
written.
The failure mode to plan for is staleness. A template nobody generates is a snapshot of what good looked like a year ago, and it fails at the worst possible moment — day one of a new project, when nobody has patience for a broken skeleton. Treat the template as a real project: give it CI that generates a project on every push and runs that project's own tests. Then the template breaks in its own repository, on a Tuesday, instead of in yours.
Reproducibility is a ladder, not a switch
"It works on my machine" is not usually a lie — it is a description of an environment nobody wrote down. Dependency reproducibility comes in rungs, and it is worth knowing which one you are standing on.
Unpinned dependencies mean every install is a fresh roll of the dice against whatever was
published that morning. Version ranges in pyproject.toml narrow the dice but do not remove
them: httpx>=0.27 legitimately resolves to different versions on two machines on the same
afternoon. A committed lockfile removes the ambiguity — it records the exact version and hash
of every transitive dependency, and it is meant to be checked in.
The last rung is the one CI should stand on:
uv sync --frozen # fails if uv.lock is out of date, rather than re-resolving
poetry install --sync # matches the environment to poetry.lock exactly
The --frozen flag matters more than it looks. Without it, a CI job that finds a stale
lockfile quietly resolves fresh versions and goes green, which means the first person to notice
the drift is a user. With it, the job fails with a message naming the file — and the fix is a
one-line commit.
Environment isolation is the other half. A project virtual environment keeps this project's
dependencies away from every other project; pipx or uv tool keeps installed tools away
from each other. They solve different problems and you generally want both. The
isolation guide
covers when each fits, including the cross-platform differences that break activation
instructions in READMEs.
Releasing without drama
A release is where setup decisions get audited. If the version in the metadata, the git tag and the published artifact can disagree, one day they will.
The pattern that holds up is single-sourcing the version and letting one command move it:
bump-my-version bump minor # rewrites the version, commits, and tags in one step
git push --follow-tags # the tag is what triggers the release workflow
Because the same command writes the file and creates the tag, they cannot drift. CI then builds the artifacts and publishes them — ideally with trusted publishing, so there is no long-lived token in the repository to leak or rotate:
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1 # no password: OIDC identity, short-lived
Two habits make the difference between a release process people trust and one they dread. First, rehearse on TestPyPI the first few times — a version number on PyPI can never be reused, so the rehearsal is where mistakes are still free. Second, make the last step of the pipeline an install of the published artifact into an empty environment, followed by running the command once. That five-second check is what catches the missing package, the broken entry point and the dependency you forgot to declare, and it catches them before a user does.
For a CLI specifically, remember what your version number is promising. Users do not import your functions — they call flags and read exit codes. Renaming a flag, changing what exit code 2 means, or altering the shape of stdout are all breaking changes even when no Python signature moved. The versioning guide works through that mapping and the changelog automation that goes with it.
Where projects usually go wrong
Six failures account for most of the setup pain people describe, and each has a one-line fix.
The environment nobody can recreate. Dependencies were installed ad hoc over months, the lockfile was never committed, and the only working environment is on one laptop. The fix is to declare and lock now, then delete the environment and rebuild it from the lockfile — if that fails, better to find out today.
Dev tools shipped to users. pytest and ruff end up in the runtime dependency list, so
every user installs a test framework. One CI job that installs only the runtime set and runs
mytool --version catches it permanently.
The command that works only for its author. It runs from the repository root because it
relies on a relative path or an unpackaged data file. Installing the built wheel into an empty
environment and running it there is the check that surfaces this; importlib.resources is the
fix for bundled data.
A version that lies. The tag says 1.4.0, the metadata says 1.3.2, and the --version output
says something else again. Single-source it and let one command move all three.
Hooks that drift. Local pre-commit runs a different formatter version from CI, so a branch is
green locally and red in review. Pin the revs, keep tool settings in pyproject.toml, run the
same configuration in both places.
Onboarding by tribal knowledge. The README says "install the requirements" and a new
contributor spends a morning discovering which Python version, which extras and which
environment variables. Two commands — clone, then uv sync — should be the whole story, and if
they are not, the gap is a bug in the setup rather than in the person.
None of these need a large investment to avoid. They need the decisions to be written down in files that tools read, which is exactly what the guides in this track do.
A recommended path
- Pick a dependency manager — start with uv unless your team already runs Poetry.
- Lock down environment isolation so builds are reproducible.
- Capture the layout in a Cookiecutter template so the next project is free.
- Add pre-commit gates before the codebase grows.
- Automate versioning and changelogs before your first release.
- Package and distribute the tool so users install it with pipx or pip.
Key takeaways
- One standardised
pyproject.tomldescribes the build, the metadata, the dependencies and the command — the dependency manager you drive it with is a preference, not a lock-in. - Commit the lockfile, and make CI install from it with a flag that fails on drift.
- A project environment and an installed-tool environment solve different problems; use both.
- Single-source the version so the metadata, the tag and the published artifact cannot disagree.
- Finish every release by installing the artifact into an empty environment and running it once.
Treat the list above as a checklist for an existing project rather than a reading order. Most teams already have three of the five in place; the value is in finding which two are missing before a release exposes them, and the fastest way to find out is to delete your local environment and rebuild the project from a clean clone.
Frequently asked questions
Do I need a lockfile if my CLI has only two dependencies?
Yes, because you do not have two dependencies — you have two plus everything they pull in, and that transitive set changes weekly. The lockfile is what makes a bug report reproducible and what stops a CI failure that has nothing to do with your commit. It costs one file and one flag.
Should the version live in pyproject.toml or in the package?
Pick one and derive the other. Either the metadata is authoritative and your code reads it with
importlib.metadata.version("mytool"), or a tool derives both from the git tag. A hard-coded
__version__ maintained alongside the metadata is the arrangement that eventually ships a
--version that lies.
Is a src/ layout worth it for a small tool?
For anything you package, yes. Without it, Python imports your package straight from the working directory, so your tests exercise the source tree rather than the installed artifact — and a packaging mistake stays invisible until a user hits it. The cost is one directory; the benefit is that what you test is what you ship.
What is the difference between dependencies and dependency groups?
Runtime dependencies are what users get when they install your tool. Groups — dev, test, docs — are for the people working on it and never reach the wheel. The check worth having in CI is one job that installs only the runtime set and runs the command: it catches the import you thought was a runtime dependency and was not.
Should end users install my CLI with pip?
Prefer pipx or uv tool install in your README. A plain pip install puts your tool's
dependencies into whichever environment happens to be active, which is how two tools end up
fighting over an incompatible version of the same library. Isolated installs make that class of
support ticket disappear.
How do I keep a template from going stale?
Treat the template as a project with its own CI: generate a project from it on every push and run that project's tests. A Cookiecutter or Copier template that is never generated is a snapshot of what good looked like eighteen months ago, and it fails in the least convenient moment — the first day of a new project.
Should CI test against several Python versions?
Test against the oldest version your requires-python claims to support and the newest one you
expect people to use — those are the two that break. Everything in between rarely surfaces a
distinct failure, and a five-version matrix mostly buys you a slower pipeline. What genuinely
justifies more entries is a platform matrix: Windows path handling breaks tools that Linux CI
declares healthy.
How do I handle a dependency that only some users need?
Declare it as an optional extra rather than a hard dependency, and import it lazily inside the
command that uses it. pip install mytool[aws] is a clear contract, and the lazy import means
users who never touch that command never pay the import cost. A helpful error when the extra is
missing — naming the exact install command — turns a traceback into an instruction.
Do I need a changelog if the project has one maintainer?
Yes, and mostly for that maintainer. Six months later the question "why did 1.3 break my script" has an answer only if something recorded it. Derived changelogs make this nearly free: write conventional commit messages and let a generator group them at release time, so the effort moves from an end-of-release chore to a habit you already have.
What is the minimum useful CI for a Python CLI?
Four steps, and they fit in a twenty-line workflow: check out the code, install strictly from the lockfile, run the linters and the test suite, then build the wheel and install it into a fresh environment to run the command once. The first three protect the codebase; the fourth protects the thing users actually receive, and it is the step most projects leave out until the first broken release teaches them to add it.
Can I move a project from requirements.txt without a big rewrite?
Usually in one sitting. Move the pinned runtime packages into [project.dependencies] as ranges
rather than exact pins, put the development-only entries into a dev group, then run uv lock or
poetry lock to produce a real lockfile from that declaration. Keep the old file around for one
release with a comment pointing at the new source of truth, so anyone with muscle memory is not
left guessing, and delete it once CI is installing from the lockfile.
Related tracks
Once the foundation is in place, move on to designing the command surface in Modern Python CLI Frameworks & Architecture, and polishing how your tool reads input and talks back in Advanced Input Parsing & User Experience.