A CLI project needs two very different sets of dependencies. There is what the tool needs to run — Typer, httpx, Rich — which ships to every user. And there is everything developers need to work on it: pytest and its plugins, Ruff, mypy, documentation generators, release tooling. Mixing the two is the classic packaging mistake: a linter listed as a runtime dependency gets installed on every user's machine, or a library the tool actually imports is listed only for development, so tests pass and the first user gets ModuleNotFoundError. Poetry's dependency groups keep the sets apart and let each CI job install exactly what it needs. This guide sets them up for a CLI, shows the install commands for each job, and explains when to use groups and when to use extras. It belongs to the Poetry workflows for CLI development topic.
Prerequisites
- A CLI project managed with Poetry 1.2 or newer (groups were introduced in 1.2; the examples use 2.x).
- A basic
pyproject.tomlwith[tool.poetry.scripts]or[project.scripts]defining your command, as in Poetry entry points and scripts for CLIs.
The shape: one main group, several tool groups
Runtime dependencies live in the main group — [project] dependencies in Poetry 2 projects using standard metadata, or [tool.poetry.dependencies] in older ones. Only these reach the wheel, so only these are installed for users. Everything else goes into named groups by purpose. Grouping by purpose rather than lumping everything into dev pays off in CI, where a lint job does not need pytest and a test job does not need the documentation toolchain.
The recipe
# pyproject.toml
[project]
name = "mytool"
version = "1.8.0"
requires-python = ">=3.10"
dependencies = [
"typer>=0.12",
"httpx>=0.27",
"rich>=13",
]
[project.scripts]
mytool = "mytool.cli:app"
[tool.poetry.group.test.dependencies]
pytest = ">=8"
pytest-cov = ">=5"
pytest-randomly = ">=3.15"
[tool.poetry.group.lint.dependencies]
ruff = ">=0.6"
mypy = ">=1.10"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
mkdocs-material = ">=9"
mkdocs-click = ">=0.8"
[tool.poetry.group.release]
optional = true
[tool.poetry.group.release.dependencies]
twine = ">=5"
[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"
Two details matter here:
optional = true means a plain poetry install skips the group. Documentation tooling is heavy and most contributors never build the docs, so making it optional keeps everyday setup fast. Contributors who need it add --with docs.
Lower bounds, not pins, in groups too. The lockfile pins exact versions for reproducibility; the constraints in pyproject.toml should say what works. That keeps poetry update meaningful and avoids lock conflicts between groups.
Adding tools to the right group is one command each:
poetry add --group test pytest-xdist
poetry add --group lint "ruff>=0.6"
poetry add --group docs mkdocs-click
poetry remove --group lint black # ruff format replaced it
Installing exactly what each job needs
Each place that installs the project should ask for exactly the groups it uses:
# .github/workflows/ci.yml (excerpt)
lint:
steps:
- run: pipx install poetry
- run: poetry install --only main,lint
- run: poetry run ruff check . && poetry run mypy src
test:
steps:
- run: pipx install poetry
- run: poetry install --only main,test
- run: poetry run pytest -q
--only installs the listed groups and nothing else, which has a useful side effect beyond speed: if the test suite accidentally depends on something from the lint group, the test job fails and tells you. --with adds optional groups to the default set; --without removes groups from it. For a production container, poetry install --only main --no-root followed by installing the built wheel gives an environment with no development tooling at all.
Groups versus extras
Groups and extras solve different problems, and confusing them leads to packaging bugs.
- Dependency groups are for developers. They are never part of the published package's metadata; users cannot request them.
- Extras (
[project.optional-dependencies]) are for users. They are published in the wheel's metadata and requested at install time:pipx install "mytool[s3]"adds the S3 backend's dependencies.
If a feature of your CLI needs boto3 and you want users to opt into it, that is an extra. If you need moto to test that feature, that is a test-group dependency. The runtime code should import the optional dependency lazily and give a helpful message when it is missing:
def s3_client():
try:
import boto3
except ImportError:
raise SystemExit("S3 support needs an extra: pipx install 'mytool[s3]'") from None
return boto3.client("s3")
That pattern also keeps startup fast for users who never touch S3, as described in reducing CLI dependency weight.
Keeping tool groups current
Development tools change faster than runtime dependencies, and they change in ways that affect every contributor at once: a new Ruff release adds rules, a new mypy release finds errors it previously missed, a pytest plugin drops support for an old Python. Because the lockfile pins every group, those changes arrive only when you run poetry update — which is exactly what you want, as long as someone actually runs it.
A few practices keep the groups healthy without surprises:
- Update groups separately from runtime dependencies.
poetry update --only lintbumps the linters without touching what users install, so a linter upgrade never rides along with a behaviour change in the tool. - Let automation open the pull requests. Dependabot and Renovate both understand Poetry lockfiles and can group development-tool updates into one weekly pull request, where CI shows exactly which new findings appear.
- Match tool versions in pre-commit. If
.pre-commit-config.yamlpins Ruff separately, keep it in step with the lint group, or developers see different results on commit and in CI. Running Ruff through a local hook that uses the project environment avoids the duplication; see writing local pre-commit hooks in Python. - Remove what nobody uses. Groups accumulate abandoned tools.
poetry show --only lint --top-levelonce a quarter is enough to notice them.
UX considerations
- Fast default setup.
poetry installwith no flags should give a contributor everything needed for the usual loop — runtime, test and lint — and nothing heavier. Make slow groups optional. - Document the groups. A short table in
CONTRIBUTING.mdlisting each group and when to install it saves every new contributor from readingpyproject.toml. - Audit what users get.
poetry show --only main --treeshows exactly the dependency tree that ships to users. Review it before each release; it is the only part of the lockfile users experience.
Testing the behaviour
Two checks confirm the groups are right. First, the test suite must pass with only the main and test groups installed — CI's --only main,test already enforces this. Second, the built package must declare exactly the runtime dependencies and no tooling:
# tests/test_packaging.py
import subprocess
import sys
import zipfile
from pathlib import Path
import pytest
DEV_ONLY = {"pytest", "ruff", "mypy", "mkdocs-material", "twine"}
@pytest.mark.slow
def test_wheel_does_not_depend_on_dev_tools(tmp_path: Path) -> None:
subprocess.run([sys.executable, "-m", "build", "--wheel", "--outdir", str(tmp_path)],
check=True, capture_output=True)
wheel = next(tmp_path.glob("*.whl"))
with zipfile.ZipFile(wheel) as zf:
meta = next(n for n in zf.namelist() if n.endswith("METADATA"))
requires = [l.split(":", 1)[1].strip() for l in zf.read(meta).decode().splitlines()
if l.startswith("Requires-Dist:")]
names = {r.split()[0].split(";")[0].split(">")[0].split("=")[0].split("[")[0].lower()
for r in requires}
assert not names & DEV_ONLY, f"dev tools leaked into runtime deps: {names & DEV_ONLY}"
assert {"typer", "httpx", "rich"} <= names
The test builds the wheel with python -m build (add build to the test group) and reads Requires-Dist from its metadata — the exact list pip or uv will install for users. It is slow compared with unit tests, so mark it and run it in the packaging job rather than on every save.
Conclusion
Dependency groups draw the line between what your CLI needs to run and what you need to build it. Keep runtime dependencies in the main group, split tooling into test, lint and optional docs or release groups, install exactly the groups each CI job needs with --only, and use extras — not groups — for optional features users choose. The result is faster CI, lighter installs for users, and packaging mistakes caught by the pipeline rather than by the first person to run the release.
Frequently asked questions
What happened to dev-dependencies?
[tool.poetry.dev-dependencies] is the pre-1.2 syntax. Poetry still reads it as a group named dev, but new projects should use [tool.poetry.group.dev.dependencies] or more specific groups.
Does Poetry support standard [dependency-groups] (PEP 735)?
Recent Poetry 2.x releases read PEP 735 dependency groups as well as their own format. Using the standard table makes the project readable by uv and other tools too, which is useful if you are considering migrating a CLI from Poetry to uv.
Can one group include another?
Poetry groups cannot include each other, but PEP 735 groups can, with {include-group = "test"} entries. If you need composition, the standard format is the one to use.
Should the test group include the package's optional extras?
Yes, if you test the optional features. Install them in the test job with poetry install --only main,test --extras s3 (or --all-extras) so extra-dependent code paths are exercised.