codeanalyzer-python 0.2.1__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.
@@ -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