codeanalyzer-python 0.2.0__py3-none-any.whl → 0.3.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.
- codeanalyzer/__main__.py +99 -6
- codeanalyzer/core.py +192 -215
- codeanalyzer/neo4j/bolt.py +13 -2
- codeanalyzer/neo4j/catalog.py +2 -2
- codeanalyzer/neo4j/project.py +18 -10
- codeanalyzer/neo4j/rows.py +10 -5
- codeanalyzer/options/__init__.py +2 -2
- codeanalyzer/options/options.py +21 -1
- codeanalyzer/schema/__init__.py +2 -0
- codeanalyzer/schema/py_schema.py +16 -1
- codeanalyzer/semantic_analysis/call_graph.py +24 -3
- codeanalyzer/semantic_analysis/pycg/__init__.py +20 -0
- codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +1054 -0
- codeanalyzer/semantic_analysis/{codeql/__init__.py → pycg/pycg_exceptions.py} +5 -8
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +401 -0
- codeanalyzer/utils/logging.py +5 -2
- codeanalyzer/utils/progress_bar.py +15 -7
- codeanalyzer_python-0.3.0.dist-info/METADATA +550 -0
- codeanalyzer_python-0.3.0.dist-info/RECORD +38 -0
- codeanalyzer/semantic_analysis/codeql/codeql_analysis.py +0 -382
- codeanalyzer/semantic_analysis/codeql/codeql_exceptions.py +0 -12
- codeanalyzer/semantic_analysis/codeql/codeql_loader.py +0 -91
- codeanalyzer/semantic_analysis/codeql/codeql_query_runner.py +0 -185
- codeanalyzer_python-0.2.0.dist-info/METADATA +0 -393
- codeanalyzer_python-0.2.0.dist-info/RECORD +0 -39
- {codeanalyzer_python-0.2.0.dist-info → codeanalyzer_python-0.3.0.dist-info}/WHEEL +0 -0
- {codeanalyzer_python-0.2.0.dist-info → codeanalyzer_python-0.3.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.2.0.dist-info → codeanalyzer_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.2.0.dist-info → codeanalyzer_python-0.3.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Copyright IBM Corporation 2025
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
################################################################################
|
|
16
|
+
|
|
17
|
+
"""PyCG-based call graph construction for analysis level 2.
|
|
18
|
+
|
|
19
|
+
PyCG (Apache-2.0, ICSE 2021) uses iterative inter-procedural name-pointer
|
|
20
|
+
analysis to produce a call graph with ~99% precision and ~69% recall on
|
|
21
|
+
micro-benchmarks. Its dotted namespace format (``module.Class.method``)
|
|
22
|
+
aligns directly with the ``PyCallable.signature`` space used by the symbol
|
|
23
|
+
table, so no name translation is needed for in-source callees.
|
|
24
|
+
|
|
25
|
+
Callees not found in the symbol table are treated as ghost nodes — the same
|
|
26
|
+
convention used by :func:`call_graph.to_digraph`.
|
|
27
|
+
|
|
28
|
+
**Sharding** (``shard=True``) runs PyCG independently per Python package
|
|
29
|
+
root instead of over the entire project. This keeps each shard under the
|
|
30
|
+
500-file ceiling by bounding PyCG's recursive import-following to the
|
|
31
|
+
package boundary. Cross-shard imports become ghost nodes (same quality as
|
|
32
|
+
Jedi-only edges for those call sites). Edge names are normalised back to
|
|
33
|
+
project-relative dotted paths so they align with the symbol table.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Python 3.13 compatibility: PyCG installs a custom import hook and calls
|
|
37
|
+
# importlib.invalidate_caches() during analysis. In Python 3.13, that call
|
|
38
|
+
# triggers lazy loading of importlib.metadata → json → json.decoder, which
|
|
39
|
+
# re-enters PyCG's hook before its import graph is ready. Pre-importing
|
|
40
|
+
# these modules at import time ensures they're already in sys.modules when
|
|
41
|
+
# PyCG's hook is active, preventing the re-entrant ImportManagerError.
|
|
42
|
+
import importlib.metadata # noqa: F401
|
|
43
|
+
import importlib.util # noqa: F401
|
|
44
|
+
import contextlib
|
|
45
|
+
import json # noqa: F401
|
|
46
|
+
import shutil
|
|
47
|
+
import signal
|
|
48
|
+
import tempfile
|
|
49
|
+
import time
|
|
50
|
+
|
|
51
|
+
from collections import Counter, defaultdict
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any, Dict, Generator, List, Optional, Set, Tuple, Union
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextlib.contextmanager
|
|
57
|
+
def _shard_timeout(seconds: int) -> Generator[None, None, None]:
|
|
58
|
+
"""Context manager that raises ``TimeoutError`` if the body runs longer than *seconds*.
|
|
59
|
+
|
|
60
|
+
Uses SIGALRM on POSIX (macOS / Linux). On platforms without SIGALRM
|
|
61
|
+
(Windows) the context manager is a no-op — shards can still be bounded
|
|
62
|
+
by the file-count ceiling.
|
|
63
|
+
|
|
64
|
+
Must be called from the main thread (SIGALRM restriction).
|
|
65
|
+
"""
|
|
66
|
+
if seconds <= 0 or not hasattr(signal, "SIGALRM"):
|
|
67
|
+
yield
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
def _handler(signum: int, frame: object) -> None:
|
|
71
|
+
raise TimeoutError(f"shard timed out after {seconds}s")
|
|
72
|
+
|
|
73
|
+
old_handler = signal.signal(signal.SIGALRM, _handler)
|
|
74
|
+
signal.alarm(seconds)
|
|
75
|
+
try:
|
|
76
|
+
yield
|
|
77
|
+
finally:
|
|
78
|
+
signal.alarm(0)
|
|
79
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
80
|
+
|
|
81
|
+
from codeanalyzer.schema.py_schema import PyCallEdge, PyModule
|
|
82
|
+
from codeanalyzer.semantic_analysis.call_graph import iter_callables_in_symbol_table
|
|
83
|
+
from codeanalyzer.semantic_analysis.pycg.pycg_exceptions import PyCGExceptions
|
|
84
|
+
from codeanalyzer.semantic_analysis.pycg.shard_planner import plan_shards
|
|
85
|
+
from codeanalyzer.utils import ProgressBar, logger
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _materialize_shard_root(
|
|
89
|
+
files: List[str],
|
|
90
|
+
project_dir: Path,
|
|
91
|
+
) -> Tuple[Path, List[str]]:
|
|
92
|
+
"""Build a temporary symlink mini-project for a shard; return ``(root, eps)``.
|
|
93
|
+
|
|
94
|
+
PyCG bounds its import-following to the ``package`` directory — only
|
|
95
|
+
modules whose resolved file lives under that root are followed; everything
|
|
96
|
+
else becomes a ghost node (``ImportManager``: ``if self.mod_dir not in
|
|
97
|
+
mod.__file__: return``). A coupling-derived shard is an arbitrary set of
|
|
98
|
+
files that need not form a directory, so we mirror the project layout into
|
|
99
|
+
a temp dir holding symlinks to exactly the shard's files plus the
|
|
100
|
+
``__init__.py`` chain each needs for package resolution. Running PyCG with
|
|
101
|
+
this mirror as the package root confines analysis to the shard while
|
|
102
|
+
emitting project-relative edge names (so ``prefix=""`` — no rename needed).
|
|
103
|
+
|
|
104
|
+
The caller owns the returned *root* and must ``shutil.rmtree`` it.
|
|
105
|
+
"""
|
|
106
|
+
root = Path(tempfile.mkdtemp(prefix="canpy_pycg_shard_"))
|
|
107
|
+
entry_points: List[str] = []
|
|
108
|
+
linked_inits: Set[Path] = set()
|
|
109
|
+
for f in files:
|
|
110
|
+
src = Path(f).resolve()
|
|
111
|
+
try:
|
|
112
|
+
rel = src.relative_to(project_dir)
|
|
113
|
+
except ValueError:
|
|
114
|
+
continue # defensively skip files outside the project
|
|
115
|
+
dst = root / rel
|
|
116
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
if not dst.exists():
|
|
118
|
+
dst.symlink_to(src)
|
|
119
|
+
entry_points.append(str(dst))
|
|
120
|
+
|
|
121
|
+
# Symlink the __init__.py chain from project root down to this file's
|
|
122
|
+
# package so PyCG/importlib can resolve the dotted module name. These
|
|
123
|
+
# add ~0 analysis cost (usually empty) and keep out-of-shard siblings
|
|
124
|
+
# unresolved → ghost nodes.
|
|
125
|
+
for i in range(len(rel.parent.parts) + 1):
|
|
126
|
+
pkg_rel = Path(*rel.parent.parts[:i])
|
|
127
|
+
real_init = project_dir / pkg_rel / "__init__.py"
|
|
128
|
+
link_init = root / pkg_rel / "__init__.py"
|
|
129
|
+
if real_init.exists() and link_init not in linked_inits:
|
|
130
|
+
link_init.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
if not link_init.exists():
|
|
132
|
+
link_init.symlink_to(real_init.resolve())
|
|
133
|
+
linked_inits.add(link_init)
|
|
134
|
+
return root, entry_points
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@contextlib.contextmanager
|
|
138
|
+
def _shard_symlink_root(
|
|
139
|
+
files: List[str],
|
|
140
|
+
project_dir: Path,
|
|
141
|
+
) -> Generator[Tuple[Path, List[str]], None, None]:
|
|
142
|
+
"""Context-manager wrapper around :func:`_materialize_shard_root`.
|
|
143
|
+
|
|
144
|
+
Yields ``(root, entry_points)`` and removes the temp tree on exit.
|
|
145
|
+
"""
|
|
146
|
+
root, entry_points = _materialize_shard_root(files, project_dir)
|
|
147
|
+
try:
|
|
148
|
+
yield root, entry_points
|
|
149
|
+
finally:
|
|
150
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pycg_shard_worker(
|
|
154
|
+
entry_points: List[str],
|
|
155
|
+
package_dir: str,
|
|
156
|
+
prefix: str,
|
|
157
|
+
max_iter: int = -1,
|
|
158
|
+
) -> List[tuple]:
|
|
159
|
+
"""Run PyCG on one shard; called in a Ray worker process.
|
|
160
|
+
|
|
161
|
+
Returns a list of ``(source, target, weight)`` tuples that the caller
|
|
162
|
+
converts to :class:`PyCallEdge` objects. This function is a plain
|
|
163
|
+
module-level callable so it can be pickled by Ray without capturing any
|
|
164
|
+
class-level state. *max_iter* caps PyCG's fixpoint passes (-1 = unbounded).
|
|
165
|
+
"""
|
|
166
|
+
import importlib
|
|
167
|
+
import sys
|
|
168
|
+
|
|
169
|
+
# Python 3.13 compatibility pre-imports (mirroring the top-level block).
|
|
170
|
+
import importlib.metadata # noqa: F401
|
|
171
|
+
import importlib.util # noqa: F401
|
|
172
|
+
import json # noqa: F401
|
|
173
|
+
from collections import Counter as _WorkerCounter
|
|
174
|
+
|
|
175
|
+
CallGraphGenerator = None
|
|
176
|
+
for pkg_name in ("pycg", "PyCG"):
|
|
177
|
+
try:
|
|
178
|
+
mod = importlib.import_module(pkg_name)
|
|
179
|
+
sys.modules.setdefault("pycg", mod)
|
|
180
|
+
sys.modules.setdefault("PyCG", mod)
|
|
181
|
+
pycg_mod = importlib.import_module(f"{pkg_name}.pycg")
|
|
182
|
+
CallGraphGenerator = pycg_mod.CallGraphGenerator
|
|
183
|
+
break
|
|
184
|
+
except ImportError:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if CallGraphGenerator is None:
|
|
188
|
+
raise RuntimeError("pycg is not installed in Ray worker — run `pip install pycg`")
|
|
189
|
+
|
|
190
|
+
_apply_pycg_posonly_patch()
|
|
191
|
+
|
|
192
|
+
cg = CallGraphGenerator(
|
|
193
|
+
entry_points=entry_points,
|
|
194
|
+
package=package_dir,
|
|
195
|
+
max_iter=max_iter,
|
|
196
|
+
operation="call-graph",
|
|
197
|
+
)
|
|
198
|
+
cg.analyze()
|
|
199
|
+
|
|
200
|
+
edge_counts = _WorkerCounter()
|
|
201
|
+
for src, dst in cg.output_edges():
|
|
202
|
+
if prefix:
|
|
203
|
+
src = f"{prefix}.{src}"
|
|
204
|
+
dst = f"{prefix}.{dst}"
|
|
205
|
+
edge_counts[(src, dst)] += 1
|
|
206
|
+
|
|
207
|
+
return [(src, dst, count) for (src, dst), count in edge_counts.items()]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _apply_pycg_posonly_patch() -> None:
|
|
211
|
+
"""Monkey-patch PyCG's PreProcessor to handle Python 3.8+ positional-only params.
|
|
212
|
+
|
|
213
|
+
PyCG's ``_get_fun_defaults`` computes the default-argument start index as
|
|
214
|
+
``len(node.args.args) - len(node.args.defaults)``. In Python 3.8+,
|
|
215
|
+
``node.args.defaults`` covers the LAST ``len(defaults)`` arguments of
|
|
216
|
+
``posonlyargs + args`` combined, not just ``args``. When any positional-
|
|
217
|
+
only argument has a default (e.g. ``def f(a=1, b=2, /):``), the start
|
|
218
|
+
index becomes too negative, causing ``IndexError: list index out of range``
|
|
219
|
+
during PyCG's pre-processing pass.
|
|
220
|
+
|
|
221
|
+
This function replaces ``PreProcessor._get_fun_defaults`` with a corrected
|
|
222
|
+
implementation the first time it is called. Subsequent calls are no-ops.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
import sys
|
|
226
|
+
preprocessor_mod = sys.modules.get("pycg.processing.preprocessor") \
|
|
227
|
+
or sys.modules.get("PyCG.processing.preprocessor")
|
|
228
|
+
if preprocessor_mod is None:
|
|
229
|
+
import importlib
|
|
230
|
+
for pkg_name in ("pycg", "PyCG"):
|
|
231
|
+
try:
|
|
232
|
+
preprocessor_mod = importlib.import_module(
|
|
233
|
+
f"{pkg_name}.processing.preprocessor"
|
|
234
|
+
)
|
|
235
|
+
break
|
|
236
|
+
except ImportError:
|
|
237
|
+
continue
|
|
238
|
+
if preprocessor_mod is None:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
PreProcessor = preprocessor_mod.PreProcessor
|
|
242
|
+
if getattr(PreProcessor, "_posonly_patched", False):
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
def _patched_get_fun_defaults(self, node): # type: ignore[override]
|
|
246
|
+
defaults = {}
|
|
247
|
+
# Combine posonlyargs (Python 3.8+) with regular args so that the
|
|
248
|
+
# start index is computed over the full positional parameter list.
|
|
249
|
+
all_args = getattr(node.args, "posonlyargs", []) + node.args.args
|
|
250
|
+
start = len(all_args) - len(node.args.defaults)
|
|
251
|
+
for cnt, d in enumerate(node.args.defaults, start=start):
|
|
252
|
+
if not d:
|
|
253
|
+
continue
|
|
254
|
+
self.visit(d)
|
|
255
|
+
if 0 <= cnt < len(all_args):
|
|
256
|
+
defaults[all_args[cnt].arg] = self.decode_node(d)
|
|
257
|
+
|
|
258
|
+
start = len(node.args.kwonlyargs) - len(node.args.kw_defaults)
|
|
259
|
+
for cnt, d in enumerate(node.args.kw_defaults, start=start):
|
|
260
|
+
if not d:
|
|
261
|
+
continue
|
|
262
|
+
self.visit(d)
|
|
263
|
+
if 0 <= cnt < len(node.args.kwonlyargs):
|
|
264
|
+
defaults[node.args.kwonlyargs[cnt].arg] = self.decode_node(d)
|
|
265
|
+
return defaults
|
|
266
|
+
|
|
267
|
+
PreProcessor._get_fun_defaults = _patched_get_fun_defaults # type: ignore[method-assign]
|
|
268
|
+
PreProcessor._posonly_patched = True # type: ignore[attr-defined]
|
|
269
|
+
logger.debug("PyCG: applied positional-only-param default patch (Python 3.8+ fix)")
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _import_pycg() -> Any:
|
|
275
|
+
"""Import PyCG's CallGraphGenerator, trying both 'pycg' and 'PyCG' package names.
|
|
276
|
+
|
|
277
|
+
The PyPI distribution installs as ``PyCG/`` (mixed case). Python's importer
|
|
278
|
+
is case-sensitive even on macOS HFS+, so we try both names and normalise
|
|
279
|
+
``pycg`` in sys.modules so PyCG's own ``from pycg import utils`` resolves
|
|
280
|
+
regardless of which name the finder used first.
|
|
281
|
+
|
|
282
|
+
Returns the ``CallGraphGenerator`` class.
|
|
283
|
+
Raises ``PyCGExceptions.PyCGImportError`` if neither name is importable.
|
|
284
|
+
"""
|
|
285
|
+
import importlib
|
|
286
|
+
import sys
|
|
287
|
+
|
|
288
|
+
for pkg_name in ("pycg", "PyCG"):
|
|
289
|
+
try:
|
|
290
|
+
mod = importlib.import_module(pkg_name)
|
|
291
|
+
sys.modules.setdefault("pycg", mod)
|
|
292
|
+
sys.modules.setdefault("PyCG", mod)
|
|
293
|
+
pycg_mod = importlib.import_module(f"{pkg_name}.pycg")
|
|
294
|
+
return pycg_mod.CallGraphGenerator
|
|
295
|
+
except ImportError:
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
raise PyCGExceptions.PyCGImportError(
|
|
299
|
+
"pycg is not installed — run `pip install pycg`"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _PyCGCallableResolver:
|
|
304
|
+
"""Maps a PyCG dotted namespace string to a ``PyCallable.signature``.
|
|
305
|
+
|
|
306
|
+
PyCG names callables as ``module.Class.method`` relative to the package
|
|
307
|
+
root, which is identical to our ``PyCallable.signature`` format. A
|
|
308
|
+
direct dict lookup is therefore sufficient; this class exists to hold
|
|
309
|
+
the index and make the ghost-node fallback explicit.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def __init__(self, known: Set[str]) -> None:
|
|
313
|
+
self._known = known
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def from_symbol_table(
|
|
317
|
+
cls, symbol_table: Dict[str, PyModule]
|
|
318
|
+
) -> "_PyCGCallableResolver":
|
|
319
|
+
known = {c.signature for c in iter_callables_in_symbol_table(symbol_table)}
|
|
320
|
+
return cls(known)
|
|
321
|
+
|
|
322
|
+
def resolve(self, pycg_name: str) -> str:
|
|
323
|
+
"""Return the canonical signature for *pycg_name*.
|
|
324
|
+
|
|
325
|
+
If the name is in the symbol table it is returned verbatim.
|
|
326
|
+
Otherwise it is returned as-is so the edge is preserved as a
|
|
327
|
+
ghost (external / library) node in the call graph.
|
|
328
|
+
"""
|
|
329
|
+
return pycg_name
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class PyCG:
|
|
333
|
+
"""Thin wrapper around PyCG's ``CallGraphGenerator``.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
project_dir: Root of the Python project to analyse.
|
|
337
|
+
skip_tests: When ``True``, files whose path contains ``test`` or
|
|
338
|
+
``conftest`` are excluded from the entry-point list.
|
|
339
|
+
shard: When ``True``, run PyCG independently per Python package
|
|
340
|
+
root instead of over the whole project. Required for projects
|
|
341
|
+
that exceed the 500-file ceiling.
|
|
342
|
+
shard_ceiling: Maximum file count per shard. Shards exceeding this
|
|
343
|
+
limit are skipped. Defaults to ``_PYCG_SHARD_CEILING`` (100).
|
|
344
|
+
shard_timeout: Per-shard wall-clock timeout in seconds. A shard that
|
|
345
|
+
exceeds this limit is skipped. 0 disables the timeout. Defaults
|
|
346
|
+
to ``_PYCG_SHARD_TIMEOUT`` (120). POSIX only; no-op on Windows.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
# PyCG's pointer analysis is practical only up to this many files.
|
|
350
|
+
# Its per-iteration cost grows super-linearly; on very large projects
|
|
351
|
+
# even a single pass can take tens of minutes.
|
|
352
|
+
_PYCG_FILE_CEILING: int = 500
|
|
353
|
+
|
|
354
|
+
# Separate, tighter ceiling applied per shard in sharding mode.
|
|
355
|
+
# A shard covers one Python package root; PyCG follows imports only
|
|
356
|
+
# within that boundary. Even so, packages with deep class hierarchies
|
|
357
|
+
# or heavily interconnected imports can cause PyCG's pointer fixpoint
|
|
358
|
+
# to diverge well before the whole-project ceiling. 100 files is the
|
|
359
|
+
# conservative default; override via --pycg-shard-ceiling.
|
|
360
|
+
_PYCG_SHARD_CEILING: int = 100
|
|
361
|
+
|
|
362
|
+
# Per-shard wall-clock timeout (seconds). PyCG's fixpoint is bimodal:
|
|
363
|
+
# either it converges in seconds or it diverges and never finishes.
|
|
364
|
+
# This timeout acts as a final safety net after the file-count ceiling.
|
|
365
|
+
# 120 seconds is generous enough for any legitimately complex shard
|
|
366
|
+
# while still catching non-converging ones. Override via
|
|
367
|
+
# --pycg-shard-timeout. Set to 0 to disable.
|
|
368
|
+
_PYCG_SHARD_TIMEOUT: int = 120
|
|
369
|
+
|
|
370
|
+
# Cap on PyCG's outer fixpoint passes. PyCG runs PostProcessor until the
|
|
371
|
+
# def/scope/MRO state stops changing; its abstract domain (field-sensitive
|
|
372
|
+
# access paths, no k-limiting or widening) has no ascending-chain bound, so
|
|
373
|
+
# on heavy metaclass/mixin code (e.g. an ORM) the def set can balloon into
|
|
374
|
+
# the thousands and each O(defs^2) pass costs seconds — convergence, if it
|
|
375
|
+
# comes, takes many passes. A finite cap turns "loop until killed" into a
|
|
376
|
+
# sound-but-incomplete result that still returns the edges found so far.
|
|
377
|
+
# 50 is generous — well-behaved code converges in well under 20 passes —
|
|
378
|
+
# while bounding the pathological case. Override via --pycg-max-iter;
|
|
379
|
+
# -1 restores PyCG's unbounded run-to-convergence behaviour.
|
|
380
|
+
_PYCG_MAX_ITER: int = 50
|
|
381
|
+
|
|
382
|
+
# Iterative decomposition of runaway (timed-out) shards: a shard that the
|
|
383
|
+
# wall-clock timeout kills is re-partitioned at half the budget and re-run,
|
|
384
|
+
# down to this file-count floor. Below the floor — or for an atomic import
|
|
385
|
+
# cycle that won't split — the residue falls back to Jedi-only coverage.
|
|
386
|
+
_PYCG_DECOMP_FLOOR: int = 10
|
|
387
|
+
_PYCG_MAX_DECOMP_ROUNDS: int = 6
|
|
388
|
+
|
|
389
|
+
# Directory names that should never be fed to PyCG as entry points, nor
|
|
390
|
+
# followed into during import resolution (an in-tree .codeanalyzer venv /
|
|
391
|
+
# site-packages lives under project_dir and would otherwise be pulled into
|
|
392
|
+
# the package bound and analysed — see _shard_symlink_root).
|
|
393
|
+
_SKIP_DIRS: frozenset = frozenset({
|
|
394
|
+
".codeanalyzer", ".git", "__pycache__",
|
|
395
|
+
"venv", ".venv", "virtualenv", "env", ".env",
|
|
396
|
+
"node_modules", "dist", "build", ".tox", ".nox",
|
|
397
|
+
"site-packages",
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
def __init__(
|
|
401
|
+
self,
|
|
402
|
+
project_dir: Union[str, Path],
|
|
403
|
+
skip_tests: bool = True,
|
|
404
|
+
shard: bool = False,
|
|
405
|
+
shard_ceiling: Optional[int] = None,
|
|
406
|
+
shard_timeout: Optional[int] = None,
|
|
407
|
+
shard_strategy: str = "jedi",
|
|
408
|
+
max_iter: Optional[int] = None,
|
|
409
|
+
using_ray: bool = False,
|
|
410
|
+
) -> None:
|
|
411
|
+
self.project_dir = Path(project_dir).resolve()
|
|
412
|
+
self.skip_tests = skip_tests
|
|
413
|
+
self.shard = shard
|
|
414
|
+
self.shard_ceiling = (
|
|
415
|
+
shard_ceiling if shard_ceiling is not None else self._PYCG_SHARD_CEILING
|
|
416
|
+
)
|
|
417
|
+
self.shard_timeout = (
|
|
418
|
+
shard_timeout if shard_timeout is not None else self._PYCG_SHARD_TIMEOUT
|
|
419
|
+
)
|
|
420
|
+
self.max_iter = max_iter if max_iter is not None else self._PYCG_MAX_ITER
|
|
421
|
+
# "jedi": partition the Jedi module graph (SCC + Louvain) so coupled
|
|
422
|
+
# modules co-compute and few edges are severed (see shard_planner).
|
|
423
|
+
# "package": legacy one-shard-per-package-directory grouping.
|
|
424
|
+
self.shard_strategy = shard_strategy
|
|
425
|
+
self.using_ray = using_ray
|
|
426
|
+
self._CallGraphGenerator: Optional[Any] = None
|
|
427
|
+
self._resolver: Optional["_PyCGCallableResolver"] = None
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _coalesce_edges(edges: List[PyCallEdge]) -> List[PyCallEdge]:
|
|
431
|
+
"""Sum weights of duplicate ``(source, target)`` pairs across shards."""
|
|
432
|
+
merged: Dict[tuple, PyCallEdge] = {}
|
|
433
|
+
for edge in edges:
|
|
434
|
+
key = (edge.source, edge.target)
|
|
435
|
+
if key in merged:
|
|
436
|
+
existing = merged[key]
|
|
437
|
+
merged[key] = PyCallEdge(
|
|
438
|
+
source=existing.source,
|
|
439
|
+
target=existing.target,
|
|
440
|
+
weight=existing.weight + edge.weight,
|
|
441
|
+
provenance=existing.provenance,
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
merged[key] = edge
|
|
445
|
+
return list(merged.values())
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
# Entry-point collection
|
|
449
|
+
# ------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def _collect_entry_points(self) -> List[str]:
|
|
452
|
+
"""Return absolute paths of project Python files, excluding caches and venvs."""
|
|
453
|
+
paths = []
|
|
454
|
+
for p in self.project_dir.rglob("*.py"):
|
|
455
|
+
# Skip any file whose path passes through a filtered directory.
|
|
456
|
+
if any(part in self._SKIP_DIRS for part in p.parts):
|
|
457
|
+
continue
|
|
458
|
+
# Skip test files using exact path-component matching, consistent
|
|
459
|
+
# with core.py's _build_symbol_table filter. Substring matching
|
|
460
|
+
# (e.g. "/test" in full_path_str) incorrectly excludes files in
|
|
461
|
+
# paths like "test/fixtures/..." that are source files, not tests.
|
|
462
|
+
rel_parts = p.relative_to(self.project_dir).parts
|
|
463
|
+
if self.skip_tests and (
|
|
464
|
+
"test" in rel_parts
|
|
465
|
+
or "tests" in rel_parts
|
|
466
|
+
or p.stem.startswith("test_")
|
|
467
|
+
or p.name.endswith("_test.py")
|
|
468
|
+
or p.name == "conftest.py"
|
|
469
|
+
):
|
|
470
|
+
continue
|
|
471
|
+
paths.append(str(p))
|
|
472
|
+
return paths
|
|
473
|
+
|
|
474
|
+
# ------------------------------------------------------------------
|
|
475
|
+
# Package-root helpers for sharding
|
|
476
|
+
# ------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
@staticmethod
|
|
479
|
+
def _find_package_root(file_path: Path, project_dir: Path) -> Path:
|
|
480
|
+
"""Return the top-level Python package directory that owns *file_path*.
|
|
481
|
+
|
|
482
|
+
Walks upward from the file's directory toward *project_dir*, returning
|
|
483
|
+
the highest ancestor that still contains an ``__init__.py``. Files
|
|
484
|
+
at the project root (no ``__init__.py`` in any parent) are placed in
|
|
485
|
+
a shard rooted at *project_dir* itself.
|
|
486
|
+
|
|
487
|
+
Examples::
|
|
488
|
+
|
|
489
|
+
project/addons/account/models/res.py → project/addons/account/
|
|
490
|
+
project/src/flask/app.py → project/src/flask/
|
|
491
|
+
project/standalone_script.py → project/
|
|
492
|
+
"""
|
|
493
|
+
package_root = file_path.parent
|
|
494
|
+
current = file_path.parent
|
|
495
|
+
while current != project_dir:
|
|
496
|
+
if not (current / "__init__.py").exists():
|
|
497
|
+
break
|
|
498
|
+
package_root = current
|
|
499
|
+
current = current.parent
|
|
500
|
+
return package_root
|
|
501
|
+
|
|
502
|
+
@staticmethod
|
|
503
|
+
def _package_prefix(pkg_root: Path, project_dir: Path) -> str:
|
|
504
|
+
"""Dot-separated path from *project_dir* to *pkg_root*.
|
|
505
|
+
|
|
506
|
+
This prefix is prepended to PyCG's package-relative edge names so
|
|
507
|
+
they become project-relative and align with the symbol table::
|
|
508
|
+
|
|
509
|
+
pkg_root = project/addons/account/ → "addons.account"
|
|
510
|
+
pkg_root = project/src/flask/ → "src.flask"
|
|
511
|
+
pkg_root = project/ → "" (no prefix needed)
|
|
512
|
+
"""
|
|
513
|
+
rel = pkg_root.relative_to(project_dir)
|
|
514
|
+
return ".".join(rel.parts)
|
|
515
|
+
|
|
516
|
+
# ------------------------------------------------------------------
|
|
517
|
+
# Core PyCG runner
|
|
518
|
+
# ------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
def _ensure_pycg_loaded(self) -> None:
|
|
521
|
+
"""Import PyCG and apply compatibility patches (idempotent)."""
|
|
522
|
+
if self._CallGraphGenerator is not None:
|
|
523
|
+
return
|
|
524
|
+
self._CallGraphGenerator = _import_pycg()
|
|
525
|
+
# Python 3.8+ positional-only-param fix and Python 3.13 import-hook fix.
|
|
526
|
+
_apply_pycg_posonly_patch()
|
|
527
|
+
|
|
528
|
+
def _run_pycg_batch(
|
|
529
|
+
self,
|
|
530
|
+
entry_points: List[str],
|
|
531
|
+
package_dir: Path,
|
|
532
|
+
resolver: "_PyCGCallableResolver",
|
|
533
|
+
prefix: str = "",
|
|
534
|
+
) -> List[PyCallEdge]:
|
|
535
|
+
"""Run PyCG on *entry_points* with *package_dir* as the package root.
|
|
536
|
+
|
|
537
|
+
*prefix* is a dot-separated path prepended to every edge name emitted
|
|
538
|
+
by PyCG so that shard-relative names become project-relative. Pass
|
|
539
|
+
``""`` when *package_dir* is the project root (names already match).
|
|
540
|
+
|
|
541
|
+
Raises ``PyCGExceptions.PyCGAnalysisError`` on any PyCG failure.
|
|
542
|
+
"""
|
|
543
|
+
assert self._CallGraphGenerator is not None
|
|
544
|
+
try:
|
|
545
|
+
cg = self._CallGraphGenerator(
|
|
546
|
+
entry_points=entry_points,
|
|
547
|
+
package=str(package_dir),
|
|
548
|
+
max_iter=self.max_iter,
|
|
549
|
+
operation="call-graph",
|
|
550
|
+
)
|
|
551
|
+
cg.analyze()
|
|
552
|
+
except TimeoutError:
|
|
553
|
+
raise # propagate directly so _build_sharded logs a clean timeout message
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
raise PyCGExceptions.PyCGAnalysisError(
|
|
556
|
+
f"PyCG analysis failed: {exc}"
|
|
557
|
+
) from exc
|
|
558
|
+
|
|
559
|
+
edge_counts: Counter = Counter()
|
|
560
|
+
for src, dst in cg.output_edges():
|
|
561
|
+
if prefix:
|
|
562
|
+
src = f"{prefix}.{src}"
|
|
563
|
+
dst = f"{prefix}.{dst}"
|
|
564
|
+
edge_counts[(resolver.resolve(src), resolver.resolve(dst))] += 1
|
|
565
|
+
|
|
566
|
+
return [
|
|
567
|
+
PyCallEdge(source=src, target=dst, weight=count, provenance=["pycg"])
|
|
568
|
+
for (src, dst), count in edge_counts.items()
|
|
569
|
+
]
|
|
570
|
+
|
|
571
|
+
# ------------------------------------------------------------------
|
|
572
|
+
# Sharded analysis
|
|
573
|
+
# ------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
def _build_sharded_planned(
|
|
576
|
+
self,
|
|
577
|
+
jedi_edges: List[PyCallEdge],
|
|
578
|
+
symbol_table: Dict[str, PyModule],
|
|
579
|
+
resolver: "_PyCGCallableResolver",
|
|
580
|
+
) -> List[PyCallEdge]:
|
|
581
|
+
"""Coupling-aware sharding with iterative decomposition of runaways.
|
|
582
|
+
|
|
583
|
+
Shards are chosen to *minimise the call edges severed between shards*:
|
|
584
|
+
:func:`shard_planner.plan_shards` condenses the Jedi call graph by
|
|
585
|
+
strongly-connected component (so import cycles never split) and clusters
|
|
586
|
+
it with Louvain so tightly-coupled modules land together. Each shard is
|
|
587
|
+
run through PyCG via a symlinked mini-project that bounds analysis to its
|
|
588
|
+
files.
|
|
589
|
+
|
|
590
|
+
PyCG's fixpoint diverges on heavy metaclass/mixin clusters, and a uniform
|
|
591
|
+
ceiling would force *every* shard small (severing many edges) just to tame
|
|
592
|
+
the few that run away. Instead we start coarse (low cut, high recall on
|
|
593
|
+
healthy code) and **only re-decompose the shards that time out**: each
|
|
594
|
+
runaway's files are re-partitioned at half the budget and re-run, down to
|
|
595
|
+
a floor. A runaway shard contributes zero edges, so splitting it recovers
|
|
596
|
+
almost all of them while paying cut on its internal seams alone. The
|
|
597
|
+
residue that still diverges at the floor (or is an atomic cycle that won't
|
|
598
|
+
split) falls back to Jedi-only coverage.
|
|
599
|
+
"""
|
|
600
|
+
self._resolver = resolver
|
|
601
|
+
plan = plan_shards(
|
|
602
|
+
symbol_table, jedi_edges, budget=self.shard_ceiling, merge_small=True
|
|
603
|
+
)
|
|
604
|
+
m = plan.metrics
|
|
605
|
+
logger.info(
|
|
606
|
+
"PyCG: planned %d shard(s) from Jedi module graph "
|
|
607
|
+
"(cut_ratio=%.3f, max_shard=%d files, %d modules)",
|
|
608
|
+
int(m["num_shards"]), m["cut_ratio"],
|
|
609
|
+
int(m["max_shard_files"]), int(m["modules"]),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
runner = (
|
|
613
|
+
self._run_fileset_shards_ray if self.using_ray
|
|
614
|
+
else self._run_fileset_shards_seq
|
|
615
|
+
)
|
|
616
|
+
all_edges: List[PyCallEdge] = []
|
|
617
|
+
shards = plan.shards
|
|
618
|
+
budget = self.shard_ceiling
|
|
619
|
+
converged_total = 0
|
|
620
|
+
irreducible_files = 0
|
|
621
|
+
round_no = 0
|
|
622
|
+
|
|
623
|
+
while shards:
|
|
624
|
+
label = "decomposition round %d (budget %d, %d shard(s))" % (
|
|
625
|
+
round_no, budget, len(shards),
|
|
626
|
+
)
|
|
627
|
+
logger.info("PyCG: %s", label)
|
|
628
|
+
edges, runaways = runner(shards)
|
|
629
|
+
all_edges.extend(edges)
|
|
630
|
+
converged_total += len(shards) - len(runaways)
|
|
631
|
+
if not runaways:
|
|
632
|
+
break
|
|
633
|
+
|
|
634
|
+
next_budget = max(self._PYCG_DECOMP_FLOOR, budget // 2)
|
|
635
|
+
stop_decomposing = (
|
|
636
|
+
round_no >= self._PYCG_MAX_DECOMP_ROUNDS or next_budget >= budget
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
next_shards: List[List[str]] = []
|
|
640
|
+
for rf in runaways:
|
|
641
|
+
# Re-partition this runaway's files alone, at a tighter budget.
|
|
642
|
+
# An atomic cycle (or a lone file) that won't shrink is
|
|
643
|
+
# irreducible — accept Jedi-only rather than loop forever.
|
|
644
|
+
sub_st = {f: symbol_table[f] for f in rf if f in symbol_table}
|
|
645
|
+
if stop_decomposing or len(rf) <= 1:
|
|
646
|
+
irreducible_files += len(rf)
|
|
647
|
+
continue
|
|
648
|
+
sub_plan = plan_shards(sub_st, jedi_edges, budget=next_budget)
|
|
649
|
+
if len(sub_plan.shards) <= 1:
|
|
650
|
+
# did not actually split (one atomic SCC) — give up on it
|
|
651
|
+
irreducible_files += len(rf)
|
|
652
|
+
continue
|
|
653
|
+
next_shards.extend(sub_plan.shards)
|
|
654
|
+
|
|
655
|
+
if not next_shards:
|
|
656
|
+
break
|
|
657
|
+
logger.info(
|
|
658
|
+
"PyCG: %d shard(s) ran away — decomposing into %d sub-shard(s) "
|
|
659
|
+
"at budget %d", len(runaways), len(next_shards), next_budget,
|
|
660
|
+
)
|
|
661
|
+
shards, budget = next_shards, next_budget
|
|
662
|
+
round_no += 1
|
|
663
|
+
|
|
664
|
+
if irreducible_files:
|
|
665
|
+
logger.warning(
|
|
666
|
+
"PyCG: %d file(s) in irreducibly-divergent shards fall back to "
|
|
667
|
+
"Jedi-only coverage", irreducible_files,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
result = self._coalesce_edges(all_edges)
|
|
671
|
+
logger.info(
|
|
672
|
+
"PyCG: %d edges from %d converged shard(s) over %d round(s) "
|
|
673
|
+
"(%d before dedup, Jedi-planned%s)",
|
|
674
|
+
len(result), converged_total, round_no + 1, len(all_edges),
|
|
675
|
+
", Ray-parallel" if self.using_ray else "",
|
|
676
|
+
)
|
|
677
|
+
return result
|
|
678
|
+
|
|
679
|
+
def _run_fileset_shards_seq(
|
|
680
|
+
self, shards: List[List[str]],
|
|
681
|
+
) -> Tuple[List[PyCallEdge], List[List[str]]]:
|
|
682
|
+
"""Run each file-set shard sequentially; return ``(edges, runaways)``.
|
|
683
|
+
|
|
684
|
+
A shard that times out or raises is returned in *runaways* (its file
|
|
685
|
+
list) for the caller to re-decompose; it contributes no edges.
|
|
686
|
+
"""
|
|
687
|
+
resolver = self._resolver
|
|
688
|
+
edges_all: List[PyCallEdge] = []
|
|
689
|
+
runaways: List[List[str]] = []
|
|
690
|
+
with ProgressBar(len(shards), "Building call graph shards", item_label="shards") as progress:
|
|
691
|
+
for files in shards:
|
|
692
|
+
try:
|
|
693
|
+
with _shard_symlink_root(files, self.project_dir) as (root, eps):
|
|
694
|
+
with _shard_timeout(self.shard_timeout):
|
|
695
|
+
edges = self._run_pycg_batch(eps, root, resolver, prefix="")
|
|
696
|
+
edges_all.extend(edges)
|
|
697
|
+
except (TimeoutError, PyCGExceptions.PyCGAnalysisError):
|
|
698
|
+
runaways.append(files)
|
|
699
|
+
progress.advance()
|
|
700
|
+
return edges_all, runaways
|
|
701
|
+
|
|
702
|
+
def _run_fileset_shards_ray(
|
|
703
|
+
self, shards: List[List[str]],
|
|
704
|
+
) -> Tuple[List[PyCallEdge], List[List[str]]]:
|
|
705
|
+
"""Ray-parallel variant of :meth:`_run_fileset_shards_seq`.
|
|
706
|
+
|
|
707
|
+
Each shard is materialised as a symlink mini-project up front (the trees
|
|
708
|
+
must outlive their remote tasks), submitted as a Ray task, and collected
|
|
709
|
+
against one wall-clock deadline — Ray workers cannot use SIGALRM, so the
|
|
710
|
+
timeout is enforced orchestrator-side. Timed-out/failed shards become
|
|
711
|
+
runaways; symlink trees are removed once the batch completes.
|
|
712
|
+
"""
|
|
713
|
+
import os
|
|
714
|
+
import ray
|
|
715
|
+
|
|
716
|
+
os.environ.setdefault("RAY_IGNORE_UNHANDLED_ERRORS", "1")
|
|
717
|
+
remote_fn = ray.remote(_pycg_shard_worker)
|
|
718
|
+
|
|
719
|
+
roots: List[Path] = []
|
|
720
|
+
futures: List[Any] = []
|
|
721
|
+
meta: Dict[Any, List[str]] = {} # ObjectRef -> shard file list
|
|
722
|
+
edges_all: List[PyCallEdge] = []
|
|
723
|
+
runaways: List[List[str]] = []
|
|
724
|
+
try:
|
|
725
|
+
with ProgressBar(len(shards), "Building call graph shards (parallel)", item_label="shards") as progress:
|
|
726
|
+
for files in shards:
|
|
727
|
+
root, eps = _materialize_shard_root(files, self.project_dir)
|
|
728
|
+
roots.append(root)
|
|
729
|
+
fut = remote_fn.remote(eps, str(root), "", self.max_iter)
|
|
730
|
+
futures.append(fut)
|
|
731
|
+
meta[fut] = files
|
|
732
|
+
|
|
733
|
+
deadline = (
|
|
734
|
+
time.perf_counter() + float(self.shard_timeout)
|
|
735
|
+
if self.shard_timeout > 0 else None
|
|
736
|
+
)
|
|
737
|
+
pending = list(futures)
|
|
738
|
+
while pending:
|
|
739
|
+
if deadline is not None:
|
|
740
|
+
remaining = deadline - time.perf_counter()
|
|
741
|
+
if remaining <= 0:
|
|
742
|
+
break
|
|
743
|
+
else:
|
|
744
|
+
remaining = None
|
|
745
|
+
|
|
746
|
+
ready, pending = ray.wait(pending, num_returns=1, timeout=remaining)
|
|
747
|
+
if not ready:
|
|
748
|
+
break
|
|
749
|
+
|
|
750
|
+
fut = ready[0]
|
|
751
|
+
try:
|
|
752
|
+
triples = ray.get(fut)
|
|
753
|
+
edges_all.extend(
|
|
754
|
+
PyCallEdge(source=s, target=t, weight=w, provenance=["pycg"])
|
|
755
|
+
for s, t, w in triples
|
|
756
|
+
)
|
|
757
|
+
except Exception:
|
|
758
|
+
runaways.append(meta[fut])
|
|
759
|
+
progress.advance()
|
|
760
|
+
|
|
761
|
+
for fut in pending: # exceeded the deadline
|
|
762
|
+
ray.cancel(fut, force=True)
|
|
763
|
+
runaways.append(meta[fut])
|
|
764
|
+
progress.advance()
|
|
765
|
+
finally:
|
|
766
|
+
for root in roots:
|
|
767
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
768
|
+
return edges_all, runaways
|
|
769
|
+
|
|
770
|
+
def _build_sharded(
|
|
771
|
+
self,
|
|
772
|
+
entry_points: List[str],
|
|
773
|
+
resolver: "_PyCGCallableResolver",
|
|
774
|
+
) -> List[PyCallEdge]:
|
|
775
|
+
"""Run PyCG per Python package shard and merge the results.
|
|
776
|
+
|
|
777
|
+
Groups entry points by their top-level package root. Each shard
|
|
778
|
+
whose size is within ``self.shard_ceiling`` is analysed independently
|
|
779
|
+
with its package directory as the PyCG ``package`` root, which limits
|
|
780
|
+
recursive import-following to that package boundary. Shards that
|
|
781
|
+
exceed the shard ceiling are skipped with a warning (framework modules
|
|
782
|
+
with deep mixin hierarchies can cause PyCG's fixpoint to diverge).
|
|
783
|
+
|
|
784
|
+
Edge names are normalised to project-relative dotted paths so they
|
|
785
|
+
match the symbol table's ``PyCallable.signature`` namespace.
|
|
786
|
+
"""
|
|
787
|
+
shards: Dict[Path, List[str]] = defaultdict(list)
|
|
788
|
+
for ep in entry_points:
|
|
789
|
+
pkg_root = self._find_package_root(Path(ep), self.project_dir)
|
|
790
|
+
shards[pkg_root].append(ep)
|
|
791
|
+
|
|
792
|
+
logger.debug(
|
|
793
|
+
"PyCG: sharding %d files into %d package shard(s)",
|
|
794
|
+
len(entry_points), len(shards),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
if self.using_ray:
|
|
798
|
+
return self._build_sharded_ray(shards)
|
|
799
|
+
|
|
800
|
+
all_edges: List[PyCallEdge] = []
|
|
801
|
+
skipped = 0
|
|
802
|
+
with ProgressBar(len(shards), "Building call graph shards", item_label="shards") as progress:
|
|
803
|
+
for pkg_root, files in shards.items():
|
|
804
|
+
n = len(files)
|
|
805
|
+
pkg_label = str(pkg_root.relative_to(self.project_dir)) or "."
|
|
806
|
+
if n > self.shard_ceiling:
|
|
807
|
+
logger.warning(
|
|
808
|
+
"PyCG shard '%s': %d files exceeds shard ceiling of %d — skipped",
|
|
809
|
+
pkg_label, n, self.shard_ceiling,
|
|
810
|
+
)
|
|
811
|
+
skipped += 1
|
|
812
|
+
progress.advance()
|
|
813
|
+
continue
|
|
814
|
+
prefix = self._package_prefix(pkg_root, self.project_dir)
|
|
815
|
+
try:
|
|
816
|
+
with _shard_timeout(self.shard_timeout):
|
|
817
|
+
edges = self._run_pycg_batch(files, pkg_root, resolver, prefix=prefix)
|
|
818
|
+
all_edges.extend(edges)
|
|
819
|
+
logger.debug(
|
|
820
|
+
"PyCG shard '%s': %d edges from %d files",
|
|
821
|
+
pkg_label, len(edges), n,
|
|
822
|
+
)
|
|
823
|
+
except TimeoutError:
|
|
824
|
+
logger.warning(
|
|
825
|
+
"PyCG shard '%s' timed out after %ds — skipped",
|
|
826
|
+
pkg_label, self.shard_timeout,
|
|
827
|
+
)
|
|
828
|
+
skipped += 1
|
|
829
|
+
except PyCGExceptions.PyCGAnalysisError as exc:
|
|
830
|
+
logger.warning("PyCG shard '%s' failed — skipped: %s", pkg_label, exc)
|
|
831
|
+
skipped += 1
|
|
832
|
+
progress.advance()
|
|
833
|
+
|
|
834
|
+
if skipped:
|
|
835
|
+
logger.warning(
|
|
836
|
+
"PyCG: %d shard(s) were skipped (exceeded %d-file ceiling, "
|
|
837
|
+
"%ds timeout, or failed)",
|
|
838
|
+
skipped, self.shard_ceiling, self.shard_timeout,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
# Merge duplicate (source, target) pairs that appear in multiple shards.
|
|
842
|
+
merged: Dict[tuple, PyCallEdge] = {}
|
|
843
|
+
for edge in all_edges:
|
|
844
|
+
key = (edge.source, edge.target)
|
|
845
|
+
if key in merged:
|
|
846
|
+
existing = merged[key]
|
|
847
|
+
merged[key] = PyCallEdge(
|
|
848
|
+
source=existing.source,
|
|
849
|
+
target=existing.target,
|
|
850
|
+
weight=existing.weight + edge.weight,
|
|
851
|
+
provenance=existing.provenance,
|
|
852
|
+
)
|
|
853
|
+
else:
|
|
854
|
+
merged[key] = edge
|
|
855
|
+
|
|
856
|
+
result = list(merged.values())
|
|
857
|
+
logger.info(
|
|
858
|
+
"PyCG: %d edges from %d/%d shard(s) (%d before dedup)",
|
|
859
|
+
len(result), len(shards) - skipped, len(shards), len(all_edges),
|
|
860
|
+
)
|
|
861
|
+
return result
|
|
862
|
+
|
|
863
|
+
def _build_sharded_ray(self, shards: Dict[Path, List[str]]) -> List[PyCallEdge]:
|
|
864
|
+
"""Ray-parallel variant of the sequential shard loop.
|
|
865
|
+
|
|
866
|
+
All eligible shards are submitted as Ray remote tasks simultaneously.
|
|
867
|
+
``ray.wait(timeout=shard_timeout)`` is used to collect results and
|
|
868
|
+
cancel stragglers — Ray workers cannot use SIGALRM, so the timeout is
|
|
869
|
+
enforced at the orchestrator level instead.
|
|
870
|
+
"""
|
|
871
|
+
import os
|
|
872
|
+
import ray
|
|
873
|
+
|
|
874
|
+
# force-cancel kills worker processes; suppress Ray's "worker died
|
|
875
|
+
# unexpectedly" noise since the death is intentional here.
|
|
876
|
+
os.environ.setdefault("RAY_IGNORE_UNHANDLED_ERRORS", "1")
|
|
877
|
+
|
|
878
|
+
remote_fn = ray.remote(_pycg_shard_worker)
|
|
879
|
+
futures: List[Any] = []
|
|
880
|
+
meta: Dict[Any, tuple] = {} # ObjectRef -> (pkg_label, n_files)
|
|
881
|
+
skipped = 0
|
|
882
|
+
|
|
883
|
+
all_edges: List[PyCallEdge] = []
|
|
884
|
+
with ProgressBar(len(shards), "Building call graph shards (parallel)", item_label="shards") as progress:
|
|
885
|
+
for pkg_root, files in shards.items():
|
|
886
|
+
n = len(files)
|
|
887
|
+
pkg_label = str(pkg_root.relative_to(self.project_dir)) or "."
|
|
888
|
+
if n > self.shard_ceiling:
|
|
889
|
+
logger.warning(
|
|
890
|
+
"PyCG shard '%s': %d files exceeds shard ceiling of %d — skipped",
|
|
891
|
+
pkg_label, n, self.shard_ceiling,
|
|
892
|
+
)
|
|
893
|
+
skipped += 1
|
|
894
|
+
progress.advance()
|
|
895
|
+
continue
|
|
896
|
+
prefix = self._package_prefix(pkg_root, self.project_dir)
|
|
897
|
+
fut = remote_fn.remote(files, str(pkg_root), prefix, self.max_iter)
|
|
898
|
+
futures.append(fut)
|
|
899
|
+
meta[fut] = (pkg_label, n)
|
|
900
|
+
|
|
901
|
+
# Collect results one shard at a time so the progress bar ticks per
|
|
902
|
+
# completed shard. A single deadline governs the whole batch: tasks
|
|
903
|
+
# submitted simultaneously all have the same wall-clock budget.
|
|
904
|
+
deadline = (
|
|
905
|
+
time.perf_counter() + float(self.shard_timeout)
|
|
906
|
+
if self.shard_timeout > 0 else None
|
|
907
|
+
)
|
|
908
|
+
pending = list(futures)
|
|
909
|
+
while pending:
|
|
910
|
+
if deadline is not None:
|
|
911
|
+
remaining = deadline - time.perf_counter()
|
|
912
|
+
if remaining <= 0:
|
|
913
|
+
break
|
|
914
|
+
else:
|
|
915
|
+
remaining = None
|
|
916
|
+
|
|
917
|
+
ready, pending = ray.wait(pending, num_returns=1, timeout=remaining)
|
|
918
|
+
if not ready:
|
|
919
|
+
break # deadline reached before any new result
|
|
920
|
+
|
|
921
|
+
fut = ready[0]
|
|
922
|
+
pkg_label, n = meta[fut]
|
|
923
|
+
try:
|
|
924
|
+
triples = ray.get(fut)
|
|
925
|
+
edges = [
|
|
926
|
+
PyCallEdge(source=s, target=t, weight=w, provenance=["pycg"])
|
|
927
|
+
for s, t, w in triples
|
|
928
|
+
]
|
|
929
|
+
all_edges.extend(edges)
|
|
930
|
+
logger.debug(
|
|
931
|
+
"PyCG shard '%s': %d edges from %d files (Ray)",
|
|
932
|
+
pkg_label, len(edges), n,
|
|
933
|
+
)
|
|
934
|
+
except Exception as exc:
|
|
935
|
+
logger.warning("PyCG shard '%s' failed — skipped: %s", pkg_label, exc)
|
|
936
|
+
skipped += 1
|
|
937
|
+
progress.advance()
|
|
938
|
+
|
|
939
|
+
# Cancel any shards that did not complete before the deadline.
|
|
940
|
+
for fut in pending:
|
|
941
|
+
pkg_label, _ = meta[fut]
|
|
942
|
+
logger.warning(
|
|
943
|
+
"PyCG shard '%s' timed out after %ds — skipped",
|
|
944
|
+
pkg_label, self.shard_timeout,
|
|
945
|
+
)
|
|
946
|
+
ray.cancel(fut, force=True)
|
|
947
|
+
skipped += 1
|
|
948
|
+
progress.advance()
|
|
949
|
+
|
|
950
|
+
if skipped:
|
|
951
|
+
logger.warning(
|
|
952
|
+
"PyCG: %d shard(s) were skipped (exceeded %d-file ceiling, "
|
|
953
|
+
"%ds timeout, or failed)",
|
|
954
|
+
skipped, self.shard_ceiling, self.shard_timeout,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
merged: Dict[tuple, PyCallEdge] = {}
|
|
958
|
+
for edge in all_edges:
|
|
959
|
+
key = (edge.source, edge.target)
|
|
960
|
+
if key in merged:
|
|
961
|
+
existing = merged[key]
|
|
962
|
+
merged[key] = PyCallEdge(
|
|
963
|
+
source=existing.source,
|
|
964
|
+
target=existing.target,
|
|
965
|
+
weight=existing.weight + edge.weight,
|
|
966
|
+
provenance=existing.provenance,
|
|
967
|
+
)
|
|
968
|
+
else:
|
|
969
|
+
merged[key] = edge
|
|
970
|
+
|
|
971
|
+
result = list(merged.values())
|
|
972
|
+
logger.info(
|
|
973
|
+
"PyCG: %d edges from %d/%d shard(s) (%d before dedup, Ray-parallel)",
|
|
974
|
+
len(result), len(shards) - skipped, len(shards), len(all_edges),
|
|
975
|
+
)
|
|
976
|
+
return result
|
|
977
|
+
|
|
978
|
+
# ------------------------------------------------------------------
|
|
979
|
+
# Public API
|
|
980
|
+
# ------------------------------------------------------------------
|
|
981
|
+
|
|
982
|
+
def build_call_graph_edges(
|
|
983
|
+
self,
|
|
984
|
+
symbol_table: Dict[str, PyModule],
|
|
985
|
+
jedi_edges: Optional[List[PyCallEdge]] = None,
|
|
986
|
+
) -> List[PyCallEdge]:
|
|
987
|
+
"""Run PyCG and return ``PyCallEdge`` entries with ``provenance=["pycg"]``.
|
|
988
|
+
|
|
989
|
+
Edges are coalesced on ``(source, target)`` — ``weight`` equals the
|
|
990
|
+
number of times PyCG reports the same (caller, callee) pair (always 1
|
|
991
|
+
per unique pair in PyCG's output). Ghost callees (not in the symbol
|
|
992
|
+
table) are preserved so external / library edges appear in the graph.
|
|
993
|
+
|
|
994
|
+
Returns an empty list and logs a warning if pycg is not installed or
|
|
995
|
+
if the analysis raises an unexpected exception.
|
|
996
|
+
|
|
997
|
+
When ``self.shard=True`` and the project exceeds the 500-file ceiling,
|
|
998
|
+
PyCG is run per Python package root (see :meth:`_build_sharded`).
|
|
999
|
+
When ``self.shard=False`` and the project exceeds the ceiling, PyCG is
|
|
1000
|
+
skipped and an empty list is returned (Jedi-only fallback).
|
|
1001
|
+
"""
|
|
1002
|
+
try:
|
|
1003
|
+
self._ensure_pycg_loaded()
|
|
1004
|
+
except PyCGExceptions.PyCGImportError:
|
|
1005
|
+
raise
|
|
1006
|
+
|
|
1007
|
+
entry_points = self._collect_entry_points()
|
|
1008
|
+
if not entry_points:
|
|
1009
|
+
logger.debug("PyCG: no Python files found under %s", self.project_dir)
|
|
1010
|
+
return []
|
|
1011
|
+
|
|
1012
|
+
n_files = len(entry_points)
|
|
1013
|
+
resolver = _PyCGCallableResolver.from_symbol_table(symbol_table)
|
|
1014
|
+
t0 = time.perf_counter()
|
|
1015
|
+
|
|
1016
|
+
if n_files > self._PYCG_FILE_CEILING:
|
|
1017
|
+
if self.shard:
|
|
1018
|
+
if self.shard_strategy == "jedi" and jedi_edges is not None:
|
|
1019
|
+
logger.info(
|
|
1020
|
+
"PyCG: starting Jedi-planned sharded analysis (%d files)",
|
|
1021
|
+
n_files,
|
|
1022
|
+
)
|
|
1023
|
+
edges = self._build_sharded_planned(
|
|
1024
|
+
jedi_edges, symbol_table, resolver
|
|
1025
|
+
)
|
|
1026
|
+
else:
|
|
1027
|
+
mode = "Ray-parallel" if self.using_ray else "sequential"
|
|
1028
|
+
logger.info(
|
|
1029
|
+
"PyCG: starting per-package sharded analysis (%d files, %s)",
|
|
1030
|
+
n_files, mode,
|
|
1031
|
+
)
|
|
1032
|
+
edges = self._build_sharded(entry_points, resolver)
|
|
1033
|
+
else:
|
|
1034
|
+
logger.warning(
|
|
1035
|
+
"PyCG: %d entry points exceeds ceiling of %d — "
|
|
1036
|
+
"skipping pointer analysis (Jedi-only edges will be used). "
|
|
1037
|
+
"Re-run with --pycg-shard to analyse per package shard.",
|
|
1038
|
+
n_files, self._PYCG_FILE_CEILING,
|
|
1039
|
+
)
|
|
1040
|
+
return []
|
|
1041
|
+
else:
|
|
1042
|
+
# Small project (≤ ceiling): whole-project analysis. Run inside a
|
|
1043
|
+
# symlink mini-project mirroring only the (already SKIP_DIRS-filtered)
|
|
1044
|
+
# entry points, so PyCG's package bound covers project source alone.
|
|
1045
|
+
# Pointing PyCG at project_dir directly would put an in-tree
|
|
1046
|
+
# .codeanalyzer venv / site-packages *under* mod_dir, and PyCG would
|
|
1047
|
+
# follow imports into those dependencies and explode the analysis.
|
|
1048
|
+
logger.info("PyCG: starting whole-project call graph analysis (%d files)", n_files)
|
|
1049
|
+
with _shard_symlink_root(entry_points, self.project_dir) as (root, eps):
|
|
1050
|
+
edges = self._run_pycg_batch(eps, root, resolver, prefix="")
|
|
1051
|
+
|
|
1052
|
+
elapsed = time.perf_counter() - t0
|
|
1053
|
+
logger.info("✅ PyCG: %d edges in %.1fs", len(edges), elapsed)
|
|
1054
|
+
return edges
|