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.
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 makespoetry installreproducible across machines; treat a drift between it andpyproject.tomlas a CI failure viapoetry check --lock. - Use the in-project venv in CI.
poetry config virtualenvs.in-project trueputs the environment under.venv/, which caches cleanly between runs. - Pin
poetry-core, not Poetry, inbuild-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. Afterpoetry install, runpoetry run weatherly --helpto 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 lockorpoetry updateso the hashes stay consistent.