Project Setup

Poetry Workflows for CLI Development

Use Poetry for Python CLI development — lock file management, script entry points, dependency groups, and automated publishing to PyPI.

Updated

Poetry gives a Python CLI project one tool for everything between the first poetry new and the poetry publish that ships it to PyPI: a reproducible lock file, isolated dependency groups for dev and test tooling, a declarative console-script entry point, and a build backend that produces wheels. This article walks the full loop for a real CLI, using a modern PEP 621 pyproject.toml, and shows where Poetry differs from uv.

TL;DR

poetry new --src weatherly          # scaffold a src-layout package
poetry add typer httpx rich         # runtime deps -> [project.dependencies]
poetry add --group dev ruff mypy    # tooling, excluded from the published wheel
poetry add --group test pytest pytest-cov
poetry install                      # resolve, write poetry.lock, install into the venv
poetry run weatherly now Lisbon     # run the console-script entry point
poetry build                        # produce sdist + wheel in dist/
poetry publish                      # upload to PyPI

The console-script name comes from [project.scripts]; poetry.lock pins the whole transitive graph so every machine resolves identically.

Poetry CLI lifecycle Poetry CLI lifecycle poetry init poetry add poetry lock poetry.lock poetry install poetry build poetry publish PyPI

A modern Poetry pyproject.toml for a CLI

Poetry 2.x reads standard PEP 621 metadata from the [project] table. Put runtime metadata and dependencies there, declare the entry point under [project.scripts], and reserve [tool.poetry] for the few Poetry-specific knobs that PEP 621 has no field for — most notably the package layout and the dependency groups.

[project]
name = "weatherly"
version = "0.3.0"
description = "A friendly command-line weather client."
authors = [{ name = "Ada Lovelace", email = "ada@example.com" }]
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
keywords = ["cli", "weather", "typer"]
dependencies = [
    "typer>=0.12.0",
    "httpx>=0.27.0",
    "rich>=13.7.0",
]

[project.urls]
Homepage = "https://github.com/ada/weatherly"
Issues = "https://github.com/ada/weatherly/issues"

[project.scripts]
weatherly = "weatherly.cli:app"

[tool.poetry]
packages = [{ include = "weatherly", from = "src" }]

[tool.poetry.group.dev.dependencies]
ruff = "^0.5.0"
mypy = "^1.10.0"

[tool.poetry.group.test.dependencies]
pytest = "^8.2.0"
pytest-cov = "^5.0.0"

[build-system]
requires = ["poetry-core>=2.0.0"]
build-backend = "poetry.core.masonry.api"

Two things are worth noting. First, runtime dependencies live in [project.dependencies] as PEP 508 strings (typer>=0.12.0), not in the old [tool.poetry.dependencies] caret table — that legacy form still works but the standard table is the forward-compatible choice. Second, [project.scripts] points at weatherly.cli:app: the module path, a colon, then the callable. For a Typer app that callable is the Typer() instance, because Typer is itself callable; for a Click group or a plain function you point at the function. The build backend turns that mapping into an executable on the user's PATH.

The entry point and the CLI it launches

The weatherly = "weatherly.cli:app" line is the contract: when the wheel installs, the packaging machinery generates a weatherly launcher that imports weatherly.cli and calls app. Here is the module it resolves to (src/weatherly/cli.py):

import typer
from rich.console import Console

app = typer.Typer(help="A friendly command-line weather client.")
console = Console()


@app.command()
def now(city: str, units: str = "metric") -> None:
    """Show the current weather for CITY."""
    console.print(f"[bold]{city}[/bold]: 21 degrees ({units})")


@app.command()
def version() -> None:
    """Print the installed version."""
    console.print("weatherly 0.3.0")


if __name__ == "__main__":
    app()

The if __name__ == "__main__" block lets you run python -m weatherly.cli during local hacking, while [project.scripts] provides the installed weatherly command. Keeping both is a common pattern — see best practices for Python CLI entry points for why you generally point the script at the Typer/Click object rather than a bespoke main() wrapper.

poetry lock and poetry install

poetry install does two jobs. If poetry.lock is missing or stale, it resolves the dependency graph and writes a fresh lock; then it installs that exact graph into the project's virtual environment, including your package in editable mode. To resolve without installing — useful in CI or after editing pyproject.toml — run poetry lock:

poetry lock              # resolve the graph, write/update poetry.lock only
poetry install           # install the locked graph (creates the venv if needed)
poetry install --sync    # additionally remove anything not in the lock
poetry check --lock      # fail fast if pyproject.toml and poetry.lock disagree

poetry.lock pins every transitive package to an exact version and records content hashes, so a teammate or a CI runner that runs poetry install gets a byte-identical environment. Commit the lock file for applications and CLIs — it is the reproducibility guarantee. (For libraries meant to be imported into other projects, the lock is still convenient locally but the published constraints come from [project.dependencies].)

Because the lock captures the resolved graph, you change versions by editing constraints and re-locking, not by hand-editing the lock. poetry add httpx@^0.28 bumps the constraint and re-resolves in one step; poetry update httpx re-resolves within the existing constraint to pick up a newer compatible release.

Dependency groups: main vs dev vs test

The dependencies array under [project] is the main group — these are the only packages installed when someone pip installs your wheel. Everything a contributor needs but a user does not — linters, type checkers, the test runner — belongs in named groups under [tool.poetry.group.<name>.dependencies]. They are recorded in the lock for reproducibility but never leak into the published artifact.

What each dependency group is for A Poetry project splitting dependencies into the main group shipped to users and dev and test groups that stay local. What each dependency group is for pyproject.toml dependencies only main ships to users main click, httpx — installed with the wheel dev ruff, mypy — never in the wheel test pytest, coverage — CI only docs optional, installed on demand poetry install --only main mirrors what a user gets CI installs main plus test, nothing else A stray dev dependency in main bloats every install Run one CI job with --only main: it catches the import you thought was a runtime dependency.
poetry install                                  # main + all non-optional groups
poetry install --only main                      # runtime deps only (mimics a user install)
poetry install --with test                      # main + the test group
poetry install --without dev                    # everything except dev tooling
poetry run pytest                               # the test group is now importable

In CI this maps cleanly onto stages: a lint job runs poetry install --only main,dev, a test job runs poetry install --with test. Groups are also how you keep heavy optional tooling (docs builders, profilers) out of the default contributor install.

Building and publishing to PyPI

poetry build invokes poetry-core to produce both a source distribution and a wheel under dist/. poetry publish uploads whatever is in dist/; pass --build to do both in one step.

poetry build                                    # -> dist/weatherly-0.3.0.tar.gz + .whl
poetry publish --build                          # build, then upload to PyPI

# First, validate against TestPyPI:
poetry config repositories.testpypi https://test.pypi.org/legacy/
poetry publish --build --repository testpypi

# Authenticate with a PyPI API token (recommended over passwords):
poetry config pypi-token.pypi pypi-AgEN...

Bump the version before publishing — poetry version patch (or minor/major) rewrites version in [project] for you. For a real release pipeline, prefer PyPI's trusted publishing (OIDC) from CI so no long-lived token is stored at all; poetry publish works under it because the upload still goes through the standard endpoint.

Poetry vs uv: when to reach for which

uv is a single Rust binary that resolves and installs at a different speed class, and it manages the Python interpreter itself. Poetry is the mature, batteries-included incumbent with a long-stable publishing story and the richest dependency-group ergonomics. The practical differences:

Reaching for Poetry or uv mid-project A decision diagram for teams already using Poetry: stay unless the install loop is the bottleneck. Reaching for Poetry or uv mid-project Is dependency install time hurting the team? No — the loop is fine and plugins are in use Stay on Poetry migration buys nothing Yes — CI and onboarding wait on installs Move to uv same pyproject, faster loop A migration is a day of work and a lockfile swap; do it for a measured problem, not for novelty.
  • Speed. uv's resolver and installer are dramatically faster; on a cold cache the gap is large, and it shows up most in CI.
  • Interpreter management. uv downloads and pins Python versions; Poetry expects an interpreter to already exist (often via pyenv).
  • Lock format. Both produce a committed lock, but they are not interchangeable — pick one per project.
  • Maturity. Poetry's publish, plugin ecosystem, and group syntax are battle-tested across years of releases.

If you want raw speed and unified interpreter+package management, lean uv. If you want a stable, well-documented workflow with first-class dependency groups and a publishing command you can set and forget, Poetry is a safe default. For a side-by-side of the scaffolding step specifically, see uv init vs poetry init for CLI tools.

Production notes

  • Commit poetry.lock. It is the only thing that makes poetry install reproducible across machines; treat a drift between it and pyproject.toml as a CI failure via poetry check --lock.
  • Use the in-project venv in CI. poetry config virtualenvs.in-project true puts the environment under .venv/, which caches cleanly between runs.
  • Pin poetry-core, not Poetry, in build-system. The build backend version is what affects your wheel; the Poetry CLI version is a developer concern.
  • Test the installed entry point, not just python -m. After poetry install, run poetry run weatherly --help to confirm the [project.scripts] mapping actually resolves — a typo there only surfaces at install time.
  • Don't hand-edit the lock. Re-resolve with poetry lock or poetry update so the hashes stay consistent.

Groups, extras and what reaches a user

Poetry distinguishes two things that look similar and behave very differently, and getting them right is most of what "a clean Poetry project" means.

Dependency groups are for the people working on the project:

[tool.poetry.group.dev.dependencies]
ruff = "^0.6"
mypy = "^1.11"

[tool.poetry.group.test.dependencies]
pytest = "^8.3"
pytest-cov = "^5.0"

[tool.poetry.group.docs]
optional = true                     # only installed when explicitly asked for

[tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5"
poetry install                      # main + non-optional groups
poetry install --only main          # exactly what a user gets
poetry install --with docs          # add an optional group
poetry install --sync               # remove anything not in the lock

That --only main install is worth a small CI job on its own: it is the cheapest way to catch a development dependency that quietly became a runtime import.

Extras are for users who want an optional feature:

[project.optional-dependencies]
aws = ["boto3>=1.35"]

The difference in one line: a group never appears in the published wheel; an extra is part of your public interface, installable with pipx install "mytool[aws]".

Versions, constraints and the lockfile

Poetry's caret constraint is compact and occasionally surprising. ^1.4.2 means "at least 1.4.2, below 2.0.0" — but for a pre-1.0 package ^0.6.1 means "below 0.7.0", because in a zero-major world the minor number carries the breaking changes. That is usually what you want, and it is worth knowing rather than discovering.

[project]
dependencies = [
  "typer>=0.12,<1.0",     # PEP 621 style: explicit, portable to any tool
  "httpx>=0.27",
]

Prefer the standard [project] table with explicit ranges over [tool.poetry.dependencies] with carets for anything new. It means the same file is readable by pip, uv and build, and it removes the translation step if you ever move.

The lockfile records the exact resolution, and two commands keep it honest:

poetry check --lock          # fails if poetry.lock is out of date with pyproject.toml
poetry lock --no-update      # re-lock after editing pyproject without upgrading anything
poetry update httpx          # move one dependency deliberately

poetry check --lock belongs in CI and in a pre-commit hook. Without it, someone adds a dependency, forgets to re-lock, and the job installs a graph nobody tested.

Publishing from Poetry

Poetry can build and publish in two commands, and for a CLI the artifacts are the same wheel and sdist any other backend would produce:

poetry build                         # dist/*.whl and dist/*.tar.gz
poetry publish --repository testpypi # rehearse first — a version can never be reused
poetry publish                       # the real thing

Configure credentials once, and prefer a project-scoped token over an account-wide one:

poetry config pypi-token.pypi pypi-AgEIcHl...

From CI, prefer trusted publishing over any stored token — build with Poetry and hand the artifacts to the publishing action, so no long-lived secret exists in the repository at all:

- run: poetry build
- uses: pypa/gh-action-pypi-publish@release/v1

Whatever route you take, finish with the check no build system performs for you: install the built wheel into an empty environment and run the command once.

python -m venv /tmp/smoke && /tmp/smoke/bin/pip install dist/*.whl && /tmp/smoke/bin/mytool --version

A CI workflow that matches local development

The point of a lockfile is that CI and a laptop install the same graph. Four steps get there:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pipx install poetry==1.8.3          # pin the tool, like any other dependency
      - run: poetry check --lock                 # fail fast if the lock is stale
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pypoetry
          key: poetry-${{ hashFiles('poetry.lock') }}
      - run: poetry install --sync --with test
      - run: poetry run pytest -q

Three details do the work. Poetry itself is pinned, so a release cannot change resolution behaviour overnight. check --lock runs before anything is installed, so a stale lockfile fails in two seconds rather than after a full install. And the cache key is a hash of the lockfile, which makes a stale cache impossible rather than merely unlikely.

Add the packaging job separately, because it tests a different thing:

  package:
    steps:
      - uses: actions/checkout@v4
      - run: pipx install poetry==1.8.3
      - run: poetry build
      - run: python -m venv /tmp/smoke
      - run: /tmp/smoke/bin/pip install dist/*.whl
      - run: /tmp/smoke/bin/mytool --version

That last line is the entire point of the job: it proves the wheel contains the package, the entry point resolves, and the tool starts with only its declared runtime dependencies available.

Frequently asked questions

Where does Poetry put the virtual environment?

By default in a central cache directory keyed on the project path, which is why which mytool sometimes points somewhere unexpected. poetry config virtualenvs.in-project true moves it to .venv inside the project, which most people find easier to reason about and trivial to delete when something has gone strange.

Do I need poetry shell?

No, and it is now a plugin rather than a built-in. poetry run <command> executes inside the environment without changing your shell, and it is the form that works identically in scripts and CI. Activation is a convenience for a long interactive session, never a requirement.

Should poetry.lock be committed?

Always. It is what makes a clone reproducible, and CI should install from it. Treat a lockfile conflict in a pull request as a re-lock rather than a manual merge: regenerate with poetry lock --no-update and commit the result.

How do I read the version at run time?

From the installed metadata, not a hard-coded string: importlib.metadata.version("mytool"). That way the number reported by --version cannot drift from what was packaged. If you prefer a single source in code, a plugin can derive the metadata version from a __version__ attribute — but pick one direction and stay with it.

Is Poetry slower than uv?

For resolution and installation, yes, noticeably — seconds rather than sub-second on a warm cache. Whether that matters depends on how often you re-lock and how often CI builds a fresh environment. It is a reason to consider uv for a new project and rarely a reason to migrate a working one.

Can Poetry manage Python versions?

No. It selects an interpreter that satisfies requires-python from what is already installed, so you still need pyenv, your system package manager, or uv to provide the interpreters themselves. Declaring the range and testing its ends in CI matters more than which tool installed the binary.

How do I keep a monorepo of small CLIs manageable?

Give each tool its own pyproject.toml and lockfile, and use path dependencies for the shared library between them:

[tool.poetry.dependencies]
mycompany-core = { path = "../core", develop = true }

Each CLI then locks independently, so upgrading a dependency for one tool cannot break another, and develop = true means edits to the shared package take effect without re-installing. The alternative — one lockfile covering everything — couples release cadences that have no reason to be coupled.

What is the quickest way to see why a version was chosen?

poetry show --tree prints the resolved graph with the constraint each parent imposed, which is usually enough to explain why an upgrade is being held back. For a single package, poetry show httpx gives the resolved version and its dependents. Both read the lockfile rather than the network, so they are instant and safe to run mid-investigation.