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