Project Setup

Project Setup & Dependency Management

Set up Python CLI projects with pyproject.toml, uv or Poetry, virtual environments, versioning strategies, and automated release workflows.

Updated

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.

Project setup pipeline — from empty directory to a shipped CLI Project setup pipeline empty directory a shipped CLI Scaffold layout & files Dependencies lock & resolve Isolation virtual envs Quality gates lint & test Version & release from an empty directory to a shipped CLI

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.

The four tables a CLI project needs The sections of a pyproject.toml for a command line tool: the build backend, the project metadata, the dependency list and the console scripts table. The four tables a CLI project needs pyproject.toml one file, four jobs [build-system] which backend builds the wheel [project] name, version, requires-python dependencies what users get installed [project.scripts] the command on their PATH Everything here is standardised (PEP 621) uv, Poetry, pip and build all read the same tables Tool settings live under [tool.*] and never affect the wheel Because the format is standard, changing dependency manager does not mean rewriting the project.

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

The seven sections of this track The project setup track covers dependency managers, virtual environments, scaffolding, pre-commit hooks, versioning and packaging. The seven sections of this track Project setup & dependencies everything before the first command runs uv & Poetry declare and lock dependencies Environments isolation that survives CI Scaffolding & hooks a repo that stays consistent Versioning & packaging wheels, PyPI, pipx each section holds runnable guides for the tools it names The order matters: a locked environment is what makes every later step reproducible.

Isolation and reproducibility

Scaffolding, quality gates, and releases

Packaging and distribution

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.

How reproducible is your setup, really? Four levels of dependency reproducibility, from unpinned installs through version ranges and a committed lockfile to a hash-verified frozen install. How reproducible is your setup, really? most reproducible Frozen install from a lockfile uv sync --frozen Exact versions and hashes. A stale lock fails the build instead of drifting. Committed lockfile uv.lock / poetry.lock Everyone resolves to the same graph; CI may still re-resolve if you let it. Version ranges in pyproject >=2.0,<3 Two machines can legitimately install different versions on the same day. Unpinned dependencies httpx Whatever was newest when each person installed. Reproducible by luck only. least reproducible The jump that matters is from ranges to a committed lockfile; the top rung is what keeps CI honest.

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.

From merged commit to installable tool A release pipeline: a version bump creates a tag, CI builds the wheel and source distribution, publishes to the index, and users install with pipx. From merged commit to installable tool Bump + tag one command, one commit CI builds wheel and sdist Publish trusted publishing pipx install what the user runs push --tags on tag available Every arrow is automated: the only human decision in a release is which part of the version number moves.

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.

The lifecycle of a CLI project A project lifecycle from scaffolding through locking dependencies, adding hooks, building a wheel and publishing a release. The lifecycle of a CLI project Scaffold pyproject, src layout day 1 Lock uv.lock or poetry.lock day 1 Gate pre-commit and CI week 1 Ship wheel, PyPI, pipx release the same four steps whether the tool has one command or forty Every step here is cheap on day one and expensive to retrofit at release time.
  1. Pick a dependency manager — start with uv unless your team already runs Poetry.
  2. Lock down environment isolation so builds are reproducible.
  3. Capture the layout in a Cookiecutter template so the next project is free.
  4. Add pre-commit gates before the codebase grows.
  5. Automate versioning and changelogs before your first release.
  6. Package and distribute the tool so users install it with pipx or pip.

Key takeaways

  • One standardised pyproject.toml describes 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.

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.