cg-code-graph 0.10.1__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.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,412 @@
1
+ """Python source roots: the directories whose contents are importable by top-level name (what `sys.path` holds when
2
+ the code runs), each with the reason it was chosen.
3
+
4
+ Detection (no configuration needed), in this order of evidence:
5
+ * the indexed root;
6
+ * packaging config at the root and in nested projects: `pyproject.toml` (`[tool.setuptools] package-dir`,
7
+ `packages.find.where`, `[tool.hatch.build] packages / sources`, `[tool.poetry] packages = [{from = ...}]`,
8
+ `[tool.pdm.build] package-dir`, `[tool.maturin] python-source`), `setup.cfg` (`[options] package_dir`,
9
+ `[options.packages.find] where`) and literal `package_dir=` / `find_packages(where)` in `setup.py`;
10
+ * conventional directories `src/`, `lib/`, `python/` that are not packages themselves;
11
+ * nested projects: directories holding `pyproject.toml`, `setup.py`, `setup.cfg` or `manage.py` (depth <= 4);
12
+ * namespace packages (PEP 420): a directory without `__init__.py` that other files import as a package
13
+ (`import acme.core` -> the parent of `acme/` is a root);
14
+ * the parent of every top-level package (a directory with `__init__.py` whose parent has none).
15
+ Configured roots (`python.source_roots` in `.cg.yaml`, or `cg index --python-root`) replace detection.
16
+
17
+ A file reachable from several roots gets one canonical module name: the one under the root whose top-level name the
18
+ project's own imports use most (ties: the deepest root). The other names stay importable aliases."""
19
+ from __future__ import annotations
20
+
21
+ import ast
22
+ import configparser
23
+ from collections import Counter
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path, PurePosixPath
26
+
27
+ NESTED_MARKERS = ("pyproject.toml", "setup.py", "setup.cfg", "manage.py")
28
+ CONVENTIONAL = ("src", "lib", "python")
29
+ NESTED_DEPTH = 4
30
+ MAX_RECORDED = 200
31
+
32
+
33
+ @dataclass
34
+ class SourceRoot:
35
+ path: str # repo-relative directory, "" = the indexed root
36
+ origin: str # detected | configured | flag
37
+ why: list = field(default_factory=list)
38
+ prefix: str = "" # package name the directory maps to (setuptools package-dir {"pkg": "dir"})
39
+
40
+ @property
41
+ def key(self) -> tuple:
42
+ return (self.path, self.prefix)
43
+
44
+ @property
45
+ def depth(self) -> int:
46
+ return len(PurePosixPath(self.path).parts) if self.path else 0
47
+
48
+ def label(self) -> str:
49
+ return (self.path or ".") + "/"
50
+
51
+
52
+ def _rel(base: str, sub: str) -> str | None:
53
+ """Join a packaging-config path to the project dir; None when it leaves the indexed root."""
54
+ p = PurePosixPath(base) / PurePosixPath(str(sub).strip().replace("\\", "/") or ".")
55
+ parts = []
56
+ for x in p.parts:
57
+ if x in (".", ""):
58
+ continue
59
+ if x == "..":
60
+ if not parts:
61
+ return None
62
+ parts.pop()
63
+ else:
64
+ parts.append(x)
65
+ return "/".join(parts)
66
+
67
+
68
+ def _pyproject_roots(path: Path) -> list[tuple[str, str, str]]:
69
+ """[(dir, prefix, why)] from a pyproject.toml (dirs relative to its directory)."""
70
+ try:
71
+ import tomllib
72
+ d = tomllib.loads(path.read_text(encoding="utf-8", errors="replace"))
73
+ except Exception: # noqa: BLE001 (unreadable / invalid TOML: no packaging evidence)
74
+ return []
75
+ tool = d.get("tool") if isinstance(d.get("tool"), dict) else {}
76
+ out = []
77
+ st = tool.get("setuptools") if isinstance(tool.get("setuptools"), dict) else {}
78
+ pd = st.get("package-dir")
79
+ if isinstance(pd, dict):
80
+ for k, v in pd.items():
81
+ if isinstance(k, str) and isinstance(v, str):
82
+ out.append(_mapping(k, v, "pyproject.toml [tool.setuptools] package-dir"))
83
+ pk = st.get("packages")
84
+ find = pk.get("find") if isinstance(pk, dict) else None
85
+ if isinstance(find, dict):
86
+ where = find.get("where")
87
+ for w in ([where] if isinstance(where, str) else where if isinstance(where, list) else []):
88
+ if isinstance(w, str):
89
+ out.append((w, "", "pyproject.toml [tool.setuptools.packages.find] where"))
90
+ hatch = tool.get("hatch") if isinstance(tool.get("hatch"), dict) else {}
91
+ build = hatch.get("build") if isinstance(hatch.get("build"), dict) else {}
92
+ targets = build.get("targets") if isinstance(build.get("targets"), dict) else {}
93
+ for where, sect in [("[tool.hatch.build]", build)] + [(f"[tool.hatch.build.targets.{t}]", v) for t, v in targets.items()
94
+ if isinstance(v, dict)]:
95
+ for p in sect.get("packages") or []:
96
+ if isinstance(p, str):
97
+ parent = str(PurePosixPath(p).parent)
98
+ out.append((parent, "", f"pyproject.toml {where} packages"))
99
+ src = sect.get("sources")
100
+ if isinstance(src, list):
101
+ out += [(s, "", f"pyproject.toml {where} sources") for s in src if isinstance(s, str)]
102
+ elif isinstance(src, dict):
103
+ for k, v in src.items():
104
+ if isinstance(k, str) and isinstance(v, str):
105
+ out.append((k, v.strip("/").replace("/", "."), f"pyproject.toml {where} sources"))
106
+ poetry = tool.get("poetry") if isinstance(tool.get("poetry"), dict) else {}
107
+ for p in poetry.get("packages") or []:
108
+ if isinstance(p, dict) and isinstance(p.get("from"), str):
109
+ out.append((p["from"], "", "pyproject.toml [tool.poetry] packages from"))
110
+ pdm = tool.get("pdm") if isinstance(tool.get("pdm"), dict) else {}
111
+ for where, sect in (("[tool.pdm.build]", pdm.get("build")), ("[tool.pdm]", pdm)):
112
+ if isinstance(sect, dict) and isinstance(sect.get("package-dir"), str):
113
+ out.append((sect["package-dir"], "", f"pyproject.toml {where} package-dir"))
114
+ mat = tool.get("maturin") if isinstance(tool.get("maturin"), dict) else {}
115
+ if isinstance(mat.get("python-source"), str):
116
+ out.append((mat["python-source"], "", "pyproject.toml [tool.maturin] python-source"))
117
+ return out
118
+
119
+
120
+ def _mapping(pkg: str, d: str, why: str) -> tuple[str, str, str]:
121
+ """setuptools package_dir entry: {"": "src"} -> root src; {"core": "lib/core"} -> root lib; {"core": "lib"} ->
122
+ lib/ holds the contents of package core."""
123
+ pkg = pkg.strip()
124
+ d = d.strip()
125
+ if not pkg:
126
+ return (d, "", why)
127
+ last = pkg.split(".")[-1]
128
+ if PurePosixPath(d).name == last:
129
+ parent = str(PurePosixPath(d).parent)
130
+ return (parent, ".".join(pkg.split(".")[:-1]), why)
131
+ return (d, pkg, why)
132
+
133
+
134
+ def _setup_cfg_roots(path: Path) -> list[tuple[str, str, str]]:
135
+ cp = configparser.ConfigParser(interpolation=None)
136
+ try:
137
+ cp.read_string(path.read_text(encoding="utf-8", errors="replace"))
138
+ except Exception: # noqa: BLE001
139
+ return []
140
+ out = []
141
+ if cp.has_option("options", "package_dir"):
142
+ for line in cp.get("options", "package_dir").splitlines():
143
+ if "=" in line:
144
+ k, v = line.split("=", 1)
145
+ if v.strip():
146
+ out.append(_mapping(k, v, "setup.cfg [options] package_dir"))
147
+ if cp.has_option("options.packages.find", "where"):
148
+ for w in cp.get("options.packages.find", "where").split():
149
+ out.append((w, "", "setup.cfg [options.packages.find] where"))
150
+ return out
151
+
152
+
153
+ def _setup_py_roots(path: Path) -> list[tuple[str, str, str]]:
154
+ try:
155
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
156
+ except (SyntaxError, ValueError, RecursionError, OSError):
157
+ return []
158
+ out = []
159
+ for n in ast.walk(tree):
160
+ if not isinstance(n, ast.Call):
161
+ continue
162
+ fname = n.func.attr if isinstance(n.func, ast.Attribute) else n.func.id if isinstance(n.func, ast.Name) else ""
163
+ if fname == "setup":
164
+ for k in n.keywords:
165
+ if k.arg == "package_dir" and isinstance(k.value, ast.Dict):
166
+ for kk, vv in zip(k.value.keys, k.value.values):
167
+ if isinstance(kk, ast.Constant) and isinstance(kk.value, str) and \
168
+ isinstance(vv, ast.Constant) and isinstance(vv.value, str):
169
+ out.append(_mapping(kk.value, vv.value, "setup.py package_dir"))
170
+ elif fname in ("find_packages", "find_namespace_packages"):
171
+ w = n.args[0] if n.args else next((k.value for k in n.keywords if k.arg == "where"), None)
172
+ if isinstance(w, ast.Constant) and isinstance(w.value, str):
173
+ out.append((w.value, "", f"setup.py {fname}({w.value!r})"))
174
+ return out
175
+
176
+
177
+ def packaging_roots(root: Path, project_dir: str) -> list[tuple[str, str, str]]:
178
+ """Source roots stated by the packaging config of the project at `project_dir` (repo-relative)."""
179
+ base = root / project_dir if project_dir else root
180
+ out = []
181
+ for fn, fnc in (("pyproject.toml", _pyproject_roots), ("setup.cfg", _setup_cfg_roots), ("setup.py", _setup_py_roots)):
182
+ p = base / fn
183
+ if p.is_file():
184
+ for d, prefix, why in fnc(p):
185
+ rel = _rel(project_dir, d)
186
+ if rel is not None:
187
+ where = f"{project_dir}/{why}" if project_dir else why
188
+ out.append((rel, prefix, where))
189
+ return out
190
+
191
+
192
+ def import_names(trees) -> Counter:
193
+ """Absolute dotted names the given module ASTs import, with how often: `import a.b` -> a.b; `from a.b import c`
194
+ -> a.b and a.b.c (c may be a submodule)."""
195
+ out: Counter = Counter()
196
+ for imps in trees:
197
+ for st in imps:
198
+ if isinstance(st, ast.Import):
199
+ for a in st.names:
200
+ out[a.name] += 1
201
+ elif isinstance(st, ast.ImportFrom) and not st.level and st.module:
202
+ out[st.module] += 1
203
+ for a in st.names:
204
+ if a.name != "*":
205
+ out[f"{st.module}.{a.name}"] += 1
206
+ return out
207
+
208
+
209
+ class RootPlan:
210
+ """The chosen source roots, plus the canonical module name of every file."""
211
+
212
+ def __init__(self, root: Path, files: list[str], imports: Counter, configured: list[str] | None = None,
213
+ origin: str = "configured", skip_dirs=frozenset()):
214
+ self.root = root
215
+ self.files = files
216
+ self.imports = imports
217
+ self.warnings: list[str] = []
218
+ self.ambiguous: list[dict] = []
219
+ self.collisions: list[dict] = []
220
+ self.n_collisions = 0 # files whose preferred module name another file holds
221
+ self.n_requalified = 0 # files named by their path from the indexed root because their tree clashed
222
+ self.mode = origin if configured is not None else "detected"
223
+ self.roots: dict[tuple, SourceRoot] = {}
224
+ self._skip = skip_dirs
225
+ mods = [f[:-3] for f in files]
226
+ self.file_set = set(files)
227
+ self.dirs: set[str] = set()
228
+ for f in files:
229
+ p = PurePosixPath(f).parent
230
+ while str(p) != ".":
231
+ self.dirs.add(str(p))
232
+ p = p.parent
233
+ self.pkg_dirs = {str(PurePosixPath(m).parent) for m in mods if PurePosixPath(m).name == "__init__"}
234
+ self.pkg_dirs.discard(".")
235
+ self.root_is_pkg = "__init__.py" in self.file_set
236
+ if configured is not None:
237
+ for c in configured:
238
+ if c and not (root / c).is_dir():
239
+ self.warnings.append(f"configured source root {c}/ does not exist")
240
+ continue
241
+ self._add(c, origin, "configured in .cg.yaml" if origin == "configured" else "--python-root")
242
+ if not self.roots:
243
+ self.warnings.append("no source root: none of the configured source roots exists")
244
+ else:
245
+ self._detect()
246
+
247
+ # ---- detection
248
+ def _add(self, path: str, origin: str, why: str, prefix: str = "") -> None:
249
+ if path and path not in self.dirs and origin == "detected":
250
+ return # no .py file below it
251
+ r = self.roots.get((path, prefix))
252
+ if r is None:
253
+ self.roots[(path, prefix)] = SourceRoot(path, origin, [why], prefix)
254
+ elif why not in r.why:
255
+ r.why.append(why)
256
+
257
+ def _nested_projects(self) -> list[str]:
258
+ """Directories (depth <= NESTED_DEPTH) holding a project marker, not packages themselves, with .py files below."""
259
+ out = []
260
+ for d in sorted(self.dirs):
261
+ if d.count("/") + 1 > NESTED_DEPTH or d in self.pkg_dirs:
262
+ continue
263
+ if any(x in self._skip for x in d.split("/")):
264
+ continue
265
+ if f"{d}/manage.py" in self.file_set or any((self.root / d / m).is_file() for m in NESTED_MARKERS[:3]):
266
+ out.append(d)
267
+ return out
268
+
269
+ def _detect(self) -> None:
270
+ self._add("", "detected", "indexed root")
271
+ projects = [""] + self._nested_projects()
272
+ for pd in projects:
273
+ if pd:
274
+ marker = next(m for m in NESTED_MARKERS if (self.root / pd / m).exists())
275
+ self._add(pd, "detected", f"nested project ({pd}/{marker})")
276
+ for d, prefix, why in packaging_roots(self.root, pd):
277
+ self._add(d, "detected", why, prefix)
278
+ for c in CONVENTIONAL:
279
+ d = f"{pd}/{c}" if pd else c
280
+ if d in self.dirs and d not in self.pkg_dirs:
281
+ self._add(d, "detected", f"{c}/ directory")
282
+ # namespace packages: a directory without __init__.py imported as a package (acme.core -> parent of acme/)
283
+ by_name: dict[str, list[str]] = {}
284
+ for d in self.dirs:
285
+ if d not in self.pkg_dirs:
286
+ by_name.setdefault(PurePosixPath(d).name, []).append(d)
287
+ ns_dirs = set()
288
+ for name in sorted(self.imports):
289
+ parts = name.split(".")
290
+ if len(parts) < 2 or parts[0] not in by_name:
291
+ continue
292
+ for d in by_name[parts[0]]:
293
+ sub = f"{d}/{parts[1]}"
294
+ if sub not in self.dirs and f"{sub}.py" not in self.file_set:
295
+ continue
296
+ parent = str(PurePosixPath(d).parent)
297
+ parent = "" if parent == "." else parent
298
+ if parent in self.pkg_dirs or (not parent and self.root_is_pkg):
299
+ continue
300
+ ns_dirs.add(d)
301
+ self._add(parent, "detected", f"namespace package {parts[0]} (imported as {parts[0]}.{parts[1]})")
302
+ # the parent of every top-level package (several package roots in one repo)
303
+ for d in sorted(self.pkg_dirs):
304
+ name = PurePosixPath(d).name
305
+ parent = str(PurePosixPath(d).parent)
306
+ parent = "" if parent == "." else parent
307
+ if not name.isidentifier() or parent in self.pkg_dirs or parent in ns_dirs:
308
+ continue
309
+ if any(parent == n or parent.startswith(n + "/") for n in ns_dirs):
310
+ continue # inside a namespace package: the namespace's parent is the root
311
+ self._add(parent, "detected", f"parent of top-level package {name}")
312
+
313
+ # ---- naming
314
+ def candidates(self, rel: str) -> list[tuple[str, bool, SourceRoot]]:
315
+ """Importable (name, is_pkg, root) of a file under every root that contains it."""
316
+ p = PurePosixPath(rel)
317
+ mp = list(p.with_suffix("").parts)
318
+ out = []
319
+ for i in range(len(mp)): # i = number of leading path parts that form the root directory
320
+ base = "/".join(mp[:i])
321
+ for prefix in self._prefixes.get(base, ()):
322
+ r = self.roots[(base, prefix)]
323
+ sub = mp[i:]
324
+ is_pkg = sub[-1] == "__init__"
325
+ if is_pkg:
326
+ sub = sub[:-1]
327
+ if prefix:
328
+ sub = prefix.split(".") + sub
329
+ if sub and all(x.isidentifier() for x in sub):
330
+ out.append((".".join(sub), is_pkg, r))
331
+ return out
332
+
333
+ def assign(self) -> tuple[dict, list[str]]:
334
+ """{file: (canonical name, is_pkg, [alias names])} and the files no root maps (unmapped)."""
335
+ self._prefixes: dict[str, list[str]] = {}
336
+ for (path, prefix) in self.roots:
337
+ self._prefixes.setdefault(path, []).append(prefix)
338
+ cands = {f: self.candidates(f) for f in self.files}
339
+ # import evidence per (root, top-level name): how often the project's own imports use names under it
340
+ index: dict[str, list[tuple]] = {}
341
+ for f, cs in cands.items():
342
+ for name, _, r in cs:
343
+ index.setdefault(name, []).append((r.key, name.split(".")[0]))
344
+ score: Counter = Counter()
345
+ for name, n in self.imports.items():
346
+ for k in set(index.get(name, ())):
347
+ score[k] += n
348
+ prefs: dict[str, list] = {}
349
+ out, unmapped = {}, []
350
+ for f, cs in cands.items():
351
+ ranked = sorted(cs, key=lambda c: (-score[(c[2].key, c[0].split(".")[0])], -c[2].depth, len(c[0])))
352
+ prefs[f] = ranked
353
+ used = [c for c in ranked if score[(c[2].key, c[0].split(".")[0])] > 0]
354
+ if len(used) > 1 and len(self.ambiguous) < MAX_RECORDED:
355
+ self.ambiguous.append({"file": f, "chosen": used[0][0], "also": [c[0] for c in used[1:3]],
356
+ "imports": [score[(c[2].key, c[0].split(".")[0])] for c in used[:3]]})
357
+ # one module name per file. A package tree (files preferring the same root and top-level name) keeps its names
358
+ # together: when another tree already holds one of them, the whole tree falls back to its path from the indexed
359
+ # root (unique, and relative imports inside the tree still resolve). Trees with more import evidence go first,
360
+ # then trees without another importable name, then the shallower root.
361
+ groups: dict[tuple, list[str]] = {}
362
+ for f, ranked in prefs.items():
363
+ if ranked:
364
+ groups.setdefault((ranked[0][2].key, ranked[0][0].split(".")[0]), []).append(f)
365
+ else:
366
+ unmapped.append(f)
367
+
368
+ def gkey(item):
369
+ (rk, anchor), fs = item
370
+ alt = any(len(prefs[f]) > 1 for f in fs)
371
+ return (-score[(rk, anchor)], alt, len(PurePosixPath(rk[0]).parts) if rk[0] else 0, rk, anchor)
372
+
373
+ taken: dict[str, str] = {}
374
+ for (rk, anchor), fs in sorted(groups.items(), key=gkey):
375
+ fs = sorted(fs, key=lambda f: (not prefs[f][0][1], f)) # pkg/__init__.py before a pkg.py twin
376
+ clash = next((f for f in fs if prefs[f][0][0] in taken), None)
377
+ for f in fs:
378
+ c = prefs[f][0]
379
+ name, is_pkg, root = c[0], c[1], c[2]
380
+ if clash is not None or name in taken:
381
+ mp = list(PurePosixPath(f).with_suffix("").parts)
382
+ if mp[-1] == "__init__":
383
+ mp = mp[:-1]
384
+ fallback = ".".join(mp)
385
+ ok = bool(fallback) and fallback not in taken
386
+ if name in taken:
387
+ self.n_collisions += 1
388
+ if len(self.collisions) < MAX_RECORDED:
389
+ self.collisions.append({"file": f, "name": name, "with": taken[name],
390
+ **({"named": fallback} if ok else {})})
391
+ if not ok:
392
+ unmapped.append(f)
393
+ continue
394
+ self.n_requalified += 1
395
+ name, root = fallback, None
396
+ taken[name] = f
397
+ out[f] = (name, is_pkg, [x[0] for x in prefs[f] if x[0] != name], root)
398
+ return out, sorted(unmapped)
399
+
400
+ def report(self, owners: Counter) -> list[dict]:
401
+ """Roots that own at least one canonical module, with origin and reason (for stats / coverage)."""
402
+ out = []
403
+ for r in sorted(self.roots.values(), key=lambda r: (r.path, r.prefix)):
404
+ n = owners.get(r.key, 0)
405
+ if not n and self.mode == "detected":
406
+ continue
407
+ e = {"path": r.label(), "origin": r.origin, "why": "; ".join(r.why[:2]) + (f" (+{len(r.why) - 2})" if len(r.why) > 2 else ""),
408
+ "modules": n}
409
+ if r.prefix:
410
+ e["package"] = r.prefix
411
+ out.append(e)
412
+ return out[:MAX_RECORDED]
@@ -0,0 +1,210 @@
1
+ """python-socketio / Flask-SocketIO events -> endpoint:socketio:<namespace>#<event> (the #31 protocol model).
2
+
3
+ Objects: module-level `sio = socketio.AsyncServer(...)` / `Server` / `flask_socketio.SocketIO(app)` (server) and
4
+ `socketio.AsyncClient()` / `Client` / `SimpleClient` (client), used in their module or imported by name elsewhere.
5
+
6
+ receive @sio.on('event', namespace='/ns') / @sio.event (the function name is the event) / sio.on('event', handler);
7
+ class namespaces (socketio.Namespace / AsyncNamespace: on_<event> methods, namespace from
8
+ register_namespace(Cls('/ns')))
9
+ send sio.emit('event', data, to=room, namespace='/ns') / sio.call(...) (role request) / sio.send(data) (event
10
+ `message`); f-string event names become `{param}` templates
11
+ guards a server namespace's `connect` handler that rejects (raises ConnectionRefusedError / returns False) is the
12
+ guard of every server receiver in that namespace; without one the receivers record guards [] (`unguarded`)
13
+ connect / disconnect / connect_error are lifecycle callbacks, not endpoints.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+
19
+ from ...core.model import EXACT, RESOLVED
20
+ from ...protocols import protocol_receive, protocol_send
21
+
22
+ SERVER = {"socketio.Server", "socketio.AsyncServer", "flask_socketio.SocketIO"}
23
+ CLIENT = {"socketio.Client", "socketio.AsyncClient", "socketio.SimpleClient", "socketio.AsyncSimpleClient"}
24
+ NAMESPACE_BASES = ("socketio.Namespace", "socketio.AsyncNamespace", "socketio.ClientNamespace", "socketio.AsyncClientNamespace",
25
+ "flask_socketio.Namespace")
26
+ LIFECYCLE = {"connect", "disconnect", "connect_error"}
27
+ LIB = "python-socketio"
28
+
29
+
30
+ def _full(m, dn: str | None) -> str | None:
31
+ if not dn:
32
+ return None
33
+ head, _, rest = dn.partition(".")
34
+ imp = m.imports.get(head)
35
+ if not imp:
36
+ return None
37
+ if imp[0] in ("mod", "modprefix"):
38
+ base = imp[1]
39
+ elif imp[0] == "sym":
40
+ base = f"{imp[1]}.{imp[2]}"
41
+ else:
42
+ return None
43
+ return f"{base}.{rest}" if rest else base
44
+
45
+
46
+ def _event(e) -> str | None:
47
+ if isinstance(e, ast.Constant) and isinstance(e.value, str):
48
+ return e.value
49
+ if isinstance(e, ast.JoinedStr):
50
+ out = []
51
+ for v in e.values:
52
+ if isinstance(v, ast.Constant):
53
+ out.append(str(v.value))
54
+ elif isinstance(v, ast.FormattedValue):
55
+ d = v.value
56
+ nm = d.attr if isinstance(d, ast.Attribute) else d.id if isinstance(d, ast.Name) else "param"
57
+ out.append("{" + nm + "}")
58
+ return "".join(out)
59
+ return None
60
+
61
+
62
+ def _str(e) -> str | None:
63
+ return e.value if isinstance(e, ast.Constant) and isinstance(e.value, str) else None
64
+
65
+
66
+ def _kw(call, name):
67
+ for k in call.keywords:
68
+ if k.arg == name:
69
+ return k.value
70
+ return None
71
+
72
+
73
+ def _rejects(fn) -> bool:
74
+ for sub in ast.walk(fn):
75
+ if isinstance(sub, ast.Raise) and sub.exc is not None:
76
+ t = sub.exc.func if isinstance(sub.exc, ast.Call) else sub.exc
77
+ if (getattr(t, "id", None) or getattr(t, "attr", None)) in ("ConnectionRefusedError",):
78
+ return True
79
+ if isinstance(sub, ast.Return) and isinstance(sub.value, ast.Constant) and sub.value.value is False:
80
+ return True
81
+ return False
82
+
83
+
84
+ def index(prog, b, walk_body) -> dict:
85
+ mods = [m for m in prog.modules.values()
86
+ if any((v[1] if v[0] != "sym" else v[1]).split(".")[0] in ("socketio", "flask_socketio") for v in m.imports.values()
87
+ if isinstance(v, tuple) and len(v) > 1 and isinstance(v[1], str))]
88
+ if not mods:
89
+ return {}
90
+ objs: dict[tuple[str, str], str] = {}
91
+ for m in mods:
92
+ for st in m.tree.body:
93
+ if isinstance(st, (ast.Assign, ast.AnnAssign)) and isinstance(st.value, ast.Call):
94
+ full = _full(m, _dn(st.value.func))
95
+ role = "server" if full in SERVER else "client" if full in CLIENT else None
96
+ tgts = st.targets if isinstance(st, ast.Assign) else [st.target]
97
+ if role:
98
+ for t in tgts:
99
+ if isinstance(t, ast.Name):
100
+ objs[(m.name, t.id)] = role
101
+ if not objs:
102
+ return {}
103
+
104
+ def obj_of(m, name):
105
+ if (m.name, name) in objs:
106
+ return objs[(m.name, name)]
107
+ imp = m.imports.get(name)
108
+ if imp and imp[0] == "sym":
109
+ return objs.get((imp[1], imp[2]))
110
+ return None
111
+
112
+ st = {"objects": len(objs), "receivers": 0, "sends": 0}
113
+ recvs = [] # (namespace, event, func, line, conf, role)
114
+ guards: dict[str, list] = {}
115
+ servers_ns: set = set()
116
+ ns_of_class: dict[str, str] = {}
117
+ for m in prog.modules.values():
118
+ for sub in ast.walk(m.tree):
119
+ if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr == "register_namespace" \
120
+ and sub.args and isinstance(sub.args[0], ast.Call):
121
+ cn = _dn(sub.args[0].func)
122
+ ns = _str(sub.args[0].args[0]) if sub.args[0].args else None
123
+ if cn:
124
+ ns_of_class[cn.split(".")[-1]] = ns or "/"
125
+ for f in prog.funcs.values():
126
+ m = f.module
127
+ for d in f.decorators:
128
+ call = d if isinstance(d, ast.Call) else None
129
+ fn = call.func if call else d
130
+ if not isinstance(fn, ast.Attribute) or not isinstance(fn.value, ast.Name):
131
+ continue
132
+ role = obj_of(m, fn.value.id)
133
+ if not role:
134
+ continue
135
+ ns = (_str(_kw(call, "namespace")) if call else None) or "/"
136
+ if fn.attr == "on" and call and call.args:
137
+ ev = _event(call.args[0])
138
+ elif fn.attr == "event":
139
+ ev = f.name
140
+ else:
141
+ continue
142
+ if not ev:
143
+ continue
144
+ if ev in LIFECYCLE:
145
+ if ev == "connect" and role == "server":
146
+ servers_ns.add(ns)
147
+ if _rejects(f.node):
148
+ guards.setdefault(ns, []).append(f"{f.name} (connect handler rejects)")
149
+ continue
150
+ recvs.append((ns, ev, f, d.lineno, EXACT, role))
151
+ for c in prog.classes.values():
152
+ if prog.subclass_of(c, *NAMESPACE_BASES):
153
+ client = prog.subclass_of(c, "socketio.ClientNamespace", "socketio.AsyncClientNamespace")
154
+ ns = ns_of_class.get(c.name, "/")
155
+ for name, f in c.methods.items():
156
+ if not name.startswith("on_"):
157
+ continue
158
+ ev = name[3:]
159
+ if ev in LIFECYCLE:
160
+ if ev == "connect" and not client:
161
+ servers_ns.add(ns)
162
+ if _rejects(f.node):
163
+ guards.setdefault(ns, []).append(f"{c.name}.{name} (connect handler rejects)")
164
+ continue
165
+ recvs.append((ns, ev, f, f.line, RESOLVED, "client" if client else "server"))
166
+ for f in prog.funcs.values():
167
+ m = f.module
168
+ for sub in walk_body(f.node):
169
+ if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and isinstance(sub.func.value, ast.Name)):
170
+ continue
171
+ role = obj_of(m, sub.func.value.id)
172
+ if not role:
173
+ continue
174
+ a = sub.func.attr
175
+ ns = _str(_kw(sub, "namespace")) or "/"
176
+ if a == "on" and len(sub.args) >= 2: # sio.on('event', handler)
177
+ ev = _event(sub.args[0])
178
+ h = prog.infer(sub.args[1], _ctx(prog, f))
179
+ if ev and ev not in LIFECYCLE and h and h[0] in ("func", "bound"):
180
+ recvs.append((ns, ev, h[1], sub.lineno, RESOLVED, role))
181
+ continue
182
+ if a not in ("emit", "call", "send"):
183
+ continue
184
+ ev = "message" if a == "send" else (_event(sub.args[0]) if sub.args else None)
185
+ if not ev:
186
+ continue
187
+ room = _kw(sub, "to") or _kw(sub, "room")
188
+ protocol_send(b, "socketio", f"{ns}#{ev}", f.id, f.file, sub.lineno, EXACT if "{" not in ev else RESOLVED,
189
+ role="request" if a == "call" else "emit", library=LIB, process=role,
190
+ room=_str(room) if room is not None else None, ack=a == "call" or None,
191
+ node_attrs={"namespace": ns, "event": ev})
192
+ st["sends"] += 1
193
+ for ns, ev, f, line, conf, role in recvs:
194
+ g = (guards.get(ns, []) if role == "server" else None)
195
+ protocol_receive(b, "socketio", f"{ns}#{ev}", f.id, f.file, line, conf, guards=g, library=LIB, process=role,
196
+ node_attrs={"namespace": ns, "event": ev})
197
+ st["receivers"] += 1
198
+ if guards:
199
+ st["guarded_namespaces"] = sorted(guards)
200
+ return st
201
+
202
+
203
+ def _dn(e):
204
+ from .plugin import dotted
205
+ return dotted(e)
206
+
207
+
208
+ def _ctx(prog, f):
209
+ from .plugin import Ctx
210
+ return Ctx(f.module, f, f.cls)