Skip to main content

Python dead code analysis using libcst

Project description

dead-cst

PyPI Python License: MIT CI codecov Ruff uv

Python dead code analysis using libcst.

dead-cst builds a full symbol graph of your Python codebase, walks from your entrypoints, and reports (or removes) anything unreachable.

Pre-release software. dead-cst is in early alpha. APIs, CLI flags, and output formats may change without notice, and bugs are expected. Do not run dead-cst remove against code that isn't committed to version control.

Installation

pip install dead-cst

Or with uv:

uv add dead-cst

Quick start

# Find dead code in your project
dead-cst analyze ./src -e "re:.*__main__\.py"

# See why a symbol is kept alive
dead-cst why-alive ./src mypackage.some_module.some_function

# Remove dead code (interactive confirmation)
dead-cst remove ./src -e "re:.*__main__\.py"

# List third-party dependencies imported by the codebase
dead-cst dependencies ./src

CLI reference

dead-cst analyze

Analyze a Python codebase for dead code.

dead-cst analyze ROOT -e ENTRYPOINT [OPTIONS]
Option Description
-e, --entrypoint Entrypoint: file path, FQN, or re:pattern for regex (repeatable)
-p, --path Search path spec: base:dep1,dep2 or base (repeatable)
--resolver Path resolver to run, e.g. venv, pyproject (repeatable)
--plugin Edge plugin to run, e.g. main_block, project_scripts (repeatable)
--format Output format: text or json
-v, --verbose Enable verbose logging
--no-cache Bypass the per-file VisitorPayload cache

Exit code 1 if dead code is found, 0 otherwise.

dead-cst why-alive

Show why a symbol is considered alive by printing its predecessor chain.

dead-cst why-alive ROOT FQNAME [OPTIONS]
Option Description
-p, --path Search path spec: base:dep1,dep2 or base (repeatable)
--resolver Path resolver to run, e.g. venv, pyproject (repeatable)
--plugin Edge plugin to run, e.g. main_block, project_scripts (repeatable)
-v, --verbose Enable verbose logging
--no-cache Bypass the per-file VisitorPayload cache

dead-cst unused-exports

Report __all__ entries whose targets are only alive because of __all__. Useful in closed-world / monorepo settings to prune the public surface.

dead-cst unused-exports ROOT -e ENTRYPOINT [OPTIONS]
Option Description
-e, --entrypoint Entrypoint: file path, FQN, or re:pattern for regex (repeatable)
-p, --path Search path spec: base:dep1,dep2 or base (repeatable)
--resolver Path resolver to run, e.g. venv, pyproject (repeatable)
--plugin Edge plugin to run, e.g. main_block, project_scripts (repeatable)
-v, --verbose Enable verbose logging
--no-cache Bypass the per-file VisitorPayload cache

dead-cst dependencies

List third-party dependencies imported by the codebase. Each base path gets its own section. Distributions are reported as [external dist] <name>; files resolved inside site-packages without a matching distribution are reported as [external file] <name>.

dead-cst dependencies ROOT [OPTIONS]
Option Description
-p, --path Search path spec: base:dep1,dep2 or base (repeatable)
--resolver Path resolver to run, e.g. venv, pyproject (repeatable)
--format Output format: text or json
-v, --verbose Enable verbose logging
--no-cache Bypass the per-file VisitorPayload cache

dead-cst remove

Remove dead code from a Python codebase. Prompts for confirmation before modifying files.

dead-cst remove ROOT -e ENTRYPOINT [OPTIONS]
Option Description
-e, --entrypoint Entrypoint: file path, FQN, or re:pattern for regex (repeatable)
-p, --path Search path spec: base:dep1,dep2 or base (repeatable)
--resolver Path resolver to run, e.g. venv, pyproject (repeatable)
--plugin Edge plugin to run, e.g. main_block, project_scripts (repeatable)
-v, --verbose Enable verbose logging
--dry-run Show what would be removed without making changes
--no-cache Bypass the per-file VisitorPayload cache

dead-cst cache clear

Delete the on-disk VisitorPayload cache (<root>/.dead-cst-cache/) for a project. The cache is keyed by a fingerprint over the PathMap, resolver chain, and plugin set, so most layout changes invalidate it automatically; this command is for force-clearing when needed.

dead-cst cache clear [ROOT]

ROOT defaults to the current directory.

Python API

import re
from pathlib import Path
from dead_cst import (
    build_symbol_graph,
    ExplicitEntrypointPlugin,
    MainBlockPlugin,
    find_reachable,
    remove_code,
)

root = Path("./src")
graph = build_symbol_graph(
    {root: []},
    plugins=[
        MainBlockPlugin(),
        ExplicitEntrypointPlugin(specs=[re.compile(r".*__main__\.py")]),
    ],
    project_root=root,
)
reachable = find_reachable(graph)

unreachable = graph.subgraph([n for n in graph.nodes if n not in reachable])
# Inspect unreachable nodes, or remove them:
remove_code(unreachable, root)

Entrypoint detection is now fully plugin-driven. Builtins:

Plugin Purpose
MainBlockPlugin Mark modules containing if __name__ == "__main__": as entrypoints
ProjectScriptsPlugin Read pyproject.toml [project.scripts] and mark each target as an entrypoint
ExplicitEntrypointPlugin Match user-supplied file paths / FQNs / regexes (powers the -e flag)
ModuleDundersPlugin Keep top-level dunder variables (__all__, __version__, etc.) alive (always on)
PytestPlugin Keep pytest-discovered tests, conftest.py decls, and @pytest.fixture functions alive (--plugin pytest)
UnittestPlugin Keep stdlib unittest.TestCase / IsolatedAsyncioTestCase subclasses and setUpModule / tearDownModule / load_tests hooks alive (--plugin unittest)
FastAPIPlugin Detect top-level FastAPI() / APIRouter() instances; mark FastAPI apps as entrypoints and add instance -> handler edges for every @app.get(...)-style decorator (HTTP methods, websockets, middleware, exception handlers, on_event). Routers stay pass-through, so an APIRouter that's never include_router'd remains dead (--plugin fastapi)
FlaskPlugin Detect top-level Flask() / Blueprint() instances; mark Flask apps as entrypoints and add instance -> handler edges for every @app.route(...) / @app.get(...) / lifecycle / errorhandler / template-helper / URL-processor decorator. Blueprints stay pass-through, so a Blueprint that's never register_blueprint'd remains dead (--plugin flask)
TyperPlugin Detect top-level Typer() instances and add instance -> handler edges for every @app.command(...) / @app.callback(...) decorator. Typer apps are pass-through (reach them via [project.scripts] or if __name__ == "__main__": app()), so a sub-typer that's never add_typer'd stays dead (--plugin typer)
ClickPlugin Detect top-level Click Group instances (functions decorated @click.group(...) or X = click.Group(...)) and add instance -> handler edges for every @cli.command(...) / @cli.group(...) / @cli.result_callback(...) decorator. Groups are pass-through (reach them via [project.scripts] or a __main__ block), so a sub-group that's never add_command'd stays dead (--plugin click)
InitSubclassPlugin Detect classes that define __init_subclass__ and add parent -> subclass edges for every (transitive) first-party subclass. Parents stay pass-through, so a registry base class only keeps subclasses alive once something else (an entrypoint, an import) keeps the parent alive (--plugin init_subclass)

Write your own by implementing the EdgePlugin or CSTAwareEdgePlugin protocol; register under the dead_cst.plugins entry-point group for CLI discovery.

Path resolution is similarly pluggable. PathResolver implementations return a {base: [dep_paths]} map to feed build_symbol_graph. Builtins: VenvResolver, PyprojectResolver, UvWorkspaceResolver (parses uv.lock to discover workspace members and their inter-member dep edges). Third-party resolvers register under dead_cst.resolvers.

Graph model

The graph has one node per top-level declaration plus a synthetic module node per file. Edges run from a declaration to each symbol it references, and from every submodule to its parent package so __init__.py stays alive as long as anything in the package does. Entrypoints seed the reachability walk; every node not reached is reported as dead.

A module-level import / from ... import ... is itself a declaration of type "import" in the current module. Uses of the imported name inside the file are wired through that local import node, and the import node in turn points at the upstream module (and, when applicable, at the specific imported symbol). Removing the last local use therefore makes the import itself dead, which is how dead-cst remove knows to drop now-unused import lines.

Scope

dead-cst tracks top-level declarations only -- module-level functions, classes, and variables. Nested definitions (inner functions, methods, nested classes) are deliberately not given their own nodes; references made from inside those nested scopes are attributed to the enclosing top-level declaration. Keeping the containing top-level symbol alive keeps its nested source alive with it.

Limitations

  • import * is treated pessimistically: every top-level declaration in the target module is considered used by the importing module.
  • Dynamic attribute access (getattr) and runtime-generated symbols are invisible to static analysis.
  • Only first-party code is analysed; third-party dependencies are treated as opaque (they appear as synthetic nodes — see dead-cst dependencies).
  • PEP 695 type statements are not tracked.
  • __all__ is followed only when assigned a list/tuple of string literals; dynamic mutation (__all__.append, comprehensions, etc.) is not tracked.

Development

git clone https://github.com/lpetre/dead-cst
cd dead-cst
uv sync
uv run pytest
uv run prek run --all-files

See CONTRIBUTING.md for the full dev guide, CHANGELOG.md for release notes, and ROADMAP.md for the stack-ranked plan toward 1.0.

TODO

  • Host API documentation on Read the Docs.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dead_cst-0.2.0.tar.gz (192.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dead_cst-0.2.0-py3-none-any.whl (90.5 kB view details)

Uploaded Python 3

File details

Details for the file dead_cst-0.2.0.tar.gz.

File metadata

  • Download URL: dead_cst-0.2.0.tar.gz
  • Upload date:
  • Size: 192.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dead_cst-0.2.0.tar.gz
Algorithm Hash digest
SHA256 d3048d1e609800cd53cf7eb7c094c70ad58f46bed02be30a82156b36291329c1
MD5 82fc8aadd0d6b1c2230b08b56f688044
BLAKE2b-256 deef0c2c2ce5d36f5935a60950fa31acd66e6002c15e1cbcff44ff187c7b9749

See more details on using hashes here.

Provenance

The following attestation bundles were made for dead_cst-0.2.0.tar.gz:

Publisher: publish.yml on lpetre/dead-cst

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dead_cst-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: dead_cst-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 90.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dead_cst-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e17367f6c98d8462deb1fe80bbef9df3c9a285c8fa30cafd12f55f91373933aa
MD5 e47f8445a4eecdcc33afabe14ad2913d
BLAKE2b-256 8881a54d07affbc82ec15f5c52205cb13a519b94d91098d40115df649bca35f4

See more details on using hashes here.

Provenance

The following attestation bundles were made for dead_cst-0.2.0-py3-none-any.whl:

Publisher: publish.yml on lpetre/dead-cst

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page