CLIs accumulate leftovers. A subcommand is removed but its helper functions stay. A formatter is replaced but the old one is still imported somewhere. A dependency added for a feature that never shipped sits in pyproject.toml, installed by every user, scanned by every security audit and loaded by nothing. Meanwhile, the code quietly imports a package it never declared, because a framework happened to pull it in — until the framework drops it and the CLI breaks on a fresh install. Two small tools catch both kinds of rot: vulture finds code that nothing uses, and deptry compares the imports in your code with the dependencies you declare. This guide runs both on a Typer CLI, explains the findings and false positives you will meet, configures them in pyproject.toml and adds them to CI. It belongs to the linting and type checking topic.
Prerequisites
- A CLI project with its dependencies installed in a virtual environment (
uv sync). uv add --dev deptry vulture, so both run inside the project environment — this matters for deptry, as shown below.
Two different questions
vulture answers "what in my code is never used?" It parses your source, records every defined name and every use, and reports definitions with no uses, each with a confidence percentage. It knows nothing about packaging.
deptry answers "do my imports and my declared dependencies agree?" It scans imports in your source and compares them with [project] dependencies (and optional dependencies and groups), reporting four kinds of mismatch:
- DEP001 — a package is imported but not declared: the CLI will break for anyone who installs it without that package.
- DEP002 — a dependency is declared but never imported: dead weight for every user.
- DEP003 — a package is imported but only installed transitively, through another dependency: it works today and breaks when that dependency changes.
- DEP004 — a development-only dependency is imported in shipped code.
The recipe: deptry
Run it from inside the project environment:
uv run deptry src
On a small Typer CLI that declares typer, httpx and pyyaml, uses Typer and PyYAML, and imports Rich directly in one module, deptry reports:
pyproject.toml: DEP002 'httpx' defined as a dependency but not used in the codebase
src/mytool/__init__.py:1:8: DEP003 'rich' imported but it is a transitive dependency
Found 2 dependency issues.
Both are real. httpx was declared for a feature that was never finished, so it can go. rich arrives only because Typer depends on it; the code should declare it explicitly, so a future Typer release that makes Rich optional does not break the CLI.
Running deptry outside the project environment — with uvx deptry from a bare shell, say — produces misleading results, because deptry uses installed package metadata to map distribution names to module names. Without the environment it cannot know that the pyyaml distribution provides the yaml module, and reports a false DEP001 for yaml and a false DEP002 for pyyaml. Always run it with the project's dependencies installed.
Configure exceptions deliberately
Some declarations are legitimately not imported: a plugin loaded by entry point, a backend selected by configuration, a package you depend on only for its command-line tool. Record those in pyproject.toml with a comment:
[tool.deptry.per_rule_ignores]
DEP002 = [
"keyring", # backend chosen at runtime by keyring's own entry points
]
Optional features imported lazily are fine as long as the dependency is declared in an extra; deptry understands [project.optional-dependencies]. Keep the ignore lists short — each entry is a place where deptry can no longer help you.
The recipe: vulture
uv run vulture src --min-confidence 60
On the same CLI, vulture's first run reports:
src/mytool/cli.py:16: unused function 'show' (60% confidence)
src/mytool/report.py:32: unused function 'summarise' (60% confidence)
src/mytool/report.py:38: unused function 'legacy_format' (60% confidence)
Only one of these is actually dead. The other two are typical CLI false positives:
showis a Typer command. Nothing calls it by name; the@app.command()decorator registers it. vulture cannot see that registration.summariseis called by tests and by another package that imports this one as a library. vulture only looks at the paths you give it.
Handle command registrations with --ignore-decorators, which skips anything decorated with the listed decorators, and genuine external uses with a whitelist — a Python file that "uses" the names so vulture sees them:
# whitelist.py — names used from outside src/ (tests, other packages, entry points)
from mytool.report import summarise
summarise
uv run vulture --make-whitelist src generates a starting whitelist from the current findings; review it rather than accepting it wholesale, or you will whitelist the dead code too. With both in place, the configuration lives in pyproject.toml:
[tool.vulture]
paths = ["src", "whitelist.py"]
min_confidence = 60
ignore_decorators = ["@app.command", "@app.callback", "@*.command", "@pytest.fixture"]
Now uv run vulture reports only legacy_format, which is genuinely unused and can be deleted.
Confidence levels
vulture reports 100% confidence for things it is certain about — unreachable code after a return, unused imports — and 60% for unused functions, classes and attributes, which might be used dynamically. A threshold of 80 gives a quiet, high-precision report suitable for failing CI; 60 is useful for a periodic manual sweep. Ruff already covers unused imports and variables (F401, F841) more precisely, so vulture's value is in the larger units: functions, methods, classes and attributes.
UX considerations
The users of these tools are maintainers, and noisy tools get disabled:
- Start with a clean baseline. Fix or whitelist everything once, then fail CI on anything new. A tool that reports forty old findings on every run trains people to ignore it.
- Explain every exception. A comment next to each deptry ignore and each whitelist entry turns "why is this here?" into a one-second answer.
- Delete, do not comment out. Git remembers removed code; commented-out blocks just confuse vulture's next run and the next reader.
- Run deptry when dependencies change. It is fast enough for every pull request, and most valuable on the ones that touch
pyproject.toml. - Treat DEP003 as a bug. "It works because something else installs it" is exactly the kind of failure that appears only on a user's fresh machine.
Testing the behaviour
Both tools exit non-zero when they find something, so CI integration is two lines. Run them in the same job as the other linters, with the project environment installed:
# .github/workflows/lint.yml (excerpt)
- run: uv sync --locked
- run: uv run deptry src
- run: uv run vulture --min-confidence 80
Confirm once that the gate works: add an unused function and an unused dependency on a branch and check that the job fails with the expected messages. Combine this with a smoke test of the installed wheel in a clean environment, as in smoke-testing the built wheel in CI: deptry finds missing declarations statically, and the smoke test proves the result at runtime.
Conclusion
vulture and deptry keep a CLI honest about what it uses. Run deptry inside the project environment to catch undeclared, unused and merely transitive dependencies; run vulture with your framework's decorators ignored and a reviewed whitelist for external uses; configure both in pyproject.toml with documented exceptions; and gate CI on a clean baseline. Every unused dependency removed is a smaller install, a faster start and one less package in every security review — see reducing CLI dependency weight.
Frequently asked questions
Does Ruff replace vulture?
Partly. Ruff finds unused imports, variables and arguments within a file. It does not track whether a function defined in one module is used anywhere else in the project, which is vulture's main job.
Why does deptry flag a package my code clearly imports?
Usually because the import name differs from the distribution name and deptry could not see the installed package — run it inside the environment. If the mapping is genuinely unusual, [tool.deptry.package_module_name_map] declares it explicitly.
Can vulture understand plugins loaded by entry points?
No; entry points are strings in pyproject.toml. Add the plugin functions to the whitelist, or ignore the decorator your plugin system uses. See discovering plugins with entry points.
Should tests be scanned too?
Not with vulture — test functions are "unused" by design. Do run deptry on tests if they import packages that should be in a development group, so a test-only import that leaks into shipped code is reported as DEP004.
How do these tools work in a uv workspace?
Run deptry once per member package, from that package's directory, so it compares each package's imports with its own pyproject.toml — a dependency declared by a sibling package does not count. vulture can scan the whole workspace in one run, which is actually an advantage: a function in a shared library package that no CLI package uses shows up as dead code. uv workspaces for multi-package CLIs covers the layout.