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,864 @@
1
+ """Programs a function runs in a subprocess, linked to the project's entry point (stdlib `ast`, `shlex`; no code runs).
2
+
3
+ A call that starts a process (`subprocess.run / call / check_call / check_output / Popen / getoutput`,
4
+ `asyncio.create_subprocess_exec / _shell`, `os.system / popen / exec*`) with an argument list or command line cg can
5
+ evaluate statically gets CALLS (attrs.via = "subprocess", attrs.how) to what it runs, when that is part of the project:
6
+ * `[sys.executable | "python" | "python3", "-m", "pkg.mod", ...]` (also `-mpkg.mod`, interpreter options such as
7
+ `-u` / `-X dev` / `-W error` before it) -> `script:pkg.mod` (the `__main__` block), `script:pkg.__main__` for a
8
+ package, else the module itself (its body runs);
9
+ * `[python, "-c", "<code>"]` -> the functions / classes the snippet calls, its imports resolved like a module's;
10
+ * `[python, "path/to/tool.py"]` -> that file's `__main__` block or module (matched by path suffix; a bare
11
+ `tool.py` / `./tool.py` only at the project root, since it runs from an unknown working directory);
12
+ * `["mytool", ...]` / `[shutil.which("mytool"), ...]` where `mytool` is a console / GUI script the project
13
+ declares -> its `script:console_scripts:mytool` node.
14
+ Argument lists are evaluated from literals, `sys.executable`, `shutil.which(...)`, `str()` / `os.fspath()` /
15
+ `os.path.join()` / `Path(...) / "x.py"`, `+` and `*` concatenation, local and module variables, the return value of a
16
+ project function (`self._args() + args`) and command strings (`shell=True`, `os.system`) split like a shell. When
17
+ the program is a parameter (`def run_cli(*args): subprocess.run([sys.executable, "-m", "pkg.cli", *args])` has it
18
+ fixed, `def run(module, *args)` does not), the function is a runner and each call site that passes it is evaluated
19
+ with its arguments bound, through up to 5 helpers, so a project's own CLI test helper links every test that uses it.
20
+ `CliRunner().invoke(app, ...)` (click / typer testing) on a typer app or click group object gets REFERENCES_FN to
21
+ the commands registered on it (a click group function is already referenced as a callback).
22
+ Test code's edges become TEST_CALLS (tests_index.isolate_tests). Programs outside the project (`git`, `-m pip`) and
23
+ argument lists that cannot be evaluated add no edge (counted in the stats).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import ast
28
+ import re
29
+ import shlex
30
+ from collections import defaultdict
31
+
32
+ from ...core.model import RESOLVED
33
+
34
+ SPAWN_ARGV = {"run", "call", "check_call", "check_output", "Popen", "getoutput", "getstatusoutput"}
35
+ SPAWN_MODS = {"subprocess", "asyncio", "os", "anyio", "trio"}
36
+ SPAWN_VARARGS = {"create_subprocess_exec", "execl", "execlp", "execle", "spawnl", "spawnlp"} # program, *args
37
+ SPAWN_SHELL = {"system", "popen", "create_subprocess_shell"}
38
+ SPAWN_LIST_AT1 = {"execv", "execvp", "execve", "execvpe", "spawnv", "spawnvp"} # (path, argv)
39
+ PY_NAMES = re.compile(r"^(?:.*[/\\])?(?:python|pypy)(?:\d+(?:\.\d+)?)?(?:\.exe)?$")
40
+ PY_FLAGS_ARG = {"-W", "-X", "--check-hash-based-pycs"}
41
+ PY_SHORT = set("uEIBOsSbqPdvRih")
42
+ MAX_HOPS = 5
43
+ # installed test-runner helpers whose program is a parameter: (class, method) -> argument shape. Their source is not in
44
+ # the project, so a small table stands in for following it.
45
+ EXT_RUNNERS = {
46
+ ("scripttest.TestFileEnvironment", "run"): "varargs", # env.run("mytool", "arg"); pip's script.run
47
+ ("_pytest.pytester.Pytester", "run"): "varargs", # pytester.run(sys.executable, "-m", "pkg")
48
+ ("pytest.Pytester", "run"): "varargs",
49
+ ("_pytest.pytester.Testdir", "run"): "varargs",
50
+ ("pytest.Testdir", "run"): "varargs",
51
+ }
52
+ PYTESTER_FIXTURES = {"pytester", "testdir"}
53
+ COPY_FUNCS = {"copy", "copy2", "copyfile"}
54
+ UNK = ("unk",)
55
+ PY = ("py",)
56
+
57
+
58
+ class Thunk:
59
+ """An argument expression to evaluate in its caller's context (with the caller's own bindings)."""
60
+ __slots__ = ("e", "ctx", "env", "spread", "line")
61
+
62
+ def __init__(self, e, ctx, env, spread=False, line=None):
63
+ self.e, self.ctx, self.env, self.spread = e, ctx, env, spread
64
+ self.line = line if line is not None else getattr(e, "lineno", None)
65
+
66
+
67
+ class Subprocesses:
68
+ def __init__(self, prog, b, Ctx, FuncInfo, ClassInfo, dotted, walk_body, rp=None, root=None):
69
+ self.rp, self.root = rp, root
70
+ self._files_by_name = None
71
+ self.prog, self.b, self.Ctx, self.FuncInfo, self.ClassInfo = prog, b, Ctx, FuncInfo, ClassInfo
72
+ self.dotted, self.walk_body = dotted, walk_body
73
+ self.st = defaultdict(int)
74
+ self.samples = defaultdict(list)
75
+ self.runners: dict = {} # func id -> [(kind, payload)]: spawn argv / call to another runner
76
+ self.by_file = {m.file: m for m in prog.modules.values()}
77
+ self.scripts = defaultdict(list) # console / GUI script name -> script node ids
78
+ for nid, n in b.nodes.items():
79
+ a = n.attrs or {}
80
+ if n.kind == "script" and a.get("group") in ("console_scripts", "gui_scripts") and a.get("script"):
81
+ self.scripts[a["script"]].append(nid)
82
+
83
+ # ------------------------------------------------------------------ recognising a process start
84
+ def spawn(self, call, ctx):
85
+ """(argv thunk list | None, shell: bool) when `call` starts a process, else None."""
86
+ fn = call.func
87
+ got = self.lib_spawn(call, ctx)
88
+ if got is not None:
89
+ return got
90
+ name = fn.attr if isinstance(fn, ast.Attribute) else fn.id if isinstance(fn, ast.Name) else None
91
+ if name is None:
92
+ return None
93
+ allowed = SPAWN_ARGV | SPAWN_VARARGS | SPAWN_SHELL | SPAWN_LIST_AT1
94
+ if name not in allowed:
95
+ return None
96
+ if isinstance(fn, ast.Attribute):
97
+ head = self.dotted(fn.value)
98
+ if head is None:
99
+ return None
100
+ r = self.prog.resolve_name(ctx.mod, head.split(".")[0])
101
+ mod = head if r is None else (r[1].name if r[0] == "mod" else r[1] if r[0] == "ext" else None)
102
+ if not isinstance(mod, str) or mod.split(".")[0] not in SPAWN_MODS:
103
+ return None
104
+ mod = mod.split(".")[0]
105
+ else:
106
+ imp = ctx.mod.imports.get(name)
107
+ if not (imp and imp[0] == "sym" and imp[1].split(".")[0] in SPAWN_MODS):
108
+ return None
109
+ mod = imp[1].split(".")[0]
110
+ if name in ("run", "call") and mod not in ("subprocess",):
111
+ return None # trio.run(f), anyio.run(f): run a coroutine, not a process
112
+ if name in SPAWN_VARARGS:
113
+ return ([Thunk(a.value if isinstance(a, ast.Starred) else a, ctx, None, isinstance(a, ast.Starred))
114
+ for a in call.args], False)
115
+ if name in SPAWN_LIST_AT1:
116
+ return ([Thunk(call.args[1], ctx, None, True)] if len(call.args) > 1 else None, False)
117
+ argv = call.args[0] if call.args else next((k.value for k in call.keywords if k.arg in ("args", "cmd")), None)
118
+ if argv is None:
119
+ return None
120
+ shell = name in SPAWN_SHELL or name in ("getoutput", "getstatusoutput") or any(
121
+ k.arg == "shell" and isinstance(k.value, ast.Constant) and k.value.value for k in call.keywords)
122
+ return ([Thunk(argv, ctx, None, True)], shell)
123
+
124
+ def varargs(self, call, ctx, head=()):
125
+ return (list(head) + [Thunk(a.value if isinstance(a, ast.Starred) else a, ctx, None, isinstance(a, ast.Starred))
126
+ for a in call.args], False)
127
+
128
+ def lib_spawn(self, call, ctx):
129
+ """Process starts through installed helpers: the EXT_RUNNERS table, `sh`, `plumbum`."""
130
+ fn, prog = call.func, self.prog
131
+ if isinstance(fn, ast.Attribute):
132
+ # (class, method) table: the receiver's inferred class or one of its external bases
133
+ if fn.attr in {m for _c, m in EXT_RUNNERS}:
134
+ try:
135
+ t = prog.infer(fn.value, ctx)
136
+ except RecursionError:
137
+ t = None
138
+ classes = set()
139
+ if isinstance(fn.value, ast.Call) and isinstance(fn.value.func, ast.Name) \
140
+ and fn.value.func.id == "super" and ctx.cls is not None:
141
+ classes |= prog.ext_bases(ctx.cls) # super().run(...) in a subclass of the runner
142
+ elif t and t[0] == "einst":
143
+ classes.add(t[1])
144
+ elif t and t[0] in ("inst", "type") and prog.find_method(t[1], fn.attr) is None:
145
+ classes |= prog.ext_bases(t[1]) # not overridden in the project
146
+ elif t and t[0] == "super":
147
+ classes |= prog.ext_bases(t[1])
148
+ elif isinstance(fn.value, ast.Name) and fn.value.id in PYTESTER_FIXTURES and ctx.func is not None \
149
+ and any(a.arg == fn.value.id for a in ctx.func.node.args.args) \
150
+ and prog.module("_pytest.pytester") is None:
151
+ classes.add("_pytest.pytester.Pytester") # the pytest fixture, pytest installed
152
+ if any((c, fn.attr) in EXT_RUNNERS for c in classes):
153
+ self.st["external_runner_calls"] += 1
154
+ return self.varargs(call, ctx)
155
+ # sh: sh.mytool(*args), sh.Command("mytool")(*args) handled below
156
+ head = self.dotted(fn.value)
157
+ if head and self.ext_root(head, ctx) == "sh" and fn.attr not in ("Command", "ErrorReturnCode"):
158
+ self.st["sh_calls"] += 1
159
+ return self.varargs(call, ctx, [Thunk(ast.Constant(fn.attr.replace("_", "-")), ctx, None)])
160
+ if isinstance(fn, ast.Name):
161
+ imp = ctx.mod.imports.get(fn.id)
162
+ if imp and imp[0] == "sym" and imp[1].split(".")[0] == "sh" and fn.id != "Command":
163
+ self.st["sh_calls"] += 1
164
+ return self.varargs(call, ctx, [Thunk(ast.Constant(imp[2].replace("_", "-")),
165
+ ctx, None)])
166
+ if isinstance(fn, ast.Call) and fn.args:
167
+ d = self.dotted(fn.func) or ""
168
+ if d.rsplit(".", 1)[-1] == "Command" and self.ext_root(d, ctx) == "sh":
169
+ self.st["sh_calls"] += 1
170
+ return self.varargs(call, ctx, [Thunk(fn.args[0], ctx, None)])
171
+ # plumbum: local["prog"]["a", "b"](...), local["prog"].run(...) / .popen(...)
172
+ chain, e = [], fn
173
+ if isinstance(e, ast.Attribute) and e.attr in ("run", "popen", "run_fg", "run_bg", "run_retcode", "run_tee"):
174
+ e = e.value
175
+ while isinstance(e, ast.Subscript):
176
+ chain.append(e.slice)
177
+ e = e.value
178
+ if chain:
179
+ d = self.dotted(e) or ""
180
+ if d.rsplit(".", 1)[-1] == "local" and self.ext_root(d, ctx) == "plumbum":
181
+ head = []
182
+ for sl in reversed(chain):
183
+ for el in (sl.elts if isinstance(sl, ast.Tuple) else [sl]):
184
+ head.append(Thunk(el.value if isinstance(el, ast.Starred) else el, ctx, None,
185
+ isinstance(el, ast.Starred)))
186
+ self.st["plumbum_calls"] += 1
187
+ return self.varargs(call, ctx, head)
188
+ return None
189
+
190
+ def ext_root(self, dotted_name: str, ctx) -> str | None:
191
+ """Top-level installed package a dotted name comes from (`sh.git` -> "sh", `local` from plumbum)."""
192
+ first = dotted_name.split(".")[0]
193
+ imp = ctx.mod.imports.get(first)
194
+ if imp:
195
+ if self.prog.module(imp[1]) is not None or self.prog.module(imp[1].split(".")[0]) is not None:
196
+ return None # a project module of that name
197
+ return imp[1].split(".")[0]
198
+ return None
199
+
200
+ # ------------------------------------------------------------------ evaluation
201
+ def seq(self, t: Thunk, depth=0) -> list:
202
+ """Items of a sequence-valued thunk: ("lit", s) | PY | UNK | ("param", name) (a parameter in runner mode)."""
203
+ if not t.spread:
204
+ return [self.scalar(t, depth)]
205
+ e, ctx, env = t.e, t.ctx, t.env
206
+ if depth > 12:
207
+ return [UNK]
208
+ if isinstance(e, (ast.List, ast.Tuple)):
209
+ out = []
210
+ for el in e.elts:
211
+ if isinstance(el, ast.Starred):
212
+ out += self.seq(Thunk(el.value, ctx, env, True, t.line), depth + 1)
213
+ else:
214
+ out.append(self.scalar(Thunk(el, ctx, env, False, t.line), depth + 1))
215
+ return out
216
+ if isinstance(e, ast.BinOp) and isinstance(e.op, ast.Add):
217
+ return self.seq(Thunk(e.left, ctx, env, True, t.line), depth + 1) + \
218
+ self.seq(Thunk(e.right, ctx, env, True, t.line), depth + 1)
219
+ if isinstance(e, ast.ListComp) and len(e.generators) == 1 and not e.generators[0].ifs:
220
+ return self.seq(Thunk(e.generators[0].iter, ctx, env, True, t.line), depth + 1)
221
+ if isinstance(e, ast.Name):
222
+ got = self.name(e.id, ctx, env, True, depth, t.line)
223
+ if got is not None:
224
+ return got
225
+ return [UNK]
226
+ if isinstance(e, ast.Call):
227
+ d = self.dotted(e.func) or ""
228
+ if d in ("list", "tuple", "sorted") and e.args:
229
+ a0 = e.args[0]
230
+ if isinstance(a0, (ast.GeneratorExp, ast.ListComp)) and len(a0.generators) == 1 \
231
+ and not a0.generators[0].ifs:
232
+ a0 = a0.generators[0].iter # tuple(os.fspath(a) for a in cmdargs): same items, converted
233
+ return self.seq(Thunk(a0, ctx, env, True, t.line), depth + 1)
234
+ if d in ("shlex.split",) and e.args:
235
+ s = self.scalar(Thunk(e.args[0], ctx, env), depth + 1)
236
+ return self.split(s)
237
+ for rv, rctx in self.prog.returned(e, ctx)[:2]:
238
+ return self.seq(Thunk(rv, rctx, {}, True), depth + 1)
239
+ return [UNK]
240
+ s = self.scalar(Thunk(e, ctx, env), depth + 1)
241
+ return self.split(s) if s[0] == "lit" else [s]
242
+
243
+ @staticmethod
244
+ def split(s) -> list:
245
+ if s[0] != "lit":
246
+ return [s]
247
+ try:
248
+ parts = shlex.split(s[1])
249
+ except ValueError:
250
+ parts = s[1].split()
251
+ return [PY if PY_NAMES.match(p) and not p.endswith(".py") else ("lit", p) for p in parts] or [UNK]
252
+
253
+ def name(self, nm, ctx, env, as_seq, depth, line=None):
254
+ if env is not None and nm in env:
255
+ v = env[nm]
256
+ if isinstance(v, list): # a *args parameter: the call site's extra positional arguments
257
+ out = []
258
+ for th in v:
259
+ out += self.seq(th, depth + 1)
260
+ return out if as_seq else None
261
+ return self.seq(Thunk(v.e, v.ctx, v.env, as_seq, v.line), depth + 1) if as_seq else [self.scalar(v, depth + 1)]
262
+ if ctx.func is not None:
263
+ lv = self.prog.local_vars(ctx).get(nm)
264
+ if not lv and self.is_vararg(ctx.func, nm):
265
+ return [("param", nm)] if env is None else [UNK]
266
+ if lv:
267
+ # the last assignment before the use (`args = self._base() + args` reads the previous `args`)
268
+ assigns = sorted(((val.lineno, i, val) for i, (val, _a, kind) in enumerate(lv)
269
+ if kind == "assign" and val is not None), key=lambda x: (x[0], x[1]))
270
+ before = [a for a in assigns if line is None or a[0] < line]
271
+ if before:
272
+ val = before[-1][2]
273
+ th = Thunk(val, ctx, env, as_seq, val.lineno)
274
+ if not as_seq:
275
+ return [self.scalar(th, depth + 1)]
276
+ return self.mutated(nm, ctx, env, val.lineno, line, self.seq(th, depth + 1), depth)
277
+ if any(kind == "param" for _v, _a, kind in lv) or self.is_vararg(ctx.func, nm):
278
+ return [("param", nm)] if env is None else [UNK]
279
+ if assigns:
280
+ th = Thunk(assigns[0][2], ctx, env, as_seq)
281
+ return self.seq(th, depth + 1) if as_seq else [self.scalar(th, depth + 1)]
282
+ return None
283
+ r = self.prog.resolve_name(ctx.mod, nm)
284
+ if r and r[0] == "var":
285
+ vals = r[1].vars.get(r[2]) or ()
286
+ if vals and vals[0][0] is not None:
287
+ th = Thunk(vals[0][0], self.Ctx(r[1], None, None), {}, as_seq)
288
+ return self.seq(th, depth + 1) if as_seq else [self.scalar(th, depth + 1)]
289
+ return None
290
+
291
+ def mutations(self, f) -> dict:
292
+ """name -> [(line, op, args, loop)] for `x.append(a)`, `x.extend(s)`, `x.insert(i, a)`, `x += s` in `f`
293
+ (`loop` is the enclosing for / while statement, None outside loops); nested defs are skipped."""
294
+ cache = self.__dict__.setdefault("_mut", {})
295
+ if id(f.node) in cache:
296
+ return cache[id(f.node)]
297
+ out = defaultdict(list)
298
+
299
+ def visit(node, loop):
300
+ for ch in ast.iter_child_nodes(node):
301
+ if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
302
+ continue
303
+ if isinstance(ch, ast.AugAssign) and isinstance(ch.op, ast.Add) and isinstance(ch.target, ast.Name):
304
+ out[ch.target.id].append((ch.lineno, "extend", [ch.value], loop))
305
+ elif isinstance(ch, ast.Call) and isinstance(ch.func, ast.Attribute) \
306
+ and isinstance(ch.func.value, ast.Name) and ch.func.attr in ("append", "extend", "insert"):
307
+ out[ch.func.value.id].append((ch.lineno, ch.func.attr, ch.args, loop))
308
+ visit(ch, ch if isinstance(ch, (ast.For, ast.AsyncFor, ast.While)) else loop)
309
+ visit(f.node, None)
310
+ cache[id(f.node)] = out
311
+ return out
312
+
313
+ def mutated(self, nm, ctx, env, start, line, items, depth):
314
+ """`items` (the list assigned at line `start`) after the appends / extends / `+=` between it and `line`.
315
+ A mutation inside a loop adds the loop's items when it appends the loop variable of a `for` over a list
316
+ cg can evaluate; otherwise it adds one unknown item (the count is not known)."""
317
+ muts = [m for m in self.mutations(ctx.func).get(nm, ()) if m[0] > start and (line is None or m[0] < line)]
318
+ if not muts:
319
+ return items
320
+ items = list(items)
321
+ for _ln, op, args, loop in sorted(muts, key=lambda m: m[0]):
322
+ if not args:
323
+ continue
324
+ if loop is not None:
325
+ a0 = args[-1]
326
+ if op == "append" and isinstance(loop, (ast.For, ast.AsyncFor)) and isinstance(loop.target, ast.Name) \
327
+ and isinstance(a0, ast.Name) and a0.id == loop.target.id:
328
+ got = self.seq(Thunk(loop.iter, ctx, env, True, loop.lineno), depth + 1)
329
+ items += got if UNK not in got else [UNK]
330
+ else:
331
+ items.append(UNK)
332
+ self.st["argv_loop_mutations"] += 1
333
+ continue
334
+ if op == "append":
335
+ items.append(self.scalar(Thunk(args[0], ctx, env), depth + 1))
336
+ elif op == "extend":
337
+ items += self.seq(Thunk(args[0], ctx, env, True), depth + 1)
338
+ elif op == "insert" and len(args) == 2:
339
+ i = args[0].value if isinstance(args[0], ast.Constant) and isinstance(args[0].value, int) else None
340
+ v = self.scalar(Thunk(args[1], ctx, env), depth + 1)
341
+ if i is None:
342
+ items.append(UNK)
343
+ else:
344
+ items.insert(i, v)
345
+ self.st["argv_mutations"] += 1
346
+ return items
347
+
348
+ @staticmethod
349
+ def is_vararg(f, nm) -> bool:
350
+ a = f.node.args
351
+ return any(x is not None and x.arg == nm for x in (a.vararg, a.kwarg))
352
+
353
+ def scalar(self, t: Thunk, depth=0):
354
+ e, ctx, env = t.e, t.ctx, t.env
355
+ if depth > 12:
356
+ return UNK
357
+ if isinstance(e, ast.Constant) and isinstance(e.value, str):
358
+ return ("lit", e.value)
359
+ d = self.dotted(e) if isinstance(e, (ast.Name, ast.Attribute)) else None
360
+ if d in ("sys.executable",) or (d and d.endswith(".executable") and d.split(".")[0] == "sys"):
361
+ return PY
362
+ if isinstance(e, ast.Name) and e.id == "__file__":
363
+ return ("lit", ctx.mod.file) # project-relative path of this module
364
+ if isinstance(e, ast.Attribute) and e.attr == "__file__" and isinstance(e.value, ast.Name):
365
+ r = self.prog.resolve_name(ctx.mod, e.value.id)
366
+ if r and r[0] == "mod":
367
+ return ("lit", r[1].file) # `from django import conf; conf.__file__`
368
+ if isinstance(e, ast.Attribute) and e.attr == "parent":
369
+ a = self.scalar(Thunk(e.value, ctx, env, False, t.line), depth + 1)
370
+ return ("lit", a[1].rsplit("/", 1)[0] if "/" in a[1] else "") if a[0] == "lit" and "{?}" not in a[1] else UNK
371
+ if isinstance(e, ast.Name):
372
+ got = self.name(e.id, ctx, env, False, depth, t.line)
373
+ return got[0] if got else UNK
374
+ if isinstance(e, ast.IfExp): # a value or its fallback: the first one cg can evaluate
375
+ a = self.scalar(Thunk(e.body, ctx, env, False, t.line), depth + 1)
376
+ return a if a[0] == "lit" and "{?}" not in a[1] else self.scalar(Thunk(e.orelse, ctx, env, False, t.line), depth + 1)
377
+ if isinstance(e, ast.JoinedStr):
378
+ parts = []
379
+ for v in e.values:
380
+ if isinstance(v, ast.Constant):
381
+ parts.append(str(v.value))
382
+ else:
383
+ s = self.scalar(Thunk(v.value, ctx, env), depth + 1)
384
+ parts.append(sys_exe_text(s))
385
+ return ("lit", "".join(parts))
386
+ if isinstance(e, ast.BinOp) and isinstance(e.op, (ast.Add, ast.Div)):
387
+ a, c = self.scalar(Thunk(e.left, ctx, env), depth + 1), self.scalar(Thunk(e.right, ctx, env), depth + 1)
388
+ if c[0] != "lit":
389
+ return UNK
390
+ sep = "/" if isinstance(e.op, ast.Div) else ""
391
+ return ("lit", (a[1] if a[0] == "lit" else "{?}") + sep + c[1])
392
+ if isinstance(e, ast.Call):
393
+ fd = self.dotted(e.func) or ""
394
+ last = fd.rsplit(".", 1)[-1]
395
+ if last == "which" and e.args:
396
+ s = self.scalar(Thunk(e.args[0], ctx, env), depth + 1)
397
+ return PY if s[0] == "lit" and PY_NAMES.match(s[1]) else s
398
+ if last in ("str", "fspath", "abspath", "realpath", "normpath", "resolve", "Path", "PurePath", "expanduser") \
399
+ and e.args:
400
+ return self.scalar(Thunk(e.args[0], ctx, env), depth + 1)
401
+ if last == "dirname" and e.args:
402
+ a = self.scalar(Thunk(e.args[0], ctx, env), depth + 1)
403
+ return ("lit", a[1].rsplit("/", 1)[0] if "/" in a[1] else "") if a[0] == "lit" and "{?}" not in a[1] else UNK
404
+ if last == "join" and fd.endswith("path.join") and e.args:
405
+ parts = [self.scalar(Thunk(a, ctx, env), depth + 1) for a in e.args]
406
+ if parts[-1][0] != "lit":
407
+ return UNK
408
+ return ("lit", "/".join(p[1] if p[0] == "lit" else "{?}" for p in parts))
409
+ if isinstance(e.func, ast.Attribute) and e.func.attr in ("resolve", "absolute") and not e.args:
410
+ return self.scalar(Thunk(e.func.value, ctx, env), depth + 1)
411
+ for rv, rctx in self.prog.returned(e, ctx)[:1]:
412
+ return self.scalar(Thunk(rv, rctx, {}), depth + 1)
413
+ return UNK
414
+
415
+ # ------------------------------------------------------------------ what the argv runs
416
+ def identify(self, items: list):
417
+ """("ok", how, target id(s), detail) | ("param",) when the program is a parameter | ("no", reason, text)."""
418
+ items = list(items)
419
+ while items and items[0][0] == "lit" and re.match(r"^[A-Z_][A-Z0-9_]*=", items[0][1]):
420
+ items.pop(0) # FOO=1 python -m x (shell)
421
+ if not items:
422
+ return ("no", "empty", "")
423
+ first = items[0]
424
+ if first[0] == "param":
425
+ return ("param",)
426
+ if first == PY or (first[0] == "lit" and PY_NAMES.match(first[1])):
427
+ i = 1
428
+ while i < len(items):
429
+ it = items[i]
430
+ if it[0] == "param":
431
+ return ("param",)
432
+ if it[0] != "lit":
433
+ return ("no", "unknown argument", "")
434
+ a = it[1]
435
+ if a in PY_FLAGS_ARG:
436
+ i += 2
437
+ continue
438
+ if a.startswith("-") and not a.startswith("--") and len(a) > 1:
439
+ # a cluster of short options: -u, -Im pip, -mpkg.cli, -Bc "code", -X dev, -Werror
440
+ j, opt, rest = 1, None, ""
441
+ while j < len(a):
442
+ ch = a[j]
443
+ if ch in "mcWX":
444
+ opt, rest = ch, a[j + 1:]
445
+ break
446
+ if ch not in PY_SHORT:
447
+ return ("no", "interpreter option", a)
448
+ j += 1
449
+ if opt is None:
450
+ i += 1
451
+ continue
452
+ if opt in "WX":
453
+ i += 1 if rest else 2
454
+ continue
455
+ if rest:
456
+ val = ("lit", rest)
457
+ elif i + 1 < len(items):
458
+ val = items[i + 1]
459
+ else:
460
+ return ("no", f"-{opt} without argument", "")
461
+ if val[0] == "param":
462
+ return ("param",)
463
+ if val[0] != "lit":
464
+ return ("no", "unknown module" if opt == "m" else "unknown code", f"-{opt} ?")
465
+ return self.module_target(val[1]) if opt == "m" else ("code", val[1])
466
+ if a.startswith("-"):
467
+ return ("no", "interpreter option", a)
468
+ return self.file_target(a)
469
+ return ("no", "interpreter only", "")
470
+ if first[0] == "lit":
471
+ prog_name = re.split(r"[/\\]", first[1])[-1]
472
+ prog_name = re.sub(r"\.exe$", "", prog_name)
473
+ ids = self.scripts.get(prog_name)
474
+ if ids:
475
+ return ("ok", "console script", ids, prog_name)
476
+ if prog_name.endswith(".py"):
477
+ return self.file_target(first[1])
478
+ return ("no", "not a project program", prog_name)
479
+ return ("no", "unknown program", "")
480
+
481
+ def module_target(self, mod: str):
482
+ b, m = self.b, self.prog.module(mod)
483
+ if m is None:
484
+ return ("no", "module outside the project", f"-m {mod}")
485
+ for key in (f"script:{mod}.__main__", f"script:{mod}"):
486
+ if key in b.nodes:
487
+ return ("ok", "-m", [key], mod)
488
+ return ("ok", "-m", [m.id], mod)
489
+
490
+ def file_target(self, path: str):
491
+ p = path.replace("\\", "/")
492
+ if not p.endswith(".py"):
493
+ return ("no", "not a project program", p.rsplit("/", 1)[-1])
494
+ tail = p.rsplit("{?}/", 1)[-1].lstrip("./")
495
+ while tail.startswith("../"):
496
+ tail = tail[3:]
497
+ if "/" in tail:
498
+ hits = [m for f, m in self.by_file.items() if f == tail or f.endswith("/" + tail)]
499
+ else: # a bare file name runs from the working directory: only the project root's file is known
500
+ hits = [m for f, m in self.by_file.items() if f == tail]
501
+ if len(hits) != 1:
502
+ return ("no", "script file not found" if not hits else "script file ambiguous", tail)
503
+ m = hits[0]
504
+ key = f"script:{m.name}"
505
+ return ("ok", "script", [key if key in self.b.nodes else m.id], m.file)
506
+
507
+ def code_targets(self, code: str, ctx):
508
+ """Functions / classes a `-c` snippet calls; imports resolved like a module's."""
509
+ try:
510
+ tree = ast.parse(code)
511
+ except SyntaxError:
512
+ return []
513
+ prog, names = self.prog, {}
514
+ for st in ast.walk(tree):
515
+ if isinstance(st, ast.Import):
516
+ for a in st.names:
517
+ if a.asname:
518
+ names[a.asname] = ("mod", a.name)
519
+ else:
520
+ names[a.name.split(".")[0]] = ("prefix", a.name.split(".")[0])
521
+ elif isinstance(st, ast.ImportFrom) and st.module and not st.level:
522
+ for a in st.names:
523
+ if a.name != "*":
524
+ names[a.asname or a.name] = ("sym", st.module, a.name)
525
+ out = []
526
+ for c in ast.walk(tree):
527
+ if not isinstance(c, ast.Call):
528
+ continue
529
+ d = self.dotted(c.func)
530
+ if not d:
531
+ continue
532
+ parts = d.split(".")
533
+ imp = names.get(parts[0])
534
+ if imp is None:
535
+ continue
536
+ if imp[0] == "sym":
537
+ t = prog.lookup(imp[1], imp[2])
538
+ rest = parts[1:]
539
+ else:
540
+ t, rest = None, parts[1:]
541
+ base = imp[1]
542
+ m = prog.module(base)
543
+ t = ("mod", m) if m else None
544
+ while rest and t and t[0] == "mod" and prog.module(f"{t[1].name}.{rest[0]}"):
545
+ t = ("mod", prog.module(f"{t[1].name}.{rest[0]}"))
546
+ rest = rest[1:]
547
+ for part in rest:
548
+ if t is None:
549
+ break
550
+ t = prog.lookup(t[1].name, part) if t[0] == "mod" else prog.member(t, part)
551
+ if t and t[0] in ("func", "bound") and isinstance(t[1], self.FuncInfo):
552
+ out.append(t[1].id)
553
+ elif t and t[0] == "type":
554
+ init = prog.find_method(t[1], "__init__")
555
+ out.append(init.id if init else t[1].id)
556
+ return list(dict.fromkeys(out))
557
+
558
+ def code_imports(self, code: str) -> list:
559
+ """Project modules a `-c` snippet imports (their bodies run) when it calls nothing of the project."""
560
+ try:
561
+ tree = ast.parse(code)
562
+ except SyntaxError:
563
+ return []
564
+ out = []
565
+ for st in ast.walk(tree):
566
+ mods = []
567
+ if isinstance(st, ast.Import):
568
+ mods = [a.name for a in st.names]
569
+ elif isinstance(st, ast.ImportFrom) and st.module and not st.level:
570
+ mods = [f"{st.module}.{a.name}" for a in st.names if a.name != "*"] + [st.module]
571
+ for name in mods:
572
+ m = self.prog.module(name)
573
+ if m is not None:
574
+ out.append(m.id)
575
+ break # `from pkg import sub`: the submodule when it is one, else pkg
576
+ return list(dict.fromkeys(out))
577
+
578
+ def copies(self, f, ctx) -> list:
579
+ """(destination text, source text) for `shutil.copy / copy2 / copyfile(src, dst)` and
580
+ `dst.write_text(src.read_text()...)` in `f`."""
581
+ out = []
582
+ for sub in self.walk_body(f.node):
583
+ if not isinstance(sub, ast.Call) or not isinstance(sub.func, ast.Attribute):
584
+ continue
585
+ src = dst = None
586
+ if sub.func.attr in COPY_FUNCS and len(sub.args) >= 2 and (self.dotted(sub.func.value) or "").endswith("shutil"):
587
+ src, dst = sub.args[0], sub.args[1]
588
+ elif sub.func.attr in ("write_text", "write_bytes") and sub.args:
589
+ rd = sub.args[0]
590
+ while isinstance(rd, ast.Call) and isinstance(rd.func, ast.Attribute) and rd.func.attr in (
591
+ "replace", "format", "strip", "lstrip"):
592
+ rd = rd.func.value # template.read_text().replace("{{ x }}", y)
593
+ if isinstance(rd, ast.Call) and isinstance(rd.func, ast.Attribute) and rd.func.attr in (
594
+ "read_text", "read_bytes"):
595
+ src, dst = rd.func.value, sub.func.value
596
+ if src is None:
597
+ continue
598
+ s = self.scalar(Thunk(src, ctx, None, False, sub.lineno))
599
+ d = self.scalar(Thunk(dst, ctx, None, False, sub.lineno))
600
+ if s[0] == "lit" and d[0] == "lit":
601
+ out.append((d[1], s[1]))
602
+ return out
603
+
604
+ def copied_script(self, f, ctx, prog_text):
605
+ """A program the function first copied from a project file (`shutil.copyfile(template, tmp / "manage.py")`):
606
+ the source's entry when it is a module, else what the source (a template) calls, parsed as Python."""
607
+ base = (prog_text or "").replace("\\", "/").rsplit("/", 1)[-1]
608
+ if not base:
609
+ return None
610
+ for dst, src in self.copies(f, ctx):
611
+ if dst.replace("\\", "/").rsplit("/", 1)[-1] != base:
612
+ continue
613
+ self.st["copied_scripts"] += 1
614
+ srcp = src.replace("\\", "/")
615
+ if srcp.endswith(".py"):
616
+ r = self.file_target(srcp)
617
+ if r[0] == "ok":
618
+ return ("copied script", r[2], r[3])
619
+ path = self.find_file(srcp)
620
+ if path is None:
621
+ continue
622
+ try:
623
+ code = (self.root / path).read_text(encoding="utf-8", errors="replace")
624
+ except OSError:
625
+ continue
626
+ tgts = self.code_targets(code, ctx) or self.code_imports(code)
627
+ if tgts:
628
+ return ("copied template", tgts, path)
629
+ return None
630
+
631
+ def find_file(self, text: str):
632
+ """Project-relative path of a non-module file whose path ends with `text` (minus unknown `{?}/` parts)."""
633
+ if self.root is None:
634
+ return None
635
+ tail = text.rsplit("{?}/", 1)[-1].lstrip("./")
636
+ name = tail.rsplit("/", 1)[-1]
637
+ if self._files_by_name is None:
638
+ import os
639
+ self._files_by_name = defaultdict(list)
640
+ from ... import presets
641
+ skip = presets.skip_dirs("python")
642
+ for dp, dns, fns in os.walk(self.root):
643
+ dns[:] = [d for d in dns if d not in skip and not d.startswith(".")]
644
+ rel = os.path.relpath(dp, self.root).replace(os.sep, "/")
645
+ for fn in fns:
646
+ if fn.endswith(("-tpl", ".tpl", ".template", ".py.in", ".py_tmpl")):
647
+ self._files_by_name[fn].append(fn if rel == "." else f"{rel}/{fn}")
648
+ hits = [p for p in self._files_by_name.get(name, ()) if p == tail or p.endswith("/" + tail)]
649
+ return hits[0] if len(hits) == 1 else None
650
+
651
+ # ------------------------------------------------------------------ binding call-site arguments
652
+ def bind(self, f, call, ctx, env):
653
+ """Parameter name -> Thunk (or list of Thunks for *args) for a call to runner `f`."""
654
+ a = f.node.args
655
+ pos = [x.arg for x in list(a.posonlyargs) + list(a.args)]
656
+ if f.cls is not None and pos and isinstance(call.func, ast.Attribute) and not any(
657
+ self.dotted(d) == "staticmethod" for d in f.node.decorator_list):
658
+ pos = pos[1:]
659
+ out, extra, i = {}, [], 0
660
+ for arg in call.args:
661
+ if isinstance(arg, ast.Starred):
662
+ extra.append(Thunk(arg.value, ctx, env, True, call.lineno))
663
+ i = len(pos) + 1
664
+ continue
665
+ if i < len(pos):
666
+ out[pos[i]] = Thunk(arg, ctx, env, False, call.lineno)
667
+ else:
668
+ extra.append(Thunk(arg, ctx, env, False, call.lineno))
669
+ i += 1
670
+ if a.vararg is not None:
671
+ out[a.vararg.arg] = extra
672
+ elif extra and any(x.spread for x in extra) and len(pos) > 0:
673
+ pass
674
+ for k in call.keywords:
675
+ if k.arg:
676
+ out[k.arg] = Thunk(k.value, ctx, env, False, call.lineno)
677
+ return out
678
+
679
+ def run_items(self, fid, env, hops=0):
680
+ """argv item lists runner `fid` produces with its parameters bound by `env`."""
681
+ if hops > MAX_HOPS:
682
+ return []
683
+ out = []
684
+ for kind, payload in self.runners.get(fid, ()):
685
+ if kind == "spawn":
686
+ thunks, shell, ctx = payload
687
+ items = []
688
+ for th in thunks:
689
+ items += self.seq(Thunk(th.e, ctx, env, th.spread, th.line))
690
+ if shell and len(items) == 1 and items[0][0] == "lit":
691
+ items = self.split(items[0])
692
+ out.append(items)
693
+ else:
694
+ callee, call, ctx = payload
695
+ out += self.run_items(callee.id, self.bind(callee, call, ctx, env), hops + 1)
696
+ return out
697
+
698
+ # ------------------------------------------------------------------ driver
699
+ def link(self) -> dict:
700
+ prog, b, st = self.prog, self.b, self.st
701
+ funcs = list(prog.funcs.values())
702
+ direct = [] # (f, ctx, call, thunks, shell)
703
+ spawn_names = SPAWN_ARGV | SPAWN_VARARGS | SPAWN_SHELL | SPAWN_LIST_AT1
704
+ calls_by_name = defaultdict(list) # callee's last name -> [(function, call)], one walk
705
+ lib_mods = {id(m) for m in prog.modules.values()
706
+ if any(i[1].split(".")[0] in ("sh", "plumbum") for i in m.imports.values())}
707
+ for f in funcs:
708
+ ctx = None
709
+ lib = id(f.module) in lib_mods
710
+ for sub in self.walk_body(f.node):
711
+ if not isinstance(sub, ast.Call):
712
+ continue
713
+ fn = sub.func
714
+ nm = fn.attr if isinstance(fn, ast.Attribute) else fn.id if isinstance(fn, ast.Name) else None
715
+ if lib and (nm is None or nm not in spawn_names):
716
+ ctx = ctx or self.Ctx(f.module, f, f.cls)
717
+ sp = self.lib_spawn(sub, ctx)
718
+ if sp is not None and sp[0]:
719
+ direct.append((f, ctx, sub, sp[0], sp[1]))
720
+ continue
721
+ if nm is None:
722
+ continue
723
+ calls_by_name[nm].append((f, sub))
724
+ if nm == "invoke" and sub.args:
725
+ ctx = ctx or self.Ctx(f.module, f, f.cls)
726
+ self.cli_runner(f, ctx, sub)
727
+ elif nm in spawn_names:
728
+ ctx = ctx or self.Ctx(f.module, f, f.cls)
729
+ sp = self.spawn(sub, ctx)
730
+ if sp is not None and sp[0]:
731
+ direct.append((f, ctx, sub, sp[0], sp[1]))
732
+ st["process_starts"] = len(direct)
733
+ pending = [] # (f, ctx, call, items list, via helper name | None)
734
+ for f, ctx, call, thunks, shell in direct:
735
+ items = []
736
+ for th in thunks:
737
+ items += self.seq(th)
738
+ if shell and len(items) == 1 and items[0][0] == "lit":
739
+ items = self.split(items[0])
740
+ if self.identify(items)[0] == "param":
741
+ self.runners.setdefault(f.id, []).append(("spawn", (thunks, shell, ctx)))
742
+ else:
743
+ pending.append((f, ctx, call, [items], None))
744
+ # runners: functions whose program comes from a parameter; their call sites pass it (fixpoint, 5 hops)
745
+ done_sites, resolved = set(), {}
746
+ frontier = set(self.runners)
747
+ for _hop in range(MAX_HOPS):
748
+ if not frontier:
749
+ break
750
+ names = {fid.rsplit(".", 1)[-1] for fid in frontier}
751
+ byid = {fid for fid in frontier}
752
+ new = set()
753
+ ctxs = {}
754
+ for g, sub in (x for nm in sorted(names) for x in calls_by_name.get(nm, ())):
755
+ if id(sub) in done_sites:
756
+ continue
757
+ ctx = ctxs.get(g.id) or ctxs.setdefault(g.id, self.Ctx(g.module, g, g.cls))
758
+ tgts = resolved.get(id(sub))
759
+ if tgts is None:
760
+ try:
761
+ tgts = resolved[id(sub)] = prog.resolve_call(sub, ctx)
762
+ except RecursionError:
763
+ tgts = resolved[id(sub)] = []
764
+ for tgt, _conf, _via in tgts:
765
+ if not isinstance(tgt, self.FuncInfo) or tgt.id not in byid or tgt.id == g.id:
766
+ continue
767
+ done_sites.add(id(sub))
768
+ probe = self.run_items(tgt.id, self.bind(tgt, sub, ctx, None))
769
+ if any(self.identify(it)[0] == "param" for it in probe):
770
+ if g.id not in self.runners:
771
+ new.add(g.id)
772
+ self.runners.setdefault(g.id, []).append(("call", (tgt, sub, ctx)))
773
+ st["runner_calls_forwarded"] += 1
774
+ res = [it for it in probe if self.identify(it)[0] != "param"]
775
+ if res:
776
+ pending.append((g, ctx, sub, res, tgt.name))
777
+ frontier = new
778
+ st["runners"] = len(self.runners)
779
+ for f, ctx, call, item_lists, helper in pending:
780
+ for items in item_lists:
781
+ self.emit(f, ctx, call, items, helper)
782
+ out = {k: v for k, v in st.items() if v}
783
+ for k, v in self.samples.items():
784
+ out.setdefault("samples", {})[k] = v[:5]
785
+ return out
786
+
787
+ def cli_runner(self, f, ctx, call):
788
+ """`CliRunner().invoke(app, [...])` on a typer app / click group object: the commands registered on it run
789
+ in process (a click group function is already referenced as a callback)."""
790
+ if self.rp is None or not isinstance(call.args[0], (ast.Name, ast.Attribute)):
791
+ return
792
+ e = call.args[0]
793
+ if isinstance(e, ast.Name):
794
+ r = self.prog.resolve_name(ctx.mod, e.id)
795
+ else:
796
+ base = self.prog.infer(e.value, ctx)
797
+ r = self.prog.lookup(base[1].name, e.attr) if base and base[0] == "mod" else None
798
+ if not (r and r[0] == "var"):
799
+ return
800
+ regs = self.rp.var_regs.get((r[1].name, r[2]), [])
801
+ for g in regs:
802
+ self.b.add_edge(f.id, g.id, "REFERENCES_FN", f.file, call.lineno, RESOLVED, how="callback",
803
+ via="CliRunner.invoke")
804
+ if regs:
805
+ self.st["cli_runner_invokes"] += 1
806
+
807
+ def emit(self, f, ctx, call, items, helper):
808
+ st, b = self.st, self.b
809
+ res = self.identify(items)
810
+ via = {"via": "subprocess"}
811
+ if helper:
812
+ via["helper"] = helper
813
+ cmd = " ".join("python" if x == PY else x[1] if x[0] == "lit" else "?" for x in items[:6])[:120]
814
+ if res[0] == "no" and res[1] in ("script file not found", "not a project program"):
815
+ got = self.copied_script(f, ctx, res[2])
816
+ if got is not None:
817
+ how, tgts, src = got
818
+ for t in tgts:
819
+ b.add_edge(f.id, t, "CALLS", f.file, call.lineno, RESOLVED, how=how, command=cmd, copied_from=src,
820
+ **via)
821
+ st["linked"] += 1
822
+ st["linked_copied_script"] += 1
823
+ st["linked_via_helper"] += bool(helper)
824
+ return
825
+ if res[0] == "code":
826
+ tgts = self.code_targets(res[1], ctx)
827
+ how = "-c"
828
+ if not tgts:
829
+ tgts, how = self.code_imports(res[1]), "-c import"
830
+ if not tgts:
831
+ st["code_without_project_calls"] += 1
832
+ return
833
+ for t in tgts:
834
+ b.add_edge(f.id, t, "CALLS", f.file, call.lineno, RESOLVED, how=how, command=cmd, **via)
835
+ st["linked"] += 1
836
+ st["linked_" + how.replace(" ", "_")] += 1
837
+ st["linked_via_helper"] += bool(helper)
838
+ return
839
+ if res[0] == "code":
840
+ for t in tgts:
841
+ b.add_edge(f.id, t, "CALLS", f.file, call.lineno, RESOLVED, how="-c", command=cmd, **via)
842
+ st["linked"] += 1
843
+ st["linked_-c"] += 1
844
+ st["linked_via_helper"] += bool(helper)
845
+ return
846
+ if res[0] == "ok":
847
+ _, how, ids, _detail = res
848
+ for t in ids:
849
+ b.add_edge(f.id, t, "CALLS", f.file, call.lineno, RESOLVED, how=how, command=cmd, **via)
850
+ st["linked"] += 1
851
+ st[f"linked_{how.replace(' ', '_')}"] += 1
852
+ st["linked_via_helper"] += bool(helper)
853
+ return
854
+ if res[0] == "no":
855
+ key = "outside_project" if res[1] in ("module outside the project", "not a project program") else "unresolved"
856
+ st[key] += 1
857
+ if res[2] and len(self.samples[key]) < 5 and res[2] not in self.samples[key]:
858
+ self.samples[key].append(res[2])
859
+
860
+
861
+ def sys_exe_text(s) -> str:
862
+ if s == PY:
863
+ return "python"
864
+ return s[1] if s[0] == "lit" else "{?}"