Skip to main content

dbg_cli/session_db/canonicalizer/
python.rs

1//! Python (CPython / PyPy) symbol canonicalization.
2//!
3//! Raw forms seen in the wild:
4//!   * cProfile pstats: `/path/to/app.py:42(my_func)`
5//!     → fqn=`app.my_func`, file=`/path/to/app.py`, line=42
6//!   * py-spy / austin:   `my_func (app.py)`   → fqn=`app.my_func`, file=`app.py`
7//!   * dotted already:    `app.submodule.my_func` → fqn as-is
8//!   * Built-ins:         `<built-in method builtins.print>` → fqn=`builtins.print`
9//!
10//! Lambdas and comprehensions — `<lambda>`, `<listcomp>`, `<genexpr>` —
11//! get `is_synthetic = true` because their line-number-derived identity
12//! is not stable across edits.
13
14use std::path::Path;
15use std::sync::OnceLock;
16
17use regex::Regex;
18
19use super::{CanonicalSymbol, Canonicalizer};
20
21pub struct PythonCanonicalizer;
22
23impl Canonicalizer for PythonCanonicalizer {
24    fn lang(&self) -> &'static str {
25        "python"
26    }
27
28    fn canonicalize(&self, raw: &str) -> CanonicalSymbol {
29        let parsed = parse(raw);
30        CanonicalSymbol {
31            lang: "python",
32            fqn: parsed.fqn,
33            file: parsed.file,
34            line: parsed.line,
35            demangled: None,
36            raw: raw.to_string(),
37            is_synthetic: parsed.synthetic,
38        }
39    }
40}
41
42struct Parsed {
43    fqn: String,
44    file: Option<String>,
45    line: Option<u32>,
46    synthetic: bool,
47}
48
49fn parse(raw: &str) -> Parsed {
50    // `<built-in method builtins.print>`  → `builtins.print`
51    if let Some(inner) = raw
52        .strip_prefix("<built-in method ")
53        .and_then(|s| s.strip_suffix('>'))
54    {
55        return Parsed {
56            fqn: inner.to_string(),
57            file: None,
58            line: None,
59            synthetic: false,
60        };
61    }
62    // `<method 'write' of 'BufferedWriter' objects>` → `BufferedWriter.write`
63    static METHOD_OF: OnceLock<Regex> = OnceLock::new();
64    let re_method_of = METHOD_OF.get_or_init(|| {
65        Regex::new(r"^<method '(?P<m>[^']+)' of '(?P<t>[^']+)' objects>$").unwrap()
66    });
67    if let Some(c) = re_method_of.captures(raw) {
68        return Parsed {
69            fqn: format!("{}.{}", &c["t"], &c["m"]),
70            file: None,
71            line: None,
72            synthetic: false,
73        };
74    }
75
76    // pstats form: `<file>:<line>(<func>)`
77    static PSTATS: OnceLock<Regex> = OnceLock::new();
78    let re_pstats = PSTATS.get_or_init(|| {
79        Regex::new(r"^(?P<file>[^\s\(]+):(?P<line>\d+)\((?P<func>[^)]+)\)$").unwrap()
80    });
81    if let Some(c) = re_pstats.captures(raw) {
82        let file = c["file"].to_string();
83        let line: u32 = c["line"].parse().ok().unwrap_or(0);
84        let func = c["func"].to_string();
85        let module = module_from_file(&file);
86        let fqn = if module.is_empty() {
87            func.clone()
88        } else {
89            format!("{module}.{func}")
90        };
91        let synthetic = is_synthetic_func(&func);
92        return Parsed {
93            fqn,
94            file: Some(file),
95            line: Some(line),
96            synthetic,
97        };
98    }
99
100    // py-spy form: `my_func (app.py)` / `my_func (app.py:42)`
101    static PYSPY: OnceLock<Regex> = OnceLock::new();
102    let re_pyspy = PYSPY.get_or_init(|| {
103        Regex::new(r"^(?P<func>[A-Za-z_<][\w<>]*)\s+\((?P<file>[^:)]+)(?::(?P<line>\d+))?\)$")
104            .unwrap()
105    });
106    if let Some(c) = re_pyspy.captures(raw) {
107        let func = c["func"].to_string();
108        let file = c["file"].to_string();
109        let line: Option<u32> = c.name("line").and_then(|m| m.as_str().parse().ok());
110        let module = module_from_file(&file);
111        let fqn = if module.is_empty() {
112            func.clone()
113        } else {
114            format!("{module}.{func}")
115        };
116        let synthetic = is_synthetic_func(&func);
117        return Parsed {
118            fqn,
119            file: Some(file),
120            line,
121            synthetic,
122        };
123    }
124
125    // Bare dotted or bare function name.
126    let synthetic = is_synthetic_func(raw);
127    Parsed {
128        fqn: raw.to_string(),
129        file: None,
130        line: None,
131        synthetic,
132    }
133}
134
135fn module_from_file(file: &str) -> String {
136    // Drop directories, drop `.py` suffix. Preserve package-ish dots only
137    // if the filename already contains them (rare).
138    Path::new(file)
139        .file_stem()
140        .and_then(|s| s.to_str())
141        .unwrap_or("")
142        .to_string()
143}
144
145fn is_synthetic_func(f: &str) -> bool {
146    matches!(
147        f,
148        "<lambda>" | "<listcomp>" | "<dictcomp>" | "<setcomp>" | "<genexpr>" | "<module>"
149    )
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn py() -> PythonCanonicalizer {
157        PythonCanonicalizer
158    }
159
160    #[test]
161    fn pstats_form_parsed() {
162        let s = py().canonicalize("/opt/myapp/api.py:42(handle_request)");
163        assert_eq!(s.fqn, "api.handle_request");
164        assert_eq!(s.file.as_deref(), Some("/opt/myapp/api.py"));
165        assert_eq!(s.line, Some(42));
166        assert!(!s.is_synthetic);
167    }
168
169    #[test]
170    fn pstats_lambda_is_synthetic() {
171        let s = py().canonicalize("/opt/myapp/api.py:42(<lambda>)");
172        assert!(s.is_synthetic);
173        assert_eq!(s.fqn, "api.<lambda>");
174    }
175
176    #[test]
177    fn pyspy_form_with_line_parsed() {
178        let s = py().canonicalize("handle_request (api.py:42)");
179        assert_eq!(s.fqn, "api.handle_request");
180        assert_eq!(s.file.as_deref(), Some("api.py"));
181        assert_eq!(s.line, Some(42));
182    }
183
184    #[test]
185    fn pyspy_form_without_line_parsed() {
186        let s = py().canonicalize("handle_request (api.py)");
187        assert_eq!(s.fqn, "api.handle_request");
188        assert_eq!(s.line, None);
189    }
190
191    #[test]
192    fn bare_dotted_is_passed_through() {
193        let s = py().canonicalize("myapp.services.users.login");
194        assert_eq!(s.fqn, "myapp.services.users.login");
195        assert_eq!(s.file, None);
196    }
197
198    #[test]
199    fn builtin_method_form() {
200        let s = py().canonicalize("<built-in method builtins.print>");
201        assert_eq!(s.fqn, "builtins.print");
202    }
203
204    #[test]
205    fn method_of_form() {
206        let s = py().canonicalize("<method 'write' of 'BufferedWriter' objects>");
207        assert_eq!(s.fqn, "BufferedWriter.write");
208    }
209
210    #[test]
211    fn listcomp_synthetic() {
212        let s = py().canonicalize("/app/main.py:10(<listcomp>)");
213        assert!(s.is_synthetic);
214    }
215
216    #[test]
217    fn module_level_synthetic() {
218        let s = py().canonicalize("/app/main.py:1(<module>)");
219        assert!(s.is_synthetic);
220    }
221
222    #[test]
223    fn structured_default_joins_with_dot() {
224        let s = py().canonicalize_structured("", "UserService", "login", "");
225        assert_eq!(s.fqn, "UserService.login");
226    }
227
228    #[test]
229    fn key_is_lang_plus_fqn() {
230        let s = py().canonicalize("app.main");
231        assert_eq!(s.key(), ("python", "app.main"));
232    }
233}