capsule_core/wasm/compiler/
python.rs1use 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}