Skip to main content

speccoder_runtime/format/speckit/
python.rs

1//! The Python Spec Kit's scripts run. `.specify/scripts/bash/common.sh`
2//! resolves preset templates with a Python that imports PyYAML — its own
3//! override `SPECKIT_PYTHON_EXECUTABLE`, else `python3` — and the system
4//! `python3` usually has no PyYAML, so with a preset installed
5//! `setup-tasks.sh` finds no tasks template. The installed `specify` runs
6//! on a Python that has it (Spec Kit depends on PyYAML): speccoder finds
7//! that interpreter, checks that it imports `yaml`, and gives it to every
8//! command of a Spec Kit project — unless the person set the override.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::sync::{Mutex, OnceLock};
14use std::time::{Duration, Instant};
15
16/// Spec Kit's override, read by `common.sh`.
17pub const VAR: &str = "SPECKIT_PYTHON_EXECUTABLE";
18/// Its deprecated predecessor, still read when the override is unset.
19const LEGACY: &str = "SPECKIT_PYTHON";
20
21/// How long the probe of an interpreter may take.
22const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
23
24/// What `common.sh` itself requires of the interpreter: Python 3, PyYAML.
25const PROBE: &str = "import sys, yaml; raise SystemExit(sys.version_info.major != 3)";
26
27/// The fix, when no Python with PyYAML is found.
28pub const FIX: &[&str] = &[
29    "python3 -m pip install pyyaml",
30    "export SPECKIT_PYTHON_EXECUTABLE=/path/to/a/python-with-pyyaml",
31];
32
33/// The variables a command of a Spec Kit project gets: the override, set
34/// to the installed `specify`'s own interpreter when it imports PyYAML.
35/// `env` looks up the command's environment (its `PATH` finds `specify`);
36/// nothing when the person set the override or its predecessor, or when
37/// no such interpreter is found.
38#[must_use]
39pub fn command_env(env: &dyn Fn(&str) -> Option<String>) -> Vec<(String, String)> {
40    let set = |k: &str| env(k).is_some_and(|v| !v.is_empty());
41    if set(VAR) || set(LEGACY) {
42        return Vec::new();
43    }
44    specify_interpreter(env("PATH").as_deref())
45        .map(|p| vec![(VAR.to_owned(), p.display().to_string())])
46        .unwrap_or_default()
47}
48
49/// The interpreter of the `specify` that `path` finds, when it imports
50/// PyYAML and a sandboxed command can read it (remembered per `specify`
51/// for the process: every command asks).
52#[must_use]
53pub fn specify_interpreter(path: Option<&str>) -> Option<PathBuf> {
54    static FOUND: OnceLock<Mutex<HashMap<PathBuf, Option<PathBuf>>>> = OnceLock::new();
55    let lookup = path.map(|p| vec![("PATH".to_owned(), p.to_owned())]);
56    let specify = crate::sandbox::resolve_executable(
57        "specify",
58        Path::new("/"),
59        lookup.as_deref().unwrap_or_default(),
60    )?;
61    let cache = FOUND.get_or_init(|| Mutex::new(HashMap::new()));
62    if let Ok(found) = cache.lock()
63        && let Some(hit) = found.get(&specify)
64    {
65        return hit.clone();
66    }
67    let hit = discover(&specify, path);
68    if let Ok(mut found) = cache.lock() {
69        found.insert(specify, hit.clone());
70    }
71    hit
72}
73
74/// The first interpreter of the `specify` at `specify` (canonical) that
75/// imports PyYAML: its shebang's (`#!/…/python`, or `#!/usr/bin/env
76/// python3` on `path`), then the interpreters beside it (a virtual
77/// environment's `bin/`, as `uv tool` and `pipx` install it, whose
78/// launcher may be a `/bin/sh` trampoline).
79#[must_use]
80pub fn discover(specify: &Path, path: Option<&str>) -> Option<PathBuf> {
81    let mut candidates = Vec::new();
82    if let Some(p) = shebang(specify, path) {
83        candidates.push(p);
84    }
85    if let Some(dir) = specify.parent() {
86        candidates.extend(["python3", "python"].map(|n| dir.join(n)));
87    }
88    let hidden = hidden();
89    candidates
90        .into_iter()
91        .filter(|p| !hidden.iter().any(|h| p.starts_with(h)))
92        .find(|p| imports_yaml(p))
93}
94
95/// The Python a script's `#!` line names, if it names one.
96fn shebang(script: &Path, path: Option<&str>) -> Option<PathBuf> {
97    use std::io::Read as _;
98    let mut head = [0u8; 512];
99    let n = std::fs::File::open(script)
100        .and_then(|mut f| f.read(&mut head))
101        .ok()?;
102    let text = String::from_utf8_lossy(&head[..n]);
103    let line = text.lines().next()?.strip_prefix("#!")?;
104    let mut words = line.split_whitespace();
105    let program = PathBuf::from(words.next()?);
106    let python = |p: &Path| {
107        p.file_name()
108            .and_then(|n| n.to_str())
109            .is_some_and(|n| n.starts_with("python"))
110    };
111    if program.file_name().is_some_and(|n| n == "env") {
112        let name = words.find(|w| !w.starts_with('-'))?;
113        let lookup = path.map(|p| vec![("PATH".to_owned(), p.to_owned())]);
114        return python(Path::new(name))
115            .then(|| {
116                crate::sandbox::resolve_executable(
117                    name,
118                    Path::new("/"),
119                    lookup.as_deref().unwrap_or_default(),
120                )
121            })
122            .flatten();
123    }
124    python(&program).then_some(program)
125}
126
127/// The trees a sandboxed command cannot read.
128fn hidden() -> Vec<PathBuf> {
129    std::env::var_os("HOME")
130        .map(PathBuf::from)
131        .map(|home| {
132            crate::sandbox::HIDDEN
133                .iter()
134                .map(|h| home.join(h))
135                .collect()
136        })
137        .unwrap_or_default()
138}
139
140/// Whether `python` is a Python 3 that imports `yaml`, as `common.sh`
141/// asks it (run outside any project: a `yaml.py` there is not PyYAML).
142#[must_use]
143pub fn imports_yaml(python: &Path) -> bool {
144    let Ok(mut child) = Command::new(python)
145        .args(["-c", PROBE])
146        .current_dir(std::env::temp_dir())
147        .stdin(Stdio::null())
148        .stdout(Stdio::null())
149        .stderr(Stdio::null())
150        .spawn()
151    else {
152        return false;
153    };
154    let began = Instant::now();
155    loop {
156        match child.try_wait() {
157            Ok(Some(status)) => return status.success(),
158            Ok(None) if began.elapsed() > PROBE_TIMEOUT => {
159                let _ = child.kill();
160                let _ = child.wait();
161                return false;
162            }
163            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
164            Err(_) => return false,
165        }
166    }
167}
168
169/// The presets installed in the project at `root` (`.specify/presets/<id>/`).
170#[must_use]
171pub fn presets(root: &Path) -> Vec<String> {
172    let mut ids: Vec<String> = std::fs::read_dir(root.join(".specify/presets"))
173        .into_iter()
174        .flatten()
175        .flatten()
176        .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
177        .map(|e| e.file_name().to_string_lossy().into_owned())
178        .filter(|n| !n.starts_with('.'))
179        .collect();
180    ids.sort();
181    ids
182}
183
184/// The Python Spec Kit's scripts would resolve preset templates with,
185/// given the person's environment `env`: the override when it imports
186/// PyYAML, the installed `specify`'s interpreter (what speccoder passes),
187/// else `python3` on `PATH`. `Err`: none imports PyYAML — the reason.
188///
189/// # Errors
190///
191/// No Python with PyYAML is found.
192pub fn resolver(env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
193    let path = env("PATH");
194    for var in [VAR, LEGACY] {
195        if let Some(v) = env(var).filter(|v| !v.is_empty()) {
196            let lookup = path.as_ref().map(|p| vec![("PATH".to_owned(), p.clone())]);
197            let found = crate::sandbox::resolve_executable(
198                &v,
199                Path::new("/"),
200                lookup.as_deref().unwrap_or_default(),
201            );
202            if found.as_deref().is_some_and(imports_yaml) {
203                return Ok(format!("{var}={v}"));
204            }
205            return Err(format!(
206                "{var}={v} is not a Python 3 that imports PyYAML, and an override set by you is kept"
207            ));
208        }
209    }
210    if let Some(p) = specify_interpreter(path.as_deref()) {
211        return Ok(format!("{} (specify's own Python)", p.display()));
212    }
213    let lookup = path.as_ref().map(|p| vec![("PATH".to_owned(), p.clone())]);
214    let python3 = crate::sandbox::resolve_executable(
215        "python3",
216        Path::new("/"),
217        lookup.as_deref().unwrap_or_default(),
218    );
219    match python3 {
220        Some(p) if imports_yaml(&p) => Ok(format!("{} (python3 on PATH)", p.display())),
221        _ => Err(
222            "no Python with PyYAML found: neither specify's own interpreter nor python3 on PATH imports yaml"
223                .to_owned(),
224        ),
225    }
226}
227
228/// The warning for the project at `root`: presets are installed and no
229/// Python Spec Kit's scripts would use imports PyYAML (`None`: no preset,
230/// or one does).
231#[must_use]
232pub fn warning(root: &Path, env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
233    let presets = presets(root);
234    if presets.is_empty() {
235        return None;
236    }
237    resolver(env).err().map(|why| {
238        format!(
239            "Spec Kit preset{} {} installed, but {why}: Spec Kit's scripts cannot resolve preset templates, so `speccoder spec tasks` stops (\"Template 'tasks-template' was not found\")",
240            if presets.len() == 1 { "" } else { "s" },
241            presets.join(", ")
242        )
243    })
244}
245
246/// The same before a preset is installed into the project at `root`.
247#[must_use]
248pub fn warning_before_add(env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
249    resolver(env).err().map(|why| {
250        format!(
251            "{why}: once a preset is installed, Spec Kit's scripts cannot resolve its templates, so `speccoder spec tasks` stops (\"Template 'tasks-template' was not found\")"
252        )
253    })
254}
255
256#[cfg(test)]
257#[cfg(unix)]
258mod tests {
259    use super::*;
260
261    /// A fake `specify` in `<dir>/bin` whose shebang names `<dir>/venv/python`,
262    /// a script that "imports yaml" when `<dir>/venv/yaml.py` exists.
263    fn fake(dir: &Path, yaml: bool) -> PathBuf {
264        use std::os::unix::fs::PermissionsExt as _;
265        let bin = dir.join("bin");
266        let venv = dir.join("venv");
267        std::fs::create_dir_all(&bin).unwrap();
268        std::fs::create_dir_all(&venv).unwrap();
269        let python = venv.join("python");
270        std::fs::write(
271            &python,
272            "#!/bin/sh\ncase \"$2\" in *'import sys, yaml'*) [ -f \"$(dirname \"$0\")/yaml.py\" ] && exit 0; exit 1;; esac\nexit 2\n",
273        )
274        .unwrap();
275        if yaml {
276            std::fs::write(venv.join("yaml.py"), "").unwrap();
277        }
278        let specify = bin.join("specify");
279        std::fs::write(
280            &specify,
281            format!("#!{}\nprint('specify')\n", python.display()),
282        )
283        .unwrap();
284        for f in [&python, &specify] {
285            std::fs::set_permissions(f, std::fs::Permissions::from_mode(0o755)).unwrap();
286        }
287        python
288    }
289
290    #[test]
291    fn the_shebang_interpreter_is_passed_when_it_imports_yaml() {
292        let d = tempfile::tempdir().unwrap();
293        let dir = std::fs::canonicalize(d.path()).unwrap();
294        let python = fake(&dir, true);
295        let path = format!("{}:/usr/bin:/bin", dir.join("bin").display());
296        let env = |k: &str| (k == "PATH").then(|| path.clone());
297        assert_eq!(
298            command_env(&env),
299            vec![(VAR.to_owned(), python.display().to_string())]
300        );
301        // The person's own value is never overridden.
302        for var in [VAR, LEGACY] {
303            let mine = |k: &str| match k {
304                "PATH" => Some(path.clone()),
305                k if k == var => Some("/mine/python".to_owned()),
306                _ => None,
307            };
308            assert!(command_env(&mine).is_empty(), "{var}");
309        }
310    }
311
312    #[test]
313    fn an_interpreter_without_yaml_is_not_passed_and_warns_with_a_preset() {
314        let d = tempfile::tempdir().unwrap();
315        let dir = std::fs::canonicalize(d.path()).unwrap();
316        fake(&dir, false);
317        let path = format!("{}", dir.join("bin").display());
318        let env = |k: &str| (k == "PATH").then(|| path.clone());
319        assert!(command_env(&env).is_empty());
320        let root = dir.join("project");
321        std::fs::create_dir_all(&root).unwrap();
322        assert_eq!(warning(&root, &env), None, "no preset, no warning");
323        std::fs::create_dir_all(root.join(".specify/presets/speccoder")).unwrap();
324        let w = warning(&root, &env).unwrap_or_default();
325        assert!(w.contains("speccoder") && w.contains("PyYAML"), "{w}");
326        assert!(warning_before_add(&env).is_some());
327    }
328
329    #[test]
330    fn a_uv_tool_trampoline_finds_the_interpreter_beside_it() {
331        use std::os::unix::fs::PermissionsExt as _;
332        let d = tempfile::tempdir().unwrap();
333        let dir = std::fs::canonicalize(d.path()).unwrap();
334        let python = fake(&dir, true);
335        // uv's launcher for a long path: a /bin/sh trampoline in the venv.
336        let specify = dir.join("venv/specify");
337        std::fs::write(
338            &specify,
339            "#!/bin/sh\n'''exec' \"$(dirname -- \"$(realpath -- \"$0\")\")\"/'python' \"$0\" \"$@\"\n' '''\n",
340        )
341        .unwrap();
342        std::fs::set_permissions(&specify, std::fs::Permissions::from_mode(0o755)).unwrap();
343        assert_eq!(discover(&specify, None), Some(python));
344    }
345}