dead-cst 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dead_cst/__init__.py +99 -0
- dead_cst/_analyze.py +258 -0
- dead_cst/_branches.py +203 -0
- dead_cst/_codemod.py +135 -0
- dead_cst/_flow.py +173 -0
- dead_cst/_fqn.py +30 -0
- dead_cst/_plugins/__init__.py +106 -0
- dead_cst/_plugins/_core.py +524 -0
- dead_cst/_plugins/click.py +181 -0
- dead_cst/_plugins/explicit.py +58 -0
- dead_cst/_plugins/fastapi.py +154 -0
- dead_cst/_plugins/flask.py +172 -0
- dead_cst/_plugins/init_subclass.py +296 -0
- dead_cst/_plugins/main_block.py +72 -0
- dead_cst/_plugins/module_dunders.py +55 -0
- dead_cst/_plugins/project_scripts.py +60 -0
- dead_cst/_plugins/pytest.py +127 -0
- dead_cst/_plugins/typer.py +101 -0
- dead_cst/_plugins/unittest.py +171 -0
- dead_cst/_resolve.py +232 -0
- dead_cst/_resolvers/__init__.py +58 -0
- dead_cst/_resolvers/_core.py +52 -0
- dead_cst/_resolvers/_exports.py +111 -0
- dead_cst/_resolvers/pyproject.py +49 -0
- dead_cst/_resolvers/uv_workspace.py +104 -0
- dead_cst/_resolvers/venv.py +117 -0
- dead_cst/_symbols.py +177 -0
- dead_cst/_version.py +24 -0
- dead_cst/_visitor.py +542 -0
- dead_cst/cli.py +556 -0
- dead_cst/py.typed +0 -0
- dead_cst-0.1.0.dist-info/METADATA +223 -0
- dead_cst-0.1.0.dist-info/RECORD +36 -0
- dead_cst-0.1.0.dist-info/WHEEL +4 -0
- dead_cst-0.1.0.dist-info/entry_points.txt +2 -0
- dead_cst-0.1.0.dist-info/licenses/LICENSE +21 -0
dead_cst/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Dead code analysis for Python using LibCST.
|
|
2
|
+
|
|
3
|
+
`dead-cst` builds a symbol-level reachability graph of a Python codebase and
|
|
4
|
+
reports (or removes) anything not reachable from a configurable set of
|
|
5
|
+
entrypoints.
|
|
6
|
+
|
|
7
|
+
The public API has three layers:
|
|
8
|
+
|
|
9
|
+
* :func:`build_symbol_graph` parses every ``.py`` file under each base in a
|
|
10
|
+
``{base: [dep_paths]}`` map and returns a :class:`networkx.DiGraph` of
|
|
11
|
+
:class:`SymbolNode` instances. Edges encode "keeps alive" relationships.
|
|
12
|
+
* Edge plugins (:class:`MainBlockPlugin`, :class:`ProjectScriptsPlugin`,
|
|
13
|
+
:class:`ExplicitEntrypointPlugin`, :class:`ModuleDundersPlugin`,
|
|
14
|
+
:class:`PytestPlugin`, :class:`UnittestPlugin`, :class:`FastAPIPlugin`,
|
|
15
|
+
:class:`FlaskPlugin`, :class:`TyperPlugin`, :class:`ClickPlugin`)
|
|
16
|
+
extend the graph with edges that pure CST analysis can't infer --
|
|
17
|
+
entry points, framework conventions, dynamic dispatch. Custom
|
|
18
|
+
plugins implement the :class:`EdgePlugin` protocol.
|
|
19
|
+
* Path resolvers (:class:`VenvResolver`, :class:`PyprojectResolver`,
|
|
20
|
+
:class:`UvWorkspaceResolver`) discover the ``{base: [dep_paths]}`` map
|
|
21
|
+
itself from a project root, so callers don't have to hand-build it.
|
|
22
|
+
|
|
23
|
+
After plugins run, :func:`find_reachable` walks successors from every node
|
|
24
|
+
tagged with ``entrypoint=True`` and returns the live set;
|
|
25
|
+
:func:`remove_code` rewrites source files in place to delete the rest via
|
|
26
|
+
a LibCST codemod that preserves surrounding formatting.
|
|
27
|
+
|
|
28
|
+
See the README for the CLI, the entrypoint and search-path specs, and the
|
|
29
|
+
limitations of static analysis.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from ._analyze import build_symbol_graph, count_nodes, find_reachable, order_paths
|
|
33
|
+
from ._codemod import remove_code
|
|
34
|
+
from ._plugins import (
|
|
35
|
+
AddEdge,
|
|
36
|
+
AddNode,
|
|
37
|
+
BUILTIN_PLUGINS,
|
|
38
|
+
ClickPlugin,
|
|
39
|
+
EdgePlugin,
|
|
40
|
+
ExplicitEntrypointPlugin,
|
|
41
|
+
FastAPIPlugin,
|
|
42
|
+
FlaskPlugin,
|
|
43
|
+
GraphOp,
|
|
44
|
+
InitSubclassPlugin,
|
|
45
|
+
MainBlockPlugin,
|
|
46
|
+
ModuleDundersPlugin,
|
|
47
|
+
PluginContext,
|
|
48
|
+
ProjectScriptsPlugin,
|
|
49
|
+
PytestPlugin,
|
|
50
|
+
RemoveEdge,
|
|
51
|
+
TyperPlugin,
|
|
52
|
+
UnittestPlugin,
|
|
53
|
+
load_plugin,
|
|
54
|
+
)
|
|
55
|
+
from ._resolvers import (
|
|
56
|
+
BUILTIN_RESOLVERS,
|
|
57
|
+
PathResolver,
|
|
58
|
+
PyprojectResolver,
|
|
59
|
+
UvWorkspaceResolver,
|
|
60
|
+
VenvResolver,
|
|
61
|
+
load_resolver,
|
|
62
|
+
merge_paths,
|
|
63
|
+
)
|
|
64
|
+
from ._version import __version__
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"__version__",
|
|
68
|
+
"AddEdge",
|
|
69
|
+
"AddNode",
|
|
70
|
+
"BUILTIN_PLUGINS",
|
|
71
|
+
"BUILTIN_RESOLVERS",
|
|
72
|
+
"ClickPlugin",
|
|
73
|
+
"EdgePlugin",
|
|
74
|
+
"ExplicitEntrypointPlugin",
|
|
75
|
+
"FastAPIPlugin",
|
|
76
|
+
"FlaskPlugin",
|
|
77
|
+
"GraphOp",
|
|
78
|
+
"InitSubclassPlugin",
|
|
79
|
+
"MainBlockPlugin",
|
|
80
|
+
"ModuleDundersPlugin",
|
|
81
|
+
"PathResolver",
|
|
82
|
+
"PluginContext",
|
|
83
|
+
"ProjectScriptsPlugin",
|
|
84
|
+
"PyprojectResolver",
|
|
85
|
+
"PytestPlugin",
|
|
86
|
+
"RemoveEdge",
|
|
87
|
+
"TyperPlugin",
|
|
88
|
+
"UnittestPlugin",
|
|
89
|
+
"UvWorkspaceResolver",
|
|
90
|
+
"VenvResolver",
|
|
91
|
+
"build_symbol_graph",
|
|
92
|
+
"count_nodes",
|
|
93
|
+
"find_reachable",
|
|
94
|
+
"load_plugin",
|
|
95
|
+
"load_resolver",
|
|
96
|
+
"merge_paths",
|
|
97
|
+
"order_paths",
|
|
98
|
+
"remove_code",
|
|
99
|
+
]
|
dead_cst/_analyze.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
import libcst as cst
|
|
8
|
+
import networkx as nx
|
|
9
|
+
from libcst.metadata import FullRepoManager
|
|
10
|
+
|
|
11
|
+
from ._fqn import FixedFullyQualifiedNameProvider
|
|
12
|
+
from ._plugins import (
|
|
13
|
+
EdgePlugin,
|
|
14
|
+
PluginContext,
|
|
15
|
+
apply_ops,
|
|
16
|
+
)
|
|
17
|
+
from ._resolve import resolve_edges, safe_resolve_module, temp_sys_path
|
|
18
|
+
from ._resolvers import PathMap, exported_roots
|
|
19
|
+
from ._symbols import SymbolNode, SymbolTrie
|
|
20
|
+
from ._visitor import SymbolVisitor
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def order_paths(paths: PathMap) -> list[Path]:
|
|
26
|
+
"""Topologically sort base paths so dependencies are processed first.
|
|
27
|
+
|
|
28
|
+
``paths`` maps each base directory to the list of other base directories
|
|
29
|
+
it imports from (added to ``sys.path`` while it is processed). The
|
|
30
|
+
returned order ensures that when a base is processed every base it
|
|
31
|
+
depends on has already contributed its symbols to the lookup tables, so
|
|
32
|
+
cross-package import resolution sees them.
|
|
33
|
+
"""
|
|
34
|
+
path_order = nx.DiGraph()
|
|
35
|
+
for base, search_paths in paths.items():
|
|
36
|
+
path_order.add_node(base)
|
|
37
|
+
for sp in search_paths:
|
|
38
|
+
path_order.add_edge(sp, base)
|
|
39
|
+
return list(nx.topological_sort(path_order))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_symbol_graph(
|
|
43
|
+
paths: PathMap,
|
|
44
|
+
*,
|
|
45
|
+
plugins: Sequence[EdgePlugin] = (),
|
|
46
|
+
project_root: Path | None = None,
|
|
47
|
+
) -> nx.DiGraph:
|
|
48
|
+
"""Build a directed reachability graph of every top-level symbol under ``paths``.
|
|
49
|
+
|
|
50
|
+
Each ``.py`` file under each base in ``paths`` is parsed with LibCST;
|
|
51
|
+
modules, classes, functions, top-level variables, and module-level imports
|
|
52
|
+
become :class:`SymbolNode` graph nodes. Edges encode "keeps alive"
|
|
53
|
+
relationships:
|
|
54
|
+
|
|
55
|
+
* a reference points at its referent,
|
|
56
|
+
* a declaration points at its containing module,
|
|
57
|
+
* a submodule points at its parent package, and
|
|
58
|
+
* synthetic ``unreachable`` nodes own edges into symbols referenced from
|
|
59
|
+
statically-dead suites (``if False:``, ``raise``-only branches, ...) so
|
|
60
|
+
those references don't keep the targets alive.
|
|
61
|
+
|
|
62
|
+
Third-party imports are surfaced as synthetic ``[external dist] <name>``
|
|
63
|
+
/ ``[external file] <name>`` nodes so callers can audit the
|
|
64
|
+
project's dependency surface (see the ``dependencies`` CLI command).
|
|
65
|
+
|
|
66
|
+
Plugins run once per base in topological order, after that base's import
|
|
67
|
+
edges have been resolved. Each plugin invocation gets a per-base
|
|
68
|
+
:class:`PluginContext` whose parsed-module cache is primed with the
|
|
69
|
+
modules the analyzer just walked, so plugins never re-read or re-parse.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
paths:
|
|
74
|
+
Mapping from base directory to its first-party search-path
|
|
75
|
+
dependencies. For a single-package project, pass ``{root: []}``. For
|
|
76
|
+
a monorepo, list the dependencies so they're added to ``sys.path``
|
|
77
|
+
and resolved as first-party. ``order_paths`` orders the bases.
|
|
78
|
+
plugins:
|
|
79
|
+
Sequence of :class:`EdgePlugin` instances. Plugins emit
|
|
80
|
+
:class:`AddNode`, :class:`AddEdge`, and :class:`RemoveEdge` ops;
|
|
81
|
+
``AddNode(..., entrypoint=True)`` seeds :func:`find_reachable`.
|
|
82
|
+
project_root:
|
|
83
|
+
Project root used by plugins for path-relative matching. If
|
|
84
|
+
omitted, inferred as the shortest path in ``paths``.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
networkx.DiGraph
|
|
89
|
+
Nodes are :class:`SymbolNode` instances; entrypoint seeds carry
|
|
90
|
+
``graph.nodes[node]["entrypoint"] = True``.
|
|
91
|
+
"""
|
|
92
|
+
symbol_graph = nx.DiGraph()
|
|
93
|
+
base_tries: dict[Path, SymbolTrie] = {}
|
|
94
|
+
export_tries: dict[Path, SymbolTrie] = {}
|
|
95
|
+
root = project_root or _infer_project_root(paths) if paths else Path.cwd()
|
|
96
|
+
for base in order_paths(paths):
|
|
97
|
+
logger.debug("Processing base path: %s", base)
|
|
98
|
+
search_paths = [base] + paths.get(base, [])
|
|
99
|
+
safe_resolve_module.cache_clear()
|
|
100
|
+
with temp_sys_path(search_paths):
|
|
101
|
+
base_tries[base] = current_trie = SymbolTrie()
|
|
102
|
+
export_trie = SymbolTrie()
|
|
103
|
+
export_roots = exported_roots(base)
|
|
104
|
+
import_edges = set()
|
|
105
|
+
files = list(sorted(base.rglob("*.py")))
|
|
106
|
+
mgr = FullRepoManager(
|
|
107
|
+
str(base), [str(f) for f in files], {FixedFullyQualifiedNameProvider}
|
|
108
|
+
)
|
|
109
|
+
# Stash the modules the visitor pass parses so plugins can reuse
|
|
110
|
+
# them via ``ctx.parse(path)`` without re-reading or re-parsing.
|
|
111
|
+
base_modules: dict[Path, cst.Module] | None = {} if plugins else None
|
|
112
|
+
for file in files:
|
|
113
|
+
wrapper = mgr.get_metadata_wrapper_for_path(str(file))
|
|
114
|
+
if base_modules is not None:
|
|
115
|
+
base_modules[file] = wrapper.module
|
|
116
|
+
visitor = SymbolVisitor(file, search_paths)
|
|
117
|
+
wrapper.visit(visitor)
|
|
118
|
+
# A file's decls go into ``export_trie`` only when the file
|
|
119
|
+
# lives under one of ``base``'s exported dirs (or when the
|
|
120
|
+
# base has no export restriction). This is what hides
|
|
121
|
+
# ``tests/`` from dependents in the typical workspace
|
|
122
|
+
# layout: the member analyzes its own ``tests/`` (decls
|
|
123
|
+
# land in ``current_trie`` and the graph), but consumers'
|
|
124
|
+
# lookup tries never see them.
|
|
125
|
+
file_exported = export_roots is None or _under_any(file, export_roots)
|
|
126
|
+
curr = [visitor.trie]
|
|
127
|
+
while curr:
|
|
128
|
+
node = curr.pop()
|
|
129
|
+
if node.module is not None:
|
|
130
|
+
symbol_graph.add_node(node.module)
|
|
131
|
+
current_trie.add_declaration(node.module)
|
|
132
|
+
if file_exported:
|
|
133
|
+
export_trie.add_declaration(node.module)
|
|
134
|
+
for decls in node.declarations.values():
|
|
135
|
+
for decl in decls:
|
|
136
|
+
symbol_graph.add_node(decl)
|
|
137
|
+
symbol_graph.add_edge(decl, node.module)
|
|
138
|
+
current_trie.add_declaration(decl)
|
|
139
|
+
if file_exported:
|
|
140
|
+
export_trie.add_declaration(decl)
|
|
141
|
+
# Shadowed decls still belong to this module: emit them
|
|
142
|
+
# so the graph stays well-formed (every decl has a
|
|
143
|
+
# parent-module edge). They aren't re-added to
|
|
144
|
+
# ``current_trie`` -- only live-at-exit decls should
|
|
145
|
+
# win project-wide lookups.
|
|
146
|
+
for decl in node.shadowed:
|
|
147
|
+
symbol_graph.add_node(decl)
|
|
148
|
+
symbol_graph.add_edge(decl, node.module)
|
|
149
|
+
curr.extend(node.children.values())
|
|
150
|
+
|
|
151
|
+
for src, dst in visitor.internal_edges:
|
|
152
|
+
symbol_graph.add_edge(src, dst)
|
|
153
|
+
|
|
154
|
+
# Synthetic ``unreachable`` nodes for statically-dead suites.
|
|
155
|
+
# They live in the graph as orphan sources -- nothing points
|
|
156
|
+
# at them, so reachability never visits them, but the edges
|
|
157
|
+
# they own surface which symbols are referenced from inside
|
|
158
|
+
# dead branches. Existing top-level decl edges are unchanged.
|
|
159
|
+
for unreachable in visitor.unreachable_nodes:
|
|
160
|
+
symbol_graph.add_node(unreachable)
|
|
161
|
+
for src, dst in visitor.unreachable_internal_edges:
|
|
162
|
+
symbol_graph.add_edge(src, dst)
|
|
163
|
+
|
|
164
|
+
import_edges |= visitor.import_edges | visitor.unreachable_import_edges
|
|
165
|
+
|
|
166
|
+
current_trie.add_module_hierarchy_edges(symbol_graph)
|
|
167
|
+
export_tries[base] = export_trie
|
|
168
|
+
|
|
169
|
+
# Per-consumer lookup trie: this base's full trie (everything
|
|
170
|
+
# in scope when resolving its own imports) plus each dep's
|
|
171
|
+
# *exported* trie (what the dep ships to consumers). Deps are
|
|
172
|
+
# processed earlier by topological order, so their export
|
|
173
|
+
# tries already exist.
|
|
174
|
+
symbol_lookup = SymbolTrie()
|
|
175
|
+
symbol_lookup.merge(current_trie)
|
|
176
|
+
for dep in paths.get(base, []):
|
|
177
|
+
symbol_lookup.merge(export_tries.get(dep, base_tries[dep]))
|
|
178
|
+
|
|
179
|
+
for src, dst in resolve_edges(import_edges, symbol_lookup, base):
|
|
180
|
+
symbol_graph.add_edge(src, dst)
|
|
181
|
+
|
|
182
|
+
# Plugin pass for this base, with the symbol lookup that was
|
|
183
|
+
# valid at this base's resolution time and the parsed modules
|
|
184
|
+
# we just walked. The context (and ``base_modules``) goes out
|
|
185
|
+
# of scope once we move to the next base, so the parsed CSTs
|
|
186
|
+
# for this base can be GC'd.
|
|
187
|
+
if plugins:
|
|
188
|
+
ctx = PluginContext(
|
|
189
|
+
graph=symbol_graph,
|
|
190
|
+
symbol_lookup=symbol_lookup,
|
|
191
|
+
base=base,
|
|
192
|
+
project_root=root,
|
|
193
|
+
)
|
|
194
|
+
if base_modules is not None:
|
|
195
|
+
for path, module in base_modules.items():
|
|
196
|
+
ctx.prime_module(path, module)
|
|
197
|
+
for plugin in plugins:
|
|
198
|
+
if not isinstance(plugin, EdgePlugin):
|
|
199
|
+
raise TypeError(f"Plugin {plugin!r} does not satisfy EdgePlugin protocol")
|
|
200
|
+
# Materialize before applying so plugins can iterate
|
|
201
|
+
# ctx.graph.nodes without tripping "dictionary changed
|
|
202
|
+
# size during iteration".
|
|
203
|
+
ops = list(plugin.contribute(ctx))
|
|
204
|
+
apply_ops(symbol_graph, ops)
|
|
205
|
+
|
|
206
|
+
return symbol_graph
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _under_any(file: Path, roots: list[Path]) -> bool:
|
|
210
|
+
"""True iff ``file`` is equal to or nested under any of ``roots``."""
|
|
211
|
+
f = file.resolve()
|
|
212
|
+
for r in roots:
|
|
213
|
+
if f == r or f.is_relative_to(r):
|
|
214
|
+
return True
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _infer_project_root(paths: PathMap) -> Path:
|
|
219
|
+
bases = list(paths)
|
|
220
|
+
if not bases:
|
|
221
|
+
return Path.cwd()
|
|
222
|
+
return min(bases, key=lambda p: len(p.parts))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def find_reachable(graph: nx.DiGraph) -> set[SymbolNode]:
|
|
226
|
+
"""BFS forward from every node tagged as an entrypoint by a plugin.
|
|
227
|
+
|
|
228
|
+
Plugins mark seeds by setting ``graph.nodes[node]["entrypoint"] = True``
|
|
229
|
+
(see :func:`dead_cst._plugins.apply_ops`). There is no longer any
|
|
230
|
+
built-in matching against file paths or FQNs -- that lives in
|
|
231
|
+
:class:`ExplicitEntrypointPlugin`.
|
|
232
|
+
"""
|
|
233
|
+
visited: set[SymbolNode] = set()
|
|
234
|
+
stack = [n for n, attrs in graph.nodes(data=True) if attrs.get("entrypoint")]
|
|
235
|
+
while stack:
|
|
236
|
+
node = stack.pop()
|
|
237
|
+
if node in visited:
|
|
238
|
+
continue
|
|
239
|
+
visited.add(node)
|
|
240
|
+
stack.extend(graph.successors(node))
|
|
241
|
+
return visited
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def count_nodes(graph: nx.DiGraph, prefix: Path | None) -> dict[str, int]:
|
|
245
|
+
"""Count nodes in ``graph`` by ``SymbolNode.type``, optionally restricted by path.
|
|
246
|
+
|
|
247
|
+
If ``prefix`` is given, only nodes whose ``path`` is under ``prefix`` are
|
|
248
|
+
counted -- useful for per-base summaries when several packages are
|
|
249
|
+
analysed together. Includes the synthetic ``"synthetic"`` type contributed
|
|
250
|
+
by plugins and third-party-dep markers; the CLI suppresses that key when
|
|
251
|
+
rendering summaries.
|
|
252
|
+
"""
|
|
253
|
+
counts = {}
|
|
254
|
+
for node in graph.nodes:
|
|
255
|
+
if prefix and not node.path.is_relative_to(prefix):
|
|
256
|
+
continue
|
|
257
|
+
counts[node.type] = counts.get(node.type, 0) + 1
|
|
258
|
+
return counts
|
dead_cst/_branches.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Static evaluator for conditional truthiness.
|
|
2
|
+
|
|
3
|
+
Determines, when possible, whether the truthiness of an expression is
|
|
4
|
+
statically known. Used to identify dead branches of ``if`` / ``while``
|
|
5
|
+
statements so callers can prune references that live inside them.
|
|
6
|
+
|
|
7
|
+
Only handles a small whitelist of literal forms: the ``True`` /
|
|
8
|
+
``False`` / ``None`` keywords, integer / string literals,
|
|
9
|
+
empty-vs-non-empty collection literals, and ``not`` / ``and`` / ``or``
|
|
10
|
+
over those. Anything involving a name lookup (other than the three
|
|
11
|
+
keywords), attribute access, function call, comparison, or other
|
|
12
|
+
dynamic operation returns ``None`` (unknown). Returning ``None`` is
|
|
13
|
+
always the safe default: callers must treat the branch as live.
|
|
14
|
+
|
|
15
|
+
This module also owns the convention for synthetic graph nodes that
|
|
16
|
+
represent dead suites. The nodes have ``type="synthetic"`` (the
|
|
17
|
+
existing escape hatch in :mod:`dead_cst._symbols`) and a fqname
|
|
18
|
+
prefixed with :data:`UNREACHABLE_PREFIX` (``<unreachable>:``).
|
|
19
|
+
``is_unreachable_node`` is the single source of truth for that
|
|
20
|
+
convention; consumers should never check the prefix string themselves.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Sequence
|
|
27
|
+
|
|
28
|
+
import libcst as cst
|
|
29
|
+
from libcst.metadata import CodeRange
|
|
30
|
+
|
|
31
|
+
from ._symbols import SymbolNode
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_KEYWORDS: dict[str, bool] = {
|
|
35
|
+
"True": True,
|
|
36
|
+
"False": False,
|
|
37
|
+
"None": False,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
UNREACHABLE_PREFIX = "<unreachable>:"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def evaluate_truthiness(node: cst.BaseExpression) -> bool | None:
|
|
44
|
+
"""Best-effort static truthiness for ``node``.
|
|
45
|
+
|
|
46
|
+
Returns ``True`` / ``False`` if the value's truthiness is statically
|
|
47
|
+
determinable, ``None`` otherwise. Never raises.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(node, cst.Name):
|
|
50
|
+
return _KEYWORDS.get(node.value)
|
|
51
|
+
|
|
52
|
+
if isinstance(node, cst.Integer):
|
|
53
|
+
try:
|
|
54
|
+
return int(node.value, 0) != 0
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
if isinstance(node, (cst.SimpleString, cst.ConcatenatedString)):
|
|
59
|
+
try:
|
|
60
|
+
value = node.evaluated_value
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
if value is None:
|
|
64
|
+
return None
|
|
65
|
+
return bool(value)
|
|
66
|
+
|
|
67
|
+
if isinstance(node, (cst.Tuple, cst.List, cst.Set)):
|
|
68
|
+
for elt in node.elements:
|
|
69
|
+
if isinstance(elt, cst.StarredElement):
|
|
70
|
+
return None
|
|
71
|
+
return len(node.elements) > 0
|
|
72
|
+
|
|
73
|
+
if isinstance(node, cst.Dict):
|
|
74
|
+
for elt in node.elements:
|
|
75
|
+
if isinstance(elt, cst.StarredDictElement):
|
|
76
|
+
return None
|
|
77
|
+
return len(node.elements) > 0
|
|
78
|
+
|
|
79
|
+
if isinstance(node, cst.UnaryOperation) and isinstance(node.operator, cst.Not):
|
|
80
|
+
inner = evaluate_truthiness(node.expression)
|
|
81
|
+
return None if inner is None else not inner
|
|
82
|
+
|
|
83
|
+
if isinstance(node, cst.BooleanOperation):
|
|
84
|
+
left = evaluate_truthiness(node.left)
|
|
85
|
+
if isinstance(node.operator, cst.And):
|
|
86
|
+
if left is False:
|
|
87
|
+
return False
|
|
88
|
+
right = evaluate_truthiness(node.right)
|
|
89
|
+
if right is False:
|
|
90
|
+
return False
|
|
91
|
+
if left is True and right is True:
|
|
92
|
+
return True
|
|
93
|
+
return None
|
|
94
|
+
if isinstance(node.operator, cst.Or):
|
|
95
|
+
if left is True:
|
|
96
|
+
return True
|
|
97
|
+
right = evaluate_truthiness(node.right)
|
|
98
|
+
if right is True:
|
|
99
|
+
return True
|
|
100
|
+
if left is False and right is False:
|
|
101
|
+
return False
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def unreachable_suites(stmt: cst.BaseStatement) -> list[cst.BaseSuite]:
|
|
108
|
+
"""Return every dead suite inside ``stmt``.
|
|
109
|
+
|
|
110
|
+
Supports ``cst.If`` (including ``elif`` / ``else`` chains) and
|
|
111
|
+
``cst.While``. Returns ``[]`` for any other statement type or when
|
|
112
|
+
no branch can be shown to be unreachable.
|
|
113
|
+
|
|
114
|
+
Returns the suite nodes themselves so callers that need source
|
|
115
|
+
positions (e.g. the visitor) can read them off the node.
|
|
116
|
+
"""
|
|
117
|
+
if isinstance(stmt, cst.If):
|
|
118
|
+
return _unreachable_in_if(stmt, branch_taken=False)
|
|
119
|
+
if isinstance(stmt, cst.While):
|
|
120
|
+
truth = evaluate_truthiness(stmt.test)
|
|
121
|
+
if truth is False:
|
|
122
|
+
return [stmt.body]
|
|
123
|
+
# ``while True:`` exits only via break / return / exception, so
|
|
124
|
+
# the ``else`` clause (which fires on normal exit) never runs.
|
|
125
|
+
if truth is True and stmt.orelse is not None:
|
|
126
|
+
return [stmt.orelse.body]
|
|
127
|
+
return []
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def unreachable_bodies(stmt: cst.BaseStatement) -> list[Sequence[cst.CSTNode]]:
|
|
132
|
+
"""Return the ``.body`` of every dead suite inside ``stmt``.
|
|
133
|
+
|
|
134
|
+
Thin wrapper over :func:`unreachable_suites` for callers that only
|
|
135
|
+
need the statement list, not the enclosing suite. Each entry is
|
|
136
|
+
typed as ``Sequence[cst.CSTNode]`` because libcst's
|
|
137
|
+
``BaseSuite.body`` may be ``Sequence[BaseStatement]`` (an indented
|
|
138
|
+
block) or ``Sequence[BaseSmallStatement]`` (a one-line suite like
|
|
139
|
+
``if False: x = 1``).
|
|
140
|
+
"""
|
|
141
|
+
return [suite.body for suite in unreachable_suites(stmt)]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unreachable_in_if(node: cst.If, branch_taken: bool) -> list[cst.BaseSuite]:
|
|
145
|
+
"""Walk an ``if`` / ``elif`` / ``else`` chain collecting dead suites.
|
|
146
|
+
|
|
147
|
+
``branch_taken`` is ``True`` when an earlier branch in the chain is
|
|
148
|
+
known to fire; everything from this point on is then unreachable.
|
|
149
|
+
"""
|
|
150
|
+
dead: list[cst.BaseSuite] = []
|
|
151
|
+
truth = None if branch_taken else evaluate_truthiness(node.test)
|
|
152
|
+
|
|
153
|
+
if branch_taken or truth is False:
|
|
154
|
+
dead.append(node.body)
|
|
155
|
+
|
|
156
|
+
next_taken = branch_taken or truth is True
|
|
157
|
+
|
|
158
|
+
orelse = node.orelse
|
|
159
|
+
if orelse is None:
|
|
160
|
+
return dead
|
|
161
|
+
if isinstance(orelse, cst.Else):
|
|
162
|
+
if next_taken:
|
|
163
|
+
dead.append(orelse.body)
|
|
164
|
+
return dead
|
|
165
|
+
dead.extend(_unreachable_in_if(orelse, next_taken))
|
|
166
|
+
return dead
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def make_unreachable_fqname(module_fqname: str, position: CodeRange) -> str:
|
|
170
|
+
"""Build the fqname for the synthetic node representing a dead suite.
|
|
171
|
+
|
|
172
|
+
The format is intentionally fixed: callers should use
|
|
173
|
+
:func:`is_unreachable_node` to identify these nodes rather than
|
|
174
|
+
string-matching on their fqnames.
|
|
175
|
+
"""
|
|
176
|
+
return f"{UNREACHABLE_PREFIX}{module_fqname}:{position.start.line}:{position.start.column}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def make_unreachable_node(module_fqname: str, path: Path, position: CodeRange) -> SymbolNode:
|
|
180
|
+
"""Build the synthetic ``SymbolNode`` for a dead suite.
|
|
181
|
+
|
|
182
|
+
Reuses ``type="synthetic"`` -- the existing escape hatch for graph
|
|
183
|
+
nodes that don't correspond to a top-level declaration -- and
|
|
184
|
+
distinguishes itself by carrying a real source ``position`` (rather
|
|
185
|
+
than the ``SYNTHETIC_POSITION`` sentinel used by entrypoint plugins)
|
|
186
|
+
plus the :data:`UNREACHABLE_PREFIX` fqname prefix.
|
|
187
|
+
"""
|
|
188
|
+
return SymbolNode(
|
|
189
|
+
fqname=make_unreachable_fqname(module_fqname, position),
|
|
190
|
+
type="synthetic",
|
|
191
|
+
path=path,
|
|
192
|
+
position=position,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_unreachable_node(sym: SymbolNode) -> bool:
|
|
197
|
+
"""Predicate: ``True`` iff ``sym`` is a synthetic dead-suite node.
|
|
198
|
+
|
|
199
|
+
The only supported way to identify these nodes. Callers should not
|
|
200
|
+
string-match on the fqname directly; the prefix is an implementation
|
|
201
|
+
detail of this module.
|
|
202
|
+
"""
|
|
203
|
+
return sym.type == "synthetic" and sym.fqname.startswith(UNREACHABLE_PREFIX)
|
dead_cst/_codemod.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import libcst as cst
|
|
6
|
+
import networkx as nx
|
|
7
|
+
from libcst.codemod import CodemodContext
|
|
8
|
+
from libcst.codemod.visitors import RemoveImportsVisitor
|
|
9
|
+
from libcst.metadata import CodeRange, FullRepoManager, PositionProvider, QualifiedNameSource
|
|
10
|
+
|
|
11
|
+
from ._fqn import FixedFullyQualifiedNameProvider
|
|
12
|
+
from ._symbols import SymbolNode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RemoveDeadSymbols(cst.CSTTransformer):
|
|
16
|
+
METADATA_DEPENDENCIES = (FixedFullyQualifiedNameProvider, PositionProvider)
|
|
17
|
+
|
|
18
|
+
def __init__(self, dead_decls: set[tuple[str, CodeRange]]):
|
|
19
|
+
# ``(fqname, position)`` pairs. The position disambiguates same-name
|
|
20
|
+
# decls so a shadowed dead binding does not drag its live sibling
|
|
21
|
+
# out with it (and vice versa).
|
|
22
|
+
self.dead_decls = dead_decls
|
|
23
|
+
|
|
24
|
+
def _should_remove(self, node: cst.CSTNode) -> bool:
|
|
25
|
+
fqnames = self.get_metadata(FixedFullyQualifiedNameProvider, node, default=[])
|
|
26
|
+
pos = self.get_metadata(PositionProvider, node, default=None)
|
|
27
|
+
return any(
|
|
28
|
+
(qn.name, pos) in self.dead_decls
|
|
29
|
+
for qn in fqnames
|
|
30
|
+
if qn.source == QualifiedNameSource.LOCAL
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef):
|
|
34
|
+
if self._should_remove(original_node):
|
|
35
|
+
return cst.RemoveFromParent()
|
|
36
|
+
return updated_node
|
|
37
|
+
|
|
38
|
+
def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef):
|
|
39
|
+
if self._should_remove(original_node):
|
|
40
|
+
return cst.RemoveFromParent()
|
|
41
|
+
return updated_node
|
|
42
|
+
|
|
43
|
+
def leave_Assign(self, original_node: cst.Assign, updated_node: cst.Assign):
|
|
44
|
+
new_targets = []
|
|
45
|
+
for orig_target, new_target in zip(original_node.targets, updated_node.targets):
|
|
46
|
+
if not self._should_remove(orig_target.target):
|
|
47
|
+
new_targets.append(new_target)
|
|
48
|
+
|
|
49
|
+
if not new_targets:
|
|
50
|
+
return cst.RemoveFromParent()
|
|
51
|
+
|
|
52
|
+
return updated_node.with_changes(targets=new_targets)
|
|
53
|
+
|
|
54
|
+
def leave_AnnAssign(self, original_node: cst.AnnAssign, updated_node: cst.AnnAssign):
|
|
55
|
+
if self._should_remove(original_node.target):
|
|
56
|
+
return cst.RemoveFromParent()
|
|
57
|
+
return updated_node
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _import_remove_args(node: SymbolNode) -> tuple[str, str | None, str | None]:
|
|
61
|
+
"""Convert an ``"import"``-typed node to ``RemoveImportsVisitor`` args.
|
|
62
|
+
|
|
63
|
+
Returns ``(module, obj, asname)`` matching the signature of
|
|
64
|
+
:meth:`RemoveImportsVisitor.remove_unused_import`. ``asname`` is set
|
|
65
|
+
only when the local bound name differs from the natural binding
|
|
66
|
+
(``obj`` for ``from X import obj``, the leftmost segment of
|
|
67
|
+
``module`` for bare ``import X``).
|
|
68
|
+
"""
|
|
69
|
+
assert node.imports is not None, f"Import node missing imports metadata: {node.fqname}"
|
|
70
|
+
module = str(node.imports.module)
|
|
71
|
+
obj = node.imports.decl
|
|
72
|
+
bound = node.fqname.rsplit(".", 1)[-1]
|
|
73
|
+
natural = obj if obj is not None else module.split(".", 1)[0]
|
|
74
|
+
asname = bound if bound != natural else None
|
|
75
|
+
return module, obj, asname
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def remove_code(G: nx.Graph, base: Path) -> None:
|
|
79
|
+
"""Delete every symbol in ``G`` from the source files under ``base``.
|
|
80
|
+
|
|
81
|
+
Modules are removed by unlinking the file. Functions, classes, and
|
|
82
|
+
top-level variables are dropped by a LibCST transformer matching on
|
|
83
|
+
``(fqname, position)`` so a shadowed dead binding does not drag its
|
|
84
|
+
live sibling out with it. Surviving statements, comments, and
|
|
85
|
+
formatting around the deletions are preserved.
|
|
86
|
+
|
|
87
|
+
Imports are handled in a second pass via libcst's
|
|
88
|
+
:class:`RemoveImportsVisitor`, which walks scopes itself and skips
|
|
89
|
+
anything still referenced (defensive -- the graph already classifies
|
|
90
|
+
these as dead, but the scope check is cheap insurance).
|
|
91
|
+
|
|
92
|
+
``G`` is typically the unreachable subgraph of the graph from
|
|
93
|
+
:func:`build_symbol_graph`; only the nodes are inspected, edges are
|
|
94
|
+
ignored. Symbols whose path is not under ``base`` are skipped, so call
|
|
95
|
+
once per base when analysing several packages together. The
|
|
96
|
+
transformation is destructive -- back the files up first, or run on a
|
|
97
|
+
clean working tree.
|
|
98
|
+
"""
|
|
99
|
+
by_file: dict[Path, list[SymbolNode]] = {}
|
|
100
|
+
for node in G.nodes:
|
|
101
|
+
if not node.path.is_relative_to(base):
|
|
102
|
+
continue
|
|
103
|
+
if not node.path.exists():
|
|
104
|
+
continue
|
|
105
|
+
match node.type:
|
|
106
|
+
case "function" | "class" | "variable" | "import":
|
|
107
|
+
by_file.setdefault(node.path, []).append(node)
|
|
108
|
+
case "module":
|
|
109
|
+
node.path.unlink()
|
|
110
|
+
|
|
111
|
+
mgr = FullRepoManager(str(base), [str(p) for p in by_file], {FixedFullyQualifiedNameProvider})
|
|
112
|
+
for path, nodes in sorted(by_file.items(), key=lambda x: x):
|
|
113
|
+
if not path.exists():
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Pass 1: drop dead defs / classes / variables. Imports they
|
|
117
|
+
# used to reference become eligible for removal in pass 2.
|
|
118
|
+
wrapper = mgr.get_metadata_wrapper_for_path(str(path))
|
|
119
|
+
dead_decls = {(n.fqname, n.position) for n in nodes if n.type != "import"}
|
|
120
|
+
result = wrapper.visit(RemoveDeadSymbols(dead_decls))
|
|
121
|
+
|
|
122
|
+
# Pass 2: hand the dead-import set to libcst's stock import
|
|
123
|
+
# remover. It walks scopes itself, so it'll skip anything still
|
|
124
|
+
# referenced after pass 1 (defensive -- if the graph said
|
|
125
|
+
# something is dead, no live user remains).
|
|
126
|
+
dead_imports = [n for n in nodes if n.type == "import"]
|
|
127
|
+
if dead_imports:
|
|
128
|
+
ctx = CodemodContext()
|
|
129
|
+
for imp in dead_imports:
|
|
130
|
+
module, obj, asname = _import_remove_args(imp)
|
|
131
|
+
RemoveImportsVisitor.remove_unused_import(ctx, module, obj, asname)
|
|
132
|
+
result = RemoveImportsVisitor(ctx).transform_module(result)
|
|
133
|
+
|
|
134
|
+
with path.open("w") as f:
|
|
135
|
+
f.write(result.code)
|