capsule_core/wasm/compiler/
python.rs

1use std::fmt;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::process::Stdio;
6
7use super::CAPSULE_WIT;
8
9pub enum PythonWasmCompilerError {
10    CompileFailed(String),
11    FsError(String),
12}
13
14impl fmt::Display for PythonWasmCompilerError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            PythonWasmCompilerError::CompileFailed(msg) => {
18                write!(f, "Compilation failed > {}", msg)
19            }
20            PythonWasmCompilerError::FsError(msg) => write!(f, "File system error > {}", msg),
21        }
22    }
23}
24
25impl From<std::io::Error> for PythonWasmCompilerError {
26    fn from(err: std::io::Error) -> Self {
27        PythonWasmCompilerError::CompileFailed(err.to_string())
28    }
29}
30
31impl From<std::time::SystemTimeError> for PythonWasmCompilerError {
32    fn from(err: std::time::SystemTimeError) -> Self {
33        PythonWasmCompilerError::FsError(err.to_string())
34    }
35}
36
37pub struct PythonWasmCompiler {
38    pub source_path: PathBuf,
39    pub cache_dir: PathBuf,
40    pub output_wasm: PathBuf,
41}
42
43impl PythonWasmCompiler {
44    fn get_python_command() -> String {
45        let candidates = vec!["python3", "python"];
46
47        for cmd in candidates {
48            if Command::new(cmd)
49                .arg("--version")
50                .stdout(Stdio::null())
51                .stderr(Stdio::null())
52                .status()
53                .is_ok()
54            {
55                return cmd.to_string();
56            }
57        }
58
59        "python3".to_string()
60    }
61
62    fn normalize_path_for_command(path: &Path) -> PathBuf {
63        #[cfg(windows)]
64        {
65            let path_str = path.to_string_lossy();
66            if path_str.starts_with(r"\\?\") {
67                return PathBuf::from(&path_str[4..]);
68            }
69        }
70        path.to_path_buf()
71    }
72
73    pub fn new(source_path: &Path) -> Result<Self, PythonWasmCompilerError> {
74        let source_path = source_path.canonicalize().map_err(|e| {
75            PythonWasmCompilerError::FsError(format!("Cannot resolve source path: {}", e))
76        })?;
77
78        let source_dir = source_path
79            .parent()
80            .ok_or(PythonWasmCompilerError::FsError(
81                "Cannot determine source directory".to_string(),
82            ))?;
83
84        let cache_dir = source_dir.join(".capsule");
85        let output_wasm = cache_dir.join("capsule.wasm");
86
87        if !cache_dir.exists() {
88            fs::create_dir_all(&cache_dir)?;
89        }
90
91        Ok(Self {
92            source_path,
93            cache_dir,
94            output_wasm,
95        })
96    }
97
98    pub fn compile_wasm(&self) -> Result<PathBuf, PythonWasmCompilerError> {
99        if self.needs_rebuild(&self.source_path, &self.output_wasm)? {
100            let module_name = self
101                .source_path
102                .file_stem()
103                .ok_or(PythonWasmCompilerError::FsError(
104                    "Invalid source file name".to_string(),
105                ))?
106                .to_str()
107                .ok_or(PythonWasmCompilerError::FsError(
108                    "Invalid UTF-8 in file name".to_string(),
109                ))?;
110
111            let python_path = self
112                .source_path
113                .parent()
114                .ok_or(PythonWasmCompilerError::FsError(
115                    "Cannot determine parent directory".to_string(),
116                ))?;
117
118            let wit_path = self.get_wit_path()?;
119
120            let sdk_path = self.get_sdk_path()?;
121
122            if !sdk_path.exists() {
123                return Err(PythonWasmCompilerError::FsError(format!(
124                    "SDK directory not found: {}",
125                    sdk_path.display()
126                )));
127            }
128
129            if !sdk_path.exists() {
130                return Err(PythonWasmCompilerError::FsError(format!(
131                    "SDK directory not found: {}",
132                    sdk_path.display()
133                )));
134            }
135
136            let bootloader_path = self.cache_dir.join("_capsule_boot.py");
137            let bootloader_content = format!(
138                r#"# Auto-generated bootloader for Capsule
139import {module_name}
140import capsule.app
141capsule.app._main_module = {module_name}
142from capsule.app import TaskRunner, exports
143"#,
144                module_name = module_name
145            );
146
147            fs::write(&bootloader_path, bootloader_content)?;
148
149            let wit_path_normalized = Self::normalize_path_for_command(&wit_path);
150            let cache_dir_normalized = Self::normalize_path_for_command(&self.cache_dir);
151            let python_path_normalized = Self::normalize_path_for_command(python_path);
152            let sdk_path_normalized = Self::normalize_path_for_command(&sdk_path);
153            let output_wasm_normalized = Self::normalize_path_for_command(&self.output_wasm);
154
155            let output = Command::new("componentize-py")
156                .arg("-d")
157                .arg(&wit_path_normalized)
158                .arg("-w")
159                .arg("capsule-agent")
160                .arg("componentize")
161                .arg("_capsule_boot")
162                .arg("-p")
163                .arg(&cache_dir_normalized)
164                .arg("-p")
165                .arg(&python_path_normalized)
166                .arg("-p")
167                .arg(&sdk_path_normalized)
168                .arg("-o")
169                .arg(&output_wasm_normalized)
170                .stdout(Stdio::piped())
171                .stderr(Stdio::piped())
172                .output()?;
173
174            if !output.status.success() {
175                return Err(PythonWasmCompilerError::CompileFailed(format!(
176                    "Compilation failed: {}",
177                    String::from_utf8_lossy(&output.stderr).trim()
178                )));
179            }
180        }
181
182        Ok(self.output_wasm.clone())
183    }
184
185    fn needs_rebuild(
186        &self,
187        source: &Path,
188        wasm_path: &Path,
189    ) -> Result<bool, PythonWasmCompilerError> {
190        if !wasm_path.exists() {
191            return Ok(true);
192        }
193
194        let wasm_time = fs::metadata(wasm_path).and_then(|m| m.modified())?;
195
196        let source_time = fs::metadata(source).and_then(|m| m.modified())?;
197        if source_time > wasm_time {
198            return Ok(true);
199        }
200
201        if let Some(source_dir) = source.parent()
202            && Self::check_dir_modified(source_dir, source, wasm_time)?
203        {
204            return Ok(true);
205        }
206
207        Ok(false)
208    }
209
210    fn check_dir_modified(
211        dir: &Path,
212        source: &Path,
213        wasm_time: std::time::SystemTime,
214    ) -> Result<bool, PythonWasmCompilerError> {
215        if let Ok(entries) = fs::read_dir(dir) {
216            for entry in entries.flatten() {
217                let path = entry.path();
218
219                if path.is_dir() {
220                    let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
221                    if dir_name.starts_with('.') || dir_name == "__pycache__" {
222                        continue;
223                    }
224
225                    if Self::check_dir_modified(&path, source, wasm_time)? {
226                        return Ok(true);
227                    }
228                } else if path.extension().is_some_and(|ext| ext == "py")
229                    && path != source
230                    && let Ok(metadata) = fs::metadata(&path)
231                    && let Ok(modified) = metadata.modified()
232                    && modified > wasm_time
233                {
234                    return Ok(true);
235                }
236            }
237        }
238
239        Ok(false)
240    }
241
242    fn get_wit_path(&self) -> Result<PathBuf, PythonWasmCompilerError> {
243        if let Ok(path) = std::env::var("CAPSULE_WIT_PATH") {
244            let wit_path = PathBuf::from(path);
245            if wit_path.exists() {
246                return Ok(wit_path);
247            }
248        }
249
250        let wit_dir = self.cache_dir.join("wit");
251        let wit_file = wit_dir.join("capsule.wit");
252
253        if !wit_file.exists() {
254            fs::create_dir_all(&wit_dir)?;
255            fs::write(&wit_file, CAPSULE_WIT)?;
256        }
257
258        Ok(wit_dir)
259    }
260
261    fn get_sdk_path(&self) -> Result<PathBuf, PythonWasmCompilerError> {
262        if let Ok(path) = std::env::var("CAPSULE_SDK_PATH") {
263            let sdk_path = PathBuf::from(path);
264            if sdk_path.exists() {
265                return Ok(sdk_path);
266            }
267        }
268
269        if let Ok(sdk_path) = self.find_sdk_via_python() {
270            return Ok(sdk_path);
271        }
272
273        if let Ok(exe_path) = std::env::current_exe()
274            && let Some(project_root) = exe_path
275                .parent()
276                .and_then(|p| p.parent())
277                .and_then(|p| p.parent())
278                .and_then(|p| p.parent())
279                .and_then(|p| p.parent())
280        {
281            let sdk_path = project_root.join("crates/capsule-sdk/python/src");
282            if sdk_path.exists() {
283                return Ok(sdk_path);
284            }
285        }
286
287        Err(PythonWasmCompilerError::FsError(
288            "Cannot find SDK. Set CAPSULE_SDK_PATH environment variable or install capsule package.".to_string(),
289        ))
290    }
291
292    fn find_sdk_via_python(&self) -> Result<PathBuf, PythonWasmCompilerError> {
293        let python_cmd = Self::get_python_command();
294        let output = Command::new(&python_cmd)
295            .arg("-c")
296            .arg("import capsule; import os; print(os.path.dirname(os.path.dirname(capsule.__file__)), end='')")
297            .output()
298            .map_err(|e| {
299                PythonWasmCompilerError::FsError(format!("Failed to execute {}: {}", python_cmd, e))
300            })?;
301
302        if !output.status.success() {
303            let stderr = String::from_utf8_lossy(&output.stderr);
304            return Err(PythonWasmCompilerError::FsError(format!(
305                "Python command failed: {}",
306                stderr
307            )));
308        }
309
310        let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
311
312        if path_str.is_empty() {
313            return Err(PythonWasmCompilerError::FsError(
314                "Python returned empty path for capsule package".to_string(),
315            ));
316        }
317
318        let sdk_path = PathBuf::from(&path_str);
319
320        if !sdk_path.exists() {
321            return Err(PythonWasmCompilerError::FsError(format!(
322                "SDK path from Python does not exist: {}",
323                sdk_path.display()
324            )));
325        }
326
327        Ok(sdk_path)
328    }
329}