1use std::io::Write;
12use std::path::Path;
13use std::process::{Command, Stdio};
14
15use serde::Deserialize;
16
17use super::{FunctionFacts, LangFindings, Sighting};
18
19const AST_WALKER: &str = r#"
27import ast, json, os, sys
28from urllib.parse import urlsplit
29
30HTTP_LIBS = {"httpx", "requests", "aiohttp", "urllib3"}
31LLM_LIBS = {"openai", "anthropic"}
32OTHER_LIBS = {"psycopg", "boto3"}
33KNOWN = HTTP_LIBS | LLM_LIBS | OTHER_LIBS
34TIME_LIBS = {"time", "datetime"}
35RANDOM_LIBS = {"random", "uuid", "secrets"}
36UNSAFE_LIBS = {"threading", "multiprocessing", "subprocess", "socket"}
37TRACKED = KNOWN | TIME_LIBS | RANDOM_LIBS | UNSAFE_LIBS | {"os"}
38TIME_NAMES = {"time", "time_ns", "monotonic", "monotonic_ns",
39 "perf_counter", "perf_counter_ns", "gmtime", "localtime"}
40DT_NAMES = {"now", "utcnow", "today"}
41UUID_NAMES = {"uuid1", "uuid3", "uuid4", "uuid5"}
42OS_UNSAFE = {"system", "popen", "fork", "forkpty", "execv", "execve",
43 "execvp", "execvpe", "spawnl", "spawnv", "spawnvp"}
44SKIP = {".keel", ".git", "__pycache__", "node_modules", ".venv", "venv",
45 ".mypy_cache", ".pytest_cache", "dist", "build", "target"}
46
47
48def top(mod):
49 return mod.split(".", 1)[0] if mod else ""
50
51
52def host(s):
53 if "://" not in s:
54 return None
55 try:
56 parts = urlsplit(s.strip())
57 except ValueError:
58 return None
59 if not parts.scheme or not parts.hostname:
60 return None
61 return parts.hostname
62
63
64def call_root(f):
65 """The Name at the base of a call's attribute chain, or None. Deliberately
66 does NOT see through intermediate calls: in `httpx.get(u).json()` only the
67 inner `httpx.get(u)` has a root, so a chained method on a call result is
68 never double-counted as a second effect."""
69 while isinstance(f, ast.Attribute):
70 f = f.value
71 return f.id if isinstance(f, ast.Name) else None
72
73
74# Call names that construct a handle rather than perform an effect: CapWords
75# constructors (OpenAI(), Client()) plus the well-known factory methods.
76FACTORY_NAMES = {"client", "resource", "session", "connect"}
77
78
79def is_constructor(name):
80 return name is None or name[:1].isupper() or name in FACTORY_NAMES
81
82
83def aliases_of(tree):
84 """Binding name -> tracked top-level module. Also follows one hop of
85 constructor assignment (client = OpenAI() -> client is an openai handle),
86 the dominant SDK-client pattern."""
87 a = {}
88 for node in ast.walk(tree):
89 if isinstance(node, ast.Import):
90 for al in node.names:
91 t = top(al.name)
92 if t in TRACKED:
93 a[(al.asname or al.name).split(".", 1)[0]] = t
94 elif isinstance(node, ast.ImportFrom):
95 t = top(node.module or "")
96 if t in TRACKED:
97 for al in node.names:
98 a[al.asname or al.name] = t
99 for node in ast.walk(tree):
100 if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
101 lib = a.get(call_root(node.value.func))
102 if lib in KNOWN:
103 for tgt in node.targets:
104 if isinstance(tgt, ast.Name):
105 a[tgt.id] = lib
106 return a
107
108
109def url_consts_of(tree):
110 """Module-level NAME = "scheme://host/..." constants -> host, so a URL
111 hoisted to a constant still attributes to the functions that use it."""
112 consts = {}
113 for node in tree.body:
114 if (isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant)
115 and isinstance(node.value.value, str)):
116 h = host(node.value.value)
117 if h:
118 for tgt in node.targets:
119 if isinstance(tgt, ast.Name):
120 consts[tgt.id] = h
121 return consts
122
123
124def fn_facts(fn, rel, mod, aliases, url_consts):
125 effects = unsafe_idem = t_reads = r_reads = 0
126 targets = set()
127 reasons = []
128 for node in ast.walk(fn):
129 if isinstance(node, ast.Name) and node.id in url_consts:
130 targets.add(url_consts[node.id])
131 elif isinstance(node, ast.Constant) and isinstance(node.value, str):
132 h = host(node.value)
133 if h:
134 targets.add(h)
135 if not isinstance(node, ast.Call):
136 continue
137 lib = aliases.get(call_root(node.func))
138 if lib is None:
139 continue
140 attr = node.func.attr if isinstance(node.func, ast.Attribute) else None
141 name = attr if attr is not None else call_root(node.func)
142 if lib in KNOWN:
143 if is_constructor(name):
144 continue # a handle being built, not an effect performed
145 effects += 1
146 if name in {"post", "patch"}:
147 unsafe_idem += 1
148 if lib in LLM_LIBS:
149 targets.add("llm:" + lib)
150 elif lib == "time" and name in TIME_NAMES:
151 t_reads += 1
152 elif lib == "datetime" and name in DT_NAMES:
153 t_reads += 1
154 elif lib in {"random", "secrets"}:
155 r_reads += 1
156 elif lib == "uuid" and name in UUID_NAMES:
157 r_reads += 1
158 elif lib == "os" and name == "urandom":
159 r_reads += 1
160 elif lib in UNSAFE_LIBS:
161 reasons.append((node.lineno, "%s use at %s:%d" % (lib, rel, node.lineno)))
162 elif lib == "os" and name in OS_UNSAFE:
163 reasons.append((node.lineno, "os.%s at %s:%d" % (name, rel, node.lineno)))
164 return {"effects": effects, "file": rel, "idempotent_unsafe": unsafe_idem,
165 "line": fn.lineno, "module": mod, "name": fn.name,
166 "random_reads": r_reads, "targets": sorted(targets),
167 "time_reads": t_reads,
168 "unsafe_reasons": [t for _, t in sorted(reasons)]}
169
170
171root = sys.argv[1] if len(sys.argv) > 1 else "."
172imports = []
173urls = []
174functions = []
175files = 0
176for dirpath, dirnames, filenames in os.walk(root):
177 dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not d.startswith("."))
178 for fn in sorted(filenames):
179 if not fn.endswith(".py"):
180 continue
181 path = os.path.join(dirpath, fn)
182 rel = os.path.relpath(path, root).replace(os.sep, "/")
183 try:
184 with open(path, "r", encoding="utf-8") as fh:
185 tree = ast.parse(fh.read())
186 except (OSError, SyntaxError, UnicodeDecodeError, ValueError):
187 continue
188 files += 1
189 for node in ast.walk(tree):
190 if isinstance(node, ast.Import):
191 for alias in node.names:
192 t = top(alias.name)
193 if t in KNOWN:
194 imports.append({"lib": t, "file": rel, "line": node.lineno})
195 elif isinstance(node, ast.ImportFrom):
196 t = top(node.module or "")
197 if t in KNOWN:
198 imports.append({"lib": t, "file": rel, "line": node.lineno})
199 elif isinstance(node, ast.Constant) and isinstance(node.value, str):
200 h = host(node.value)
201 if h:
202 urls.append({"host": h, "file": rel, "line": node.lineno})
203 mod = rel[:-3].replace("/", ".")
204 if mod.endswith(".__init__"):
205 mod = mod[: -len(".__init__")]
206 aliases = aliases_of(tree)
207 consts = url_consts_of(tree)
208 for node in tree.body:
209 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
210 functions.append(fn_facts(node, rel, mod, aliases, consts))
211
212print(json.dumps({"files_scanned": files, "functions": functions,
213 "imports": imports, "urls": urls}, sort_keys=True))
214"#;
215
216#[derive(Debug, Deserialize)]
218struct Import {
219 lib: String,
220 file: String,
221 line: u32,
222}
223
224#[derive(Debug, Deserialize)]
226struct Url {
227 host: String,
228 file: String,
229 line: u32,
230}
231
232#[derive(Debug, Deserialize)]
234struct PyFunction {
235 effects: u32,
236 file: String,
237 idempotent_unsafe: u32,
238 line: u32,
239 module: String,
240 name: String,
241 random_reads: u32,
242 targets: Vec<String>,
243 time_reads: u32,
244 unsafe_reasons: Vec<String>,
245}
246
247#[derive(Debug, Deserialize)]
249struct WalkerOutput {
250 files_scanned: usize,
251 #[serde(default)]
252 functions: Vec<PyFunction>,
253 imports: Vec<Import>,
254 urls: Vec<Url>,
255}
256
257#[derive(Debug, Clone, Default)]
259pub struct PyScan {
260 pub available: bool,
262 pub files_scanned: usize,
264 pub findings: LangFindings,
266 pub functions: Vec<FunctionFacts>,
268}
269
270const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3"];
271
272pub fn scan(project: &Path) -> PyScan {
275 let Some(output) = run_walker(project) else {
276 return PyScan::default();
277 };
278 let mut findings = LangFindings::default();
279 for imp in &output.imports {
280 findings.libs.insert(imp.lib.clone());
281 let sighting = Sighting {
282 file: imp.file.clone(),
283 line: imp.line,
284 };
285 match imp.lib.as_str() {
286 "openai" => findings.llm.push(("openai".to_owned(), sighting)),
287 "anthropic" => findings.llm.push(("anthropic".to_owned(), sighting)),
288 lib if HTTP_LIBS.contains(&lib) => findings.http_in_use = true,
289 _ => {}
292 }
293 }
294 if !output.urls.is_empty() {
297 findings.http_in_use = true;
298 }
299 for url in &output.urls {
300 findings.hosts.push((
303 url.host.to_ascii_lowercase(),
304 Sighting {
305 file: url.file.clone(),
306 line: url.line,
307 },
308 ));
309 }
310 let functions = output
311 .functions
312 .into_iter()
313 .map(|f| FunctionFacts {
314 entrypoint: format!("py:{}:{}", f.module, f.name),
315 file: f.file,
316 line: f.line,
317 effects: f.effects,
318 idempotent_unsafe: f.idempotent_unsafe,
319 time_reads: f.time_reads,
320 random_reads: f.random_reads,
321 unsafe_reasons: f.unsafe_reasons,
322 targets: f.targets.into_iter().collect(),
323 })
324 .collect();
325 PyScan {
326 available: true,
327 files_scanned: output.files_scanned,
328 findings,
329 functions,
330 }
331}
332
333fn run_walker(project: &Path) -> Option<WalkerOutput> {
335 let mut child = Command::new("python3")
336 .arg("-")
337 .arg(project)
338 .stdin(Stdio::piped())
339 .stdout(Stdio::piped())
340 .stderr(Stdio::null())
341 .spawn()
342 .ok()?;
343 child.stdin.take()?.write_all(AST_WALKER.as_bytes()).ok()?;
344 let out = child.wait_with_output().ok()?;
345 if !out.status.success() {
346 return None;
347 }
348 serde_json::from_slice(&out.stdout).ok()
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use std::fs;
355 use tempfile::TempDir;
356
357 fn python3_present() -> bool {
358 Command::new("python3")
359 .arg("--version")
360 .stdout(Stdio::null())
361 .stderr(Stdio::null())
362 .status()
363 .is_ok_and(|s| s.success())
364 }
365
366 #[test]
367 fn walks_imports_and_url_literals() {
368 if !python3_present() {
369 eprintln!("skip: python3 not available");
370 return;
371 }
372 let dir = TempDir::new().unwrap();
373 fs::write(
374 dir.path().join("app.py"),
375 "import httpx\nfrom openai import OpenAI\n\nURL = \"https://api.example.com/v1\"\n",
376 )
377 .unwrap();
378 let scan = scan(dir.path());
379 assert!(scan.available);
380 assert_eq!(scan.files_scanned, 1);
381 assert!(scan.findings.http_in_use);
382 assert!(
383 scan.findings
384 .llm
385 .iter()
386 .any(|(p, s)| p == "openai" && s.file == "app.py" && s.line == 2)
387 );
388 assert!(
389 scan.findings
390 .hosts
391 .iter()
392 .any(|(h, s)| h == "api.example.com" && s.line == 4)
393 );
394 }
395
396 #[test]
397 fn attributes_effects_time_random_to_module_level_functions() {
398 if !python3_present() {
399 eprintln!("skip: python3 not available");
400 return;
401 }
402 let dir = TempDir::new().unwrap();
403 fs::write(
404 dir.path().join("pipeline.py"),
405 r#"import time
406import random
407import httpx
408from openai import OpenAI
409
410API = "https://api.example.com/v1/data"
411client = OpenAI()
412
413
414def main():
415 started = time.time()
416 seed = random.random()
417 data = httpx.get(API).json()
418 httpx.post(API, json=data)
419 client.responses.create(model="gpt-4.1", input="hi")
420 return started, seed
421
422
423def helper():
424 return 41 + 1
425"#,
426 )
427 .unwrap();
428 let s = scan(dir.path());
429 let main = s
430 .functions
431 .iter()
432 .find(|f| f.entrypoint == "py:pipeline:main")
433 .expect("main attributed");
434 assert_eq!(main.effects, 3);
436 assert_eq!(main.idempotent_unsafe, 1, "only the POST");
437 assert_eq!(main.time_reads, 1);
438 assert_eq!(main.random_reads, 1);
439 assert!(main.unsafe_reasons.is_empty());
440 assert!(main.targets.contains("api.example.com"), "URL via constant");
441 assert!(main.targets.contains("llm:openai"), "client = OpenAI() hop");
442 assert_eq!((main.file.as_str(), main.line), ("pipeline.py", 10));
443 let helper = s
444 .functions
445 .iter()
446 .find(|f| f.entrypoint == "py:pipeline:helper")
447 .expect("helper attributed");
448 assert_eq!(helper.effects, 0);
449 }
450
451 #[test]
452 fn threads_and_subprocess_defeat_the_replay_safe_estimate() {
453 if !python3_present() {
454 eprintln!("skip: python3 not available");
455 return;
456 }
457 let dir = TempDir::new().unwrap();
458 fs::write(
459 dir.path().join("jobs.py"),
460 r#"import subprocess
461import threading
462import requests
463
464
465def risky():
466 requests.post("https://api.example.com/v1/x")
467 threading.Thread(target=print).start()
468 subprocess.run(["ls"])
469"#,
470 )
471 .unwrap();
472 let s = scan(dir.path());
473 let f = s
474 .functions
475 .iter()
476 .find(|f| f.entrypoint == "py:jobs:risky")
477 .expect("risky attributed");
478 assert_eq!(f.effects, 1);
479 assert_eq!(
480 f.unsafe_reasons,
481 vec![
482 "threading use at jobs.py:8".to_owned(),
483 "subprocess use at jobs.py:9".to_owned(),
484 ]
485 );
486 }
487
488 #[test]
489 fn syntax_error_file_is_skipped_not_fatal() {
490 if !python3_present() {
491 eprintln!("skip: python3 not available");
492 return;
493 }
494 let dir = TempDir::new().unwrap();
495 fs::write(dir.path().join("broken.py"), "def (:\n").unwrap();
496 fs::write(dir.path().join("ok.py"), "import requests\n").unwrap();
497 let scan = scan(dir.path());
498 assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
499 assert!(scan.findings.http_in_use);
500 }
501}