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", "google.genai"}
32OTHER_LIBS = {"psycopg", "boto3"}
33# The agent-framework packs (dx-spec agent-first-class work). pydantic_ai,
34# crewai, langgraph, and openai-agents (import name `agents`) are plain
35# top-level module names, classified exactly like HTTP_LIBS/LLM_LIBS/
36# OTHER_LIBS above. `mcp` is the SDK's own import name (mcp_pack) — a normal
37# adapter lib here, not special-cased. `google.adk` is handled separately via
38# dotted-prefix matching (see `import_entries`): bare `google` is a namespace
39# package shared with unrelated distributions (google-protobuf and friends)
40# and must record nothing.
41AGENT_LIBS = {"pydantic_ai", "crewai", "langgraph", "agents", "mcp"}
42KNOWN = HTTP_LIBS | LLM_LIBS | OTHER_LIBS | AGENT_LIBS | {"google.adk"}
43# Known Python resilience libraries — a `keel doctor` signal that a target may
44# already have its own retry/backoff, separate from KNOWN (which is about
45# libraries Keel *adapts*; these are libraries Keel never adapts, so mixing
46# them into KNOWN would misclassify them as an "invisible" coverage gap).
47RESILIENCE_LIBS = {"tenacity", "backoff", "retrying", "stamina"}
48TIME_LIBS = {"time", "datetime"}
49RANDOM_LIBS = {"random", "uuid", "secrets"}
50UNSAFE_LIBS = {"threading", "multiprocessing", "subprocess", "socket"}
51TRACKED = KNOWN | TIME_LIBS | RANDOM_LIBS | UNSAFE_LIBS | {"os"}
52TIME_NAMES = {"time", "time_ns", "monotonic", "monotonic_ns",
53              "perf_counter", "perf_counter_ns", "gmtime", "localtime"}
54DT_NAMES = {"now", "utcnow", "today"}
55UUID_NAMES = {"uuid1", "uuid3", "uuid4", "uuid5"}
56OS_UNSAFE = {"system", "popen", "fork", "forkpty", "execv", "execve",
57             "execvp", "execvpe", "spawnl", "spawnv", "spawnvp"}
58SKIP = {".keel", ".git", "__pycache__", "node_modules", ".venv", "venv",
59        ".mypy_cache", ".pytest_cache", "dist", "build", "target"}
60# The two `google` submodules Keel adapts. Bare `import google` (the
61# namespace package) records nothing — it also hosts unrelated distributions
62# (google-protobuf and friends) that are none of Keel's business.
63GOOGLE_SUBMODULES = {"adk", "genai"}
64
65
66def top(mod):
67    return mod.split(".", 1)[0] if mod else ""
68
69
70def import_entries(node):
71    """Yield (module_key, bound_name) for each name an Import/ImportFrom node
72    binds. Handles the `google.adk` / `google.genai` dotted-prefix special
73    case: `import google.adk`, `from google.adk import ...`, and
74    `from google import genai, adk` all resolve to `google.<name>`; a bare
75    `google` import (or `from google import <something else>`) yields
76    nothing — the google namespace package alone is not evidence of either
77    library."""
78    if isinstance(node, ast.Import):
79        for alias in node.names:
80            parts = alias.name.split(".")
81            if len(parts) >= 2 and parts[0] == "google" and parts[1] in GOOGLE_SUBMODULES:
82                key = "google." + parts[1]
83            else:
84                key = parts[0]
85            yield key, (alias.asname or alias.name).split(".", 1)[0]
86    elif isinstance(node, ast.ImportFrom):
87        mod = node.module or ""
88        mparts = mod.split(".")
89        if len(mparts) >= 2 and mparts[0] == "google" and mparts[1] in GOOGLE_SUBMODULES:
90            key = "google." + mparts[1]
91            for alias in node.names:
92                yield key, alias.asname or alias.name
93        elif mod == "google":
94            for alias in node.names:
95                if alias.name in GOOGLE_SUBMODULES:
96                    yield "google." + alias.name, alias.asname or alias.name
97        else:
98            key = top(mod)
99            for alias in node.names:
100                yield key, alias.asname or alias.name
101
102
103def host(s):
104    if "://" not in s:
105        return None
106    try:
107        parts = urlsplit(s.strip())
108    except ValueError:
109        return None
110    if not parts.scheme or not parts.hostname:
111        return None
112    return parts.hostname
113
114
115def call_root(f):
116    """The Name at the base of a call's attribute chain, or None. Deliberately
117    does NOT see through intermediate calls: in `httpx.get(u).json()` only the
118    inner `httpx.get(u)` has a root, so a chained method on a call result is
119    never double-counted as a second effect."""
120    while isinstance(f, ast.Attribute):
121        f = f.value
122    return f.id if isinstance(f, ast.Name) else None
123
124
125# Call names that construct a handle rather than perform an effect: CapWords
126# constructors (OpenAI(), Client()) plus the well-known factory methods.
127FACTORY_NAMES = {"client", "resource", "session", "connect"}
128
129
130def is_constructor(name):
131    return name is None or name[:1].isupper() or name in FACTORY_NAMES
132
133
134def aliases_of(tree):
135    """Binding name -> tracked module (top-level, or `google.adk`/
136    `google.genai` — see `import_entries`). Also follows one hop of
137    constructor assignment (client = OpenAI() -> client is an openai handle),
138    the dominant SDK-client pattern."""
139    a = {}
140    for node in ast.walk(tree):
141        if isinstance(node, (ast.Import, ast.ImportFrom)):
142            for key, bound in import_entries(node):
143                if key in TRACKED:
144                    a[bound] = key
145    for node in ast.walk(tree):
146        if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
147            lib = a.get(call_root(node.value.func))
148            if lib in KNOWN:
149                for tgt in node.targets:
150                    if isinstance(tgt, ast.Name):
151                        a[tgt.id] = lib
152    return a
153
154
155def url_consts_of(tree):
156    """Module-level NAME = "scheme://host/..." constants -> host, so a URL
157    hoisted to a constant still attributes to the functions that use it."""
158    consts = {}
159    for node in tree.body:
160        if (isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant)
161                and isinstance(node.value.value, str)):
162            h = host(node.value.value)
163            if h:
164                for tgt in node.targets:
165                    if isinstance(tgt, ast.Name):
166                        consts[tgt.id] = h
167    return consts
168
169
170def fn_facts(fn, rel, mod, aliases, url_consts):
171    effects = unsafe_idem = t_reads = r_reads = 0
172    targets = set()
173    reasons = []
174    for node in ast.walk(fn):
175        if isinstance(node, ast.Name) and node.id in url_consts:
176            targets.add(url_consts[node.id])
177        elif isinstance(node, ast.Constant) and isinstance(node.value, str):
178            h = host(node.value)
179            if h:
180                targets.add(h)
181        if not isinstance(node, ast.Call):
182            continue
183        lib = aliases.get(call_root(node.func))
184        if lib is None:
185            continue
186        attr = node.func.attr if isinstance(node.func, ast.Attribute) else None
187        name = attr if attr is not None else call_root(node.func)
188        if lib in KNOWN:
189            if is_constructor(name):
190                continue  # a handle being built, not an effect performed
191            effects += 1
192            if name in {"post", "patch"}:
193                unsafe_idem += 1
194            if lib in LLM_LIBS:
195                targets.add("llm:" + lib)
196        elif lib == "time" and name in TIME_NAMES:
197            t_reads += 1
198        elif lib == "datetime" and name in DT_NAMES:
199            t_reads += 1
200        elif lib in {"random", "secrets"}:
201            r_reads += 1
202        elif lib == "uuid" and name in UUID_NAMES:
203            r_reads += 1
204        elif lib == "os" and name == "urandom":
205            r_reads += 1
206        elif lib in UNSAFE_LIBS:
207            reasons.append((node.lineno, "%s use at %s:%d" % (lib, rel, node.lineno)))
208        elif lib == "os" and name in OS_UNSAFE:
209            reasons.append((node.lineno, "os.%s at %s:%d" % (name, rel, node.lineno)))
210    return {"effects": effects, "file": rel, "idempotent_unsafe": unsafe_idem,
211            "line": fn.lineno, "module": mod, "name": fn.name,
212            "random_reads": r_reads, "targets": sorted(targets),
213            "time_reads": t_reads,
214            "unsafe_reasons": [t for _, t in sorted(reasons)]}
215
216
217root = sys.argv[1] if len(sys.argv) > 1 else "."
218imports = []
219urls = []
220functions = []
221resilience_libs = set()
222files = 0
223for dirpath, dirnames, filenames in os.walk(root):
224    dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not d.startswith("."))
225    for fn in sorted(filenames):
226        if not fn.endswith(".py"):
227            continue
228        path = os.path.join(dirpath, fn)
229        rel = os.path.relpath(path, root).replace(os.sep, "/")
230        try:
231            with open(path, "r", encoding="utf-8") as fh:
232                tree = ast.parse(fh.read())
233        except (OSError, SyntaxError, UnicodeDecodeError, ValueError):
234            continue
235        files += 1
236        for node in ast.walk(tree):
237            if isinstance(node, (ast.Import, ast.ImportFrom)):
238                for key, _bound in import_entries(node):
239                    if key in KNOWN:
240                        imports.append({"lib": key, "file": rel, "line": node.lineno})
241                    elif key in RESILIENCE_LIBS:
242                        resilience_libs.add(key)
243            elif isinstance(node, ast.Constant) and isinstance(node.value, str):
244                h = host(node.value)
245                if h:
246                    urls.append({"host": h, "file": rel, "line": node.lineno})
247        mod = rel[:-3].replace("/", ".")
248        if mod.endswith(".__init__"):
249            mod = mod[: -len(".__init__")]
250        aliases = aliases_of(tree)
251        consts = url_consts_of(tree)
252        for node in tree.body:
253            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
254                functions.append(fn_facts(node, rel, mod, aliases, consts))
255
256print(json.dumps({"files_scanned": files, "functions": functions,
257                  "imports": imports, "resilience_libs": sorted(resilience_libs),
258                  "urls": urls}, sort_keys=True))
259"#;
260
261/// One import finding from the walker.
262#[derive(Debug, Deserialize)]
263struct Import {
264    lib: String,
265    file: String,
266    line: u32,
267}
268
269/// One URL-literal finding from the walker.
270#[derive(Debug, Deserialize)]
271struct Url {
272    host: String,
273    file: String,
274    line: u32,
275}
276
277/// One module-level function's facts from the walker.
278#[derive(Debug, Deserialize)]
279struct PyFunction {
280    effects: u32,
281    file: String,
282    idempotent_unsafe: u32,
283    line: u32,
284    module: String,
285    name: String,
286    random_reads: u32,
287    targets: Vec<String>,
288    time_reads: u32,
289    unsafe_reasons: Vec<String>,
290}
291
292/// The walker's JSON output, typed.
293#[derive(Debug, Deserialize)]
294struct WalkerOutput {
295    files_scanned: usize,
296    #[serde(default)]
297    functions: Vec<PyFunction>,
298    imports: Vec<Import>,
299    #[serde(default)]
300    resilience_libs: Vec<String>,
301    urls: Vec<Url>,
302}
303
304/// The Python pass result.
305#[derive(Debug, Clone, Default)]
306pub struct PyScan {
307    /// Whether `python3` ran the walker.
308    pub available: bool,
309    /// Files the walker parsed.
310    pub files_scanned: usize,
311    /// Findings, ready to merge.
312    pub findings: LangFindings,
313    /// Per-function attribution (module-level defs), for `keel flows suggest`.
314    pub functions: Vec<FunctionFacts>,
315}
316
317const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3"];
318
319/// Normalize a walker-reported import key to the name `keel doctor`'s
320/// `REGISTRY` (see `crate::doctor`) keys its adapters by. The walker emits
321/// raw Python identifiers (`google.adk`, `pydantic_ai`, `agents`); doctor's
322/// registry — and the docs/findings that reference it — use the packs'
323/// PyPI-ish public names (`google-adk`, `pydantic-ai`, `openai-agents`).
324/// Everything else passes through unchanged (`crewai`, `langgraph`, `mcp`,
325/// `httpx`, `openai`, …).
326fn normalize_lib(lib: &str) -> String {
327    match lib {
328        "google.adk" => "google-adk".to_owned(),
329        "google.genai" => "google-genai".to_owned(),
330        "pydantic_ai" => "pydantic-ai".to_owned(),
331        "agents" => "openai-agents".to_owned(),
332        other => other.to_owned(),
333    }
334}
335
336/// Run the walker over `project`. A missing `python3`, or a walker that fails,
337/// yields an empty unavailable result — never a panic.
338pub fn scan(project: &Path) -> PyScan {
339    let Some(output) = run_walker(project) else {
340        return PyScan::default();
341    };
342    let mut findings = LangFindings::default();
343    for imp in &output.imports {
344        let lib = normalize_lib(&imp.lib);
345        findings.libs.insert(lib.clone());
346        let sighting = Sighting {
347            file: imp.file.clone(),
348            line: imp.line,
349        };
350        match lib.as_str() {
351            "openai" => findings.llm.push(("openai".to_owned(), sighting)),
352            "anthropic" => findings.llm.push(("anthropic".to_owned(), sighting)),
353            "google-genai" => findings.llm.push(("google-genai".to_owned(), sighting)),
354            lib if HTTP_LIBS.contains(&lib) => findings.http_in_use = true,
355            // psycopg/boto3/agent-pack libs: recorded as effect libraries via
356            // their DSN/URL literals (if any) or the doctor adapter registry;
357            // no synthetic host target from the import alone.
358            _ => {}
359        }
360    }
361    // A DSN literal (postgres://…) is itself evidence of an outbound call even
362    // without one of the HTTP libraries imported.
363    if !output.urls.is_empty() {
364        findings.http_in_use = true;
365    }
366    for lib in &output.resilience_libs {
367        findings.resilience_libs.insert(lib.clone());
368    }
369    for url in &output.urls {
370        // The walker already returned a bare hostname (urlsplit.hostname), so it
371        // is lowercased and port-stripped; normalize defensively.
372        findings.hosts.push((
373            url.host.to_ascii_lowercase(),
374            Sighting {
375                file: url.file.clone(),
376                line: url.line,
377            },
378        ));
379    }
380    let functions = output
381        .functions
382        .into_iter()
383        .map(|f| FunctionFacts {
384            entrypoint: format!("py:{}:{}", f.module, f.name),
385            file: f.file,
386            line: f.line,
387            effects: f.effects,
388            idempotent_unsafe: f.idempotent_unsafe,
389            time_reads: f.time_reads,
390            random_reads: f.random_reads,
391            unsafe_reasons: f.unsafe_reasons,
392            targets: f.targets.into_iter().collect(),
393        })
394        .collect();
395    PyScan {
396        available: true,
397        files_scanned: output.files_scanned,
398        findings,
399        functions,
400    }
401}
402
403/// Spawn `python3 - <root>`, feed the walker on stdin, parse its stdout.
404fn run_walker(project: &Path) -> Option<WalkerOutput> {
405    let mut child = Command::new("python3")
406        .arg("-")
407        .arg(project)
408        .stdin(Stdio::piped())
409        .stdout(Stdio::piped())
410        .stderr(Stdio::null())
411        .spawn()
412        .ok()?;
413    child.stdin.take()?.write_all(AST_WALKER.as_bytes()).ok()?;
414    let out = child.wait_with_output().ok()?;
415    if !out.status.success() {
416        return None;
417    }
418    serde_json::from_slice(&out.stdout).ok()
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use std::fs;
425    use tempfile::TempDir;
426
427    fn python3_present() -> bool {
428        Command::new("python3")
429            .arg("--version")
430            .stdout(Stdio::null())
431            .stderr(Stdio::null())
432            .status()
433            .is_ok_and(|s| s.success())
434    }
435
436    #[test]
437    fn walks_imports_and_url_literals() {
438        if !python3_present() {
439            eprintln!("skip: python3 not available");
440            return;
441        }
442        let dir = TempDir::new().unwrap();
443        fs::write(
444            dir.path().join("app.py"),
445            "import httpx\nfrom openai import OpenAI\n\nURL = \"https://api.example.com/v1\"\n",
446        )
447        .unwrap();
448        let scan = scan(dir.path());
449        assert!(scan.available);
450        assert_eq!(scan.files_scanned, 1);
451        assert!(scan.findings.http_in_use);
452        assert!(
453            scan.findings
454                .llm
455                .iter()
456                .any(|(p, s)| p == "openai" && s.file == "app.py" && s.line == 2)
457        );
458        assert!(
459            scan.findings
460                .hosts
461                .iter()
462                .any(|(h, s)| h == "api.example.com" && s.line == 4)
463        );
464    }
465
466    #[test]
467    fn agent_pack_imports_and_the_google_dotted_prefix_are_classified() {
468        if !python3_present() {
469            eprintln!("skip: python3 not available");
470            return;
471        }
472        let dir = TempDir::new().unwrap();
473        fs::write(
474            dir.path().join("app.py"),
475            "import google\n\
476             import google.protobuf\n\
477             from google import protobuf\n\
478             import google.adk\n\
479             from google.genai import types\n\
480             from google import genai, adk\n\
481             from pydantic_ai import Agent\n\
482             import crewai\n\
483             from langgraph.graph import StateGraph\n\
484             from agents import Agent as OpenAIAgent\n\
485             import mcp\n\
486             from tenacity import retry\n",
487        )
488        .unwrap();
489        let scan = scan(dir.path());
490        assert!(scan.available);
491        // The six agent-framework packs + the two google submodules, all
492        // normalized to doctor's REGISTRY names.
493        for lib in [
494            "google-adk",
495            "google-genai",
496            "pydantic-ai",
497            "crewai",
498            "langgraph",
499            "openai-agents",
500            "mcp",
501        ] {
502            assert!(scan.findings.libs.contains(lib), "missing lib: {lib}");
503        }
504        // google.genai joins the llm findings, alongside openai/anthropic.
505        assert!(scan.findings.llm.iter().any(|(p, _)| p == "google-genai"));
506        // tenacity is resilience, not a coverage-relevant lib.
507        assert_eq!(
508            scan.findings.resilience_libs,
509            ["tenacity".to_owned()].into_iter().collect()
510        );
511        // Bare `google` (the namespace package) must record NOTHING — it
512        // also hosts unrelated distributions (google-protobuf and friends).
513        // Neither must `import google.protobuf` nor `from google import
514        // protobuf`: `protobuf` isn't one of Keel's two adapted submodules
515        // (`GOOGLE_SUBMODULES = {"adk", "genai"}`), so both forms must fall
516        // through to the same "namespace package, not evidence" handling —
517        // never surfacing as `google`, `google.protobuf`, or `protobuf`.
518        for leaked in ["google", "google.protobuf", "protobuf"] {
519            assert!(
520                !scan.findings.libs.contains(leaked),
521                "google.protobuf import must not record {leaked}"
522            );
523        }
524    }
525
526    #[test]
527    fn resilience_lib_imports_are_detected_separately_from_known_libs() {
528        if !python3_present() {
529            eprintln!("skip: python3 not available");
530            return;
531        }
532        let dir = TempDir::new().unwrap();
533        fs::write(
534            dir.path().join("app.py"),
535            "import httpx\nfrom tenacity import retry\nimport backoff\n",
536        )
537        .unwrap();
538        let scan = scan(dir.path());
539        assert_eq!(
540            scan.findings.resilience_libs,
541            ["backoff".to_owned(), "tenacity".to_owned()]
542                .into_iter()
543                .collect()
544        );
545        // Resilience libs never pollute `libs` (which feeds doctor's
546        // "invisible/unadapted effect library" coverage classification —
547        // tenacity/backoff were never adapter candidates).
548        assert!(!scan.findings.libs.contains("tenacity"));
549        assert!(!scan.findings.libs.contains("backoff"));
550        assert!(scan.findings.libs.contains("httpx"));
551    }
552
553    #[test]
554    fn attributes_effects_time_random_to_module_level_functions() {
555        if !python3_present() {
556            eprintln!("skip: python3 not available");
557            return;
558        }
559        let dir = TempDir::new().unwrap();
560        fs::write(
561            dir.path().join("pipeline.py"),
562            r#"import time
563import random
564import httpx
565from openai import OpenAI
566
567API = "https://api.example.com/v1/data"
568client = OpenAI()
569
570
571def main():
572    started = time.time()
573    seed = random.random()
574    data = httpx.get(API).json()
575    httpx.post(API, json=data)
576    client.responses.create(model="gpt-4.1", input="hi")
577    return started, seed
578
579
580def helper():
581    return 41 + 1
582"#,
583        )
584        .unwrap();
585        let s = scan(dir.path());
586        let main = s
587            .functions
588            .iter()
589            .find(|f| f.entrypoint == "py:pipeline:main")
590            .expect("main attributed");
591        // get + post + create — the chained .json() must NOT double-count.
592        assert_eq!(main.effects, 3);
593        assert_eq!(main.idempotent_unsafe, 1, "only the POST");
594        assert_eq!(main.time_reads, 1);
595        assert_eq!(main.random_reads, 1);
596        assert!(main.unsafe_reasons.is_empty());
597        assert!(main.targets.contains("api.example.com"), "URL via constant");
598        assert!(main.targets.contains("llm:openai"), "client = OpenAI() hop");
599        assert_eq!((main.file.as_str(), main.line), ("pipeline.py", 10));
600        let helper = s
601            .functions
602            .iter()
603            .find(|f| f.entrypoint == "py:pipeline:helper")
604            .expect("helper attributed");
605        assert_eq!(helper.effects, 0);
606    }
607
608    #[test]
609    fn threads_and_subprocess_defeat_the_replay_safe_estimate() {
610        if !python3_present() {
611            eprintln!("skip: python3 not available");
612            return;
613        }
614        let dir = TempDir::new().unwrap();
615        fs::write(
616            dir.path().join("jobs.py"),
617            r#"import subprocess
618import threading
619import requests
620
621
622def risky():
623    requests.post("https://api.example.com/v1/x")
624    threading.Thread(target=print).start()
625    subprocess.run(["ls"])
626"#,
627        )
628        .unwrap();
629        let s = scan(dir.path());
630        let f = s
631            .functions
632            .iter()
633            .find(|f| f.entrypoint == "py:jobs:risky")
634            .expect("risky attributed");
635        assert_eq!(f.effects, 1);
636        assert_eq!(
637            f.unsafe_reasons,
638            vec![
639                "threading use at jobs.py:8".to_owned(),
640                "subprocess use at jobs.py:9".to_owned(),
641            ]
642        );
643    }
644
645    #[test]
646    fn syntax_error_file_is_skipped_not_fatal() {
647        if !python3_present() {
648            eprintln!("skip: python3 not available");
649            return;
650        }
651        let dir = TempDir::new().unwrap();
652        fs::write(dir.path().join("broken.py"), "def (:\n").unwrap();
653        fs::write(dir.path().join("ok.py"), "import requests\n").unwrap();
654        let scan = scan(dir.path());
655        assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
656        assert!(scan.findings.http_in_use);
657    }
658}