Skip to main content

keel_cli/scan/
python.rs

1//! The Python static scan: an `ast`-walker executed out-of-process via
2//! `python3 -`.
3//!
4//! Parsing Python with Python's own `ast` is exact where a regex would guess:
5//! it sees real imports and string-literal constants with true line numbers.
6//! The walker script is embedded and fed on stdin; it prints one JSON object.
7//! If `python3` is absent the pass yields nothing and reports
8//! [`available`](PyScan::available)`= false` so the caller can say so out loud
9//! rather than silently under-reporting coverage.
10
11use std::io::Write;
12use std::path::Path;
13use std::process::{Command, Stdio};
14
15use serde::Deserialize;
16
17use super::{FunctionFacts, LangFindings, Sighting};
18
19/// The embedded `ast` walker. Deterministic: directories and files are visited
20/// in sorted order, output keys are sorted. Finds imports of the known effect
21/// libraries and URL/DSN string literals, each with `file:line` — and, for
22/// `keel flows suggest`, attributes effect / time / random / replay-unsafe
23/// calls to their enclosing **module-level** function defs (real AST
24/// containment: nested defs and lambdas inside a function count toward it;
25/// class methods are not flow entrypoints and are not attributed).
26const 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/// One import finding from the walker.
228#[derive(Debug, Deserialize)]
229struct Import {
230    lib: String,
231    file: String,
232    line: u32,
233}
234
235/// One URL-literal finding from the walker.
236#[derive(Debug, Deserialize)]
237struct Url {
238    host: String,
239    file: String,
240    line: u32,
241}
242
243/// One module-level function's facts from the walker.
244#[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/// The walker's JSON output, typed.
259#[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/// The Python pass result.
271#[derive(Debug, Clone, Default)]
272pub struct PyScan {
273    /// Whether `python3` ran the walker.
274    pub available: bool,
275    /// Files the walker parsed.
276    pub files_scanned: usize,
277    /// Findings, ready to merge.
278    pub findings: LangFindings,
279    /// Per-function attribution (module-level defs), for `keel flows suggest`.
280    pub functions: Vec<FunctionFacts>,
281}
282
283const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3"];
284
285/// Run the walker over `project`. A missing `python3`, or a walker that fails,
286/// yields an empty unavailable result — never a panic.
287pub 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            // psycopg/boto3: recorded as effect libraries via their DSN/URL
303            // literals (if any); no synthetic host target from the import alone.
304            _ => {}
305        }
306    }
307    // A DSN literal (postgres://…) is itself evidence of an outbound call even
308    // without one of the HTTP libraries imported.
309    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        // The walker already returned a bare hostname (urlsplit.hostname), so it
317        // is lowercased and port-stripped; normalize defensively.
318        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
349/// Spawn `python3 - <root>`, feed the walker on stdin, parse its stdout.
350fn 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        // Resilience libs never pollute `libs` (which feeds doctor's
432        // "invisible/unadapted effect library" coverage classification —
433        // tenacity/backoff were never adapter candidates).
434        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        // get + post + create — the chained .json() must NOT double-count.
478        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}