speccoder_runtime/format/speckit/
python.rs1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::sync::{Mutex, OnceLock};
14use std::time::{Duration, Instant};
15
16pub const VAR: &str = "SPECKIT_PYTHON_EXECUTABLE";
18const LEGACY: &str = "SPECKIT_PYTHON";
20
21const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
23
24const PROBE: &str = "import sys, yaml; raise SystemExit(sys.version_info.major != 3)";
26
27pub const FIX: &[&str] = &[
29 "python3 -m pip install pyyaml",
30 "export SPECKIT_PYTHON_EXECUTABLE=/path/to/a/python-with-pyyaml",
31];
32
33#[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#[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#[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
95fn 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
127fn 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#[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#[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
184pub 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#[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#[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 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 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 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}