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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- 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 "{?}"
|