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.

Diagram: the Poetry CLI lifecycle left to right — poetry init, add, lock (poetry.lock), install, build, and finally publish to 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.

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:

  • 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.