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