Skip to main content

pro_core/python/
manager.rs

1//! Python version manager
2//!
3//! Handles downloading, installing, and managing Python versions from
4//! python-build-standalone releases.
5
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::{Error, Result};
12
13use super::platform::Platform;
14use super::versions::{available_versions, find_matching_version, AvailableVersion, PythonVersion};
15
16/// Base URL for python-build-standalone releases
17const PBS_RELEASE_URL: &str =
18    "https://github.com/astral-sh/python-build-standalone/releases/download";
19
20/// Information about an installed Python version
21#[derive(Debug, Clone)]
22pub struct InstalledPython {
23    pub version: PythonVersion,
24    pub path: PathBuf,
25}
26
27impl InstalledPython {
28    /// Get the path to the Python executable
29    pub fn executable(&self) -> PathBuf {
30        #[cfg(unix)]
31        {
32            self.path.join("bin").join("python3")
33        }
34        #[cfg(windows)]
35        {
36            self.path.join("python.exe")
37        }
38    }
39
40    /// Check if this installation is valid (executable exists)
41    pub fn is_valid(&self) -> bool {
42        self.executable().exists()
43    }
44}
45
46/// Python version manager
47///
48/// Manages Python installations in ~/.local/share/rx/python/
49pub struct PythonManager {
50    /// Base directory for Python installations
51    install_dir: PathBuf,
52    /// Config directory for global settings
53    config_dir: PathBuf,
54    /// Detected platform
55    platform: Platform,
56}
57
58impl PythonManager {
59    /// Create a new Python manager with default directories
60    pub fn new() -> Result<Self> {
61        let data_dir = dirs::data_local_dir()
62            .ok_or_else(|| Error::Config("cannot determine data directory".into()))?;
63        let config_dir = dirs::config_dir()
64            .ok_or_else(|| Error::Config("cannot determine config directory".into()))?;
65
66        Ok(Self {
67            install_dir: data_dir.join("rx").join("python"),
68            config_dir: config_dir.join("rx"),
69            platform: Platform::current()?,
70        })
71    }
72
73    /// Create a manager with custom directories (for testing)
74    pub fn with_dirs(install_dir: PathBuf, config_dir: PathBuf) -> Result<Self> {
75        Ok(Self {
76            install_dir,
77            config_dir,
78            platform: Platform::current()?,
79        })
80    }
81
82    /// Get the installation directory
83    pub fn install_dir(&self) -> &Path {
84        &self.install_dir
85    }
86
87    /// Get the config directory
88    pub fn config_dir(&self) -> &Path {
89        &self.config_dir
90    }
91
92    /// Install a Python version
93    ///
94    /// If a short version like "3.12" is provided, installs the latest patch version.
95    pub async fn install(&self, version_spec: &str) -> Result<InstalledPython> {
96        let spec = PythonVersion::parse(version_spec)?;
97
98        // Find the matching available version
99        let available =
100            find_matching_version(&spec).ok_or_else(|| Error::PythonVersionNotFound {
101                version: version_spec.to_string(),
102            })?;
103
104        let install_path = self.install_dir.join(available.version.to_string_full());
105
106        // Check if already installed
107        if install_path.exists() {
108            return Err(Error::PythonAlreadyInstalled {
109                version: available.version.to_string_full(),
110            });
111        }
112
113        tracing::info!("Installing Python {}...", available.version);
114
115        // Download the archive
116        let archive_data = self.download(&available).await?;
117
118        // Extract to installation directory
119        self.extract(&archive_data, &install_path)?;
120
121        tracing::info!(
122            "Python {} installed to {}",
123            available.version,
124            install_path.display()
125        );
126
127        Ok(InstalledPython {
128            version: available.version.clone(),
129            path: install_path,
130        })
131    }
132
133    /// Uninstall a Python version
134    pub fn uninstall(&self, version_spec: &str) -> Result<()> {
135        let spec = PythonVersion::parse(version_spec)?;
136
137        // Find the installed version matching the spec
138        let installed = self
139            .list_installed()?
140            .into_iter()
141            .find(|i| i.version.matches(&spec))
142            .ok_or_else(|| Error::PythonVersionNotFound {
143                version: version_spec.to_string(),
144            })?;
145
146        tracing::info!("Uninstalling Python {}...", installed.version);
147
148        // Remove the directory
149        fs::remove_dir_all(&installed.path).map_err(Error::Io)?;
150
151        tracing::info!("Python {} uninstalled", installed.version);
152
153        Ok(())
154    }
155
156    /// List installed Python versions
157    pub fn list_installed(&self) -> Result<Vec<InstalledPython>> {
158        let mut installed = Vec::new();
159
160        if !self.install_dir.exists() {
161            return Ok(installed);
162        }
163
164        for entry in fs::read_dir(&self.install_dir).map_err(Error::Io)? {
165            let entry = entry.map_err(Error::Io)?;
166            let path = entry.path();
167
168            if path.is_dir() {
169                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
170                    if let Ok(version) = PythonVersion::parse(name) {
171                        let python = InstalledPython {
172                            version,
173                            path: path.clone(),
174                        };
175                        if python.is_valid() {
176                            installed.push(python);
177                        }
178                    }
179                }
180            }
181        }
182
183        // Sort by version descending
184        installed.sort_by(|a, b| b.version.cmp(&a.version));
185
186        Ok(installed)
187    }
188
189    /// Get a specific installed version
190    pub fn get(&self, version_spec: &str) -> Result<Option<InstalledPython>> {
191        let spec = PythonVersion::parse(version_spec)?;
192
193        Ok(self
194            .list_installed()?
195            .into_iter()
196            .find(|i| i.version.matches(&spec)))
197    }
198
199    /// Find an installed version matching a specification
200    ///
201    /// Returns the highest patch version that matches.
202    pub fn find_matching(&self, version_spec: &str) -> Result<Option<InstalledPython>> {
203        let spec = PythonVersion::parse(version_spec)?;
204
205        let mut matches: Vec<_> = self
206            .list_installed()?
207            .into_iter()
208            .filter(|i| i.version.matches(&spec))
209            .collect();
210
211        matches.sort_by(|a, b| b.version.cmp(&a.version));
212
213        Ok(matches.into_iter().next())
214    }
215
216    /// Pin a Python version for a project by creating .python-version file
217    pub fn pin(&self, version_spec: &str, project_dir: &Path) -> Result<()> {
218        let spec = PythonVersion::parse(version_spec)?;
219        let version_str = spec.to_string_short();
220
221        let version_file = project_dir.join(".python-version");
222        let mut file = fs::File::create(&version_file).map_err(Error::Io)?;
223        writeln!(file, "{}", version_str).map_err(Error::Io)?;
224
225        tracing::info!(
226            "Pinned Python {} in {}",
227            version_str,
228            version_file.display()
229        );
230
231        Ok(())
232    }
233
234    /// Read the pinned version from .python-version file
235    pub fn read_pin(&self, project_dir: &Path) -> Result<Option<PythonVersion>> {
236        let version_file = project_dir.join(".python-version");
237
238        if !version_file.exists() {
239            return Ok(None);
240        }
241
242        let content = fs::read_to_string(&version_file).map_err(Error::Io)?;
243        let version_str = content.trim();
244
245        if version_str.is_empty() {
246            return Ok(None);
247        }
248
249        Ok(Some(PythonVersion::parse(version_str)?))
250    }
251
252    /// Set the global default Python version
253    pub fn set_global(&self, version_spec: &str) -> Result<()> {
254        let spec = PythonVersion::parse(version_spec)?;
255        let version_str = spec.to_string_short();
256
257        // Create config directory if needed
258        fs::create_dir_all(&self.config_dir).map_err(Error::Io)?;
259
260        let config_file = self.config_dir.join("config.toml");
261
262        // Read existing config or create new
263        let mut config: toml::Table = if config_file.exists() {
264            let content = fs::read_to_string(&config_file).map_err(Error::Io)?;
265            toml::from_str(&content).unwrap_or_default()
266        } else {
267            toml::Table::new()
268        };
269
270        // Set the default Python version
271        config.insert(
272            "default_python".to_string(),
273            toml::Value::String(version_str.clone()),
274        );
275
276        // Write back
277        let content = toml::to_string_pretty(&config)
278            .map_err(|e| Error::Config(format!("failed to serialize config: {}", e)))?;
279        fs::write(&config_file, content).map_err(Error::Io)?;
280
281        tracing::info!("Set global Python to {}", version_str);
282
283        Ok(())
284    }
285
286    /// Get the global default Python version
287    pub fn get_global(&self) -> Result<Option<PythonVersion>> {
288        let config_file = self.config_dir.join("config.toml");
289
290        if !config_file.exists() {
291            return Ok(None);
292        }
293
294        let content = fs::read_to_string(&config_file).map_err(Error::Io)?;
295        let config: toml::Table = toml::from_str(&content).map_err(Error::TomlParse)?;
296
297        if let Some(version) = config.get("default_python").and_then(|v| v.as_str()) {
298            Ok(Some(PythonVersion::parse(version)?))
299        } else {
300            Ok(None)
301        }
302    }
303
304    /// Get a list of all available versions (not installed)
305    pub fn list_available(&self) -> Vec<AvailableVersion> {
306        available_versions()
307    }
308
309    /// Download a Python release archive
310    async fn download(&self, version: &AvailableVersion) -> Result<Vec<u8>> {
311        let url = self.build_download_url(version);
312
313        tracing::info!("Downloading from {}", url);
314
315        let response = reqwest::get(&url)
316            .await
317            .map_err(|e| Error::DownloadFailed(format!("request failed: {}", e)))?;
318
319        if !response.status().is_success() {
320            return Err(Error::DownloadFailed(format!(
321                "HTTP {}: {}",
322                response.status(),
323                url
324            )));
325        }
326
327        let bytes = response
328            .bytes()
329            .await
330            .map_err(|e| Error::DownloadFailed(format!("failed to read response: {}", e)))?;
331
332        Ok(bytes.to_vec())
333    }
334
335    /// Build the download URL for a Python version
336    fn build_download_url(&self, version: &AvailableVersion) -> String {
337        // Example URL:
338        // https://github.com/astral-sh/python-build-standalone/releases/download/
339        //   20240814/cpython-3.12.5+20240814-aarch64-apple-darwin-pgo+lto-full.tar.zst
340
341        let triple = self.platform.triple();
342        let ext = self.platform.archive_ext();
343        let opt = if self.platform.supports_optimized() {
344            "pgo+lto"
345        } else {
346            "pgo"
347        };
348
349        format!(
350            "{}/{}/cpython-{}+{}-{}-{}-full.{}",
351            PBS_RELEASE_URL,
352            version.release_tag,
353            version.version.to_string_full(),
354            version.release_tag,
355            triple,
356            opt,
357            ext
358        )
359    }
360
361    /// Extract the downloaded archive to the installation directory
362    fn extract(&self, archive_data: &[u8], install_path: &Path) -> Result<()> {
363        // Create parent directory
364        if let Some(parent) = install_path.parent() {
365            fs::create_dir_all(parent).map_err(Error::Io)?;
366        }
367
368        match self.platform.archive_ext() {
369            "tar.zst" => self.extract_tar_zst(archive_data, install_path),
370            "zip" => self.extract_zip(archive_data, install_path),
371            ext => Err(Error::ExtractionFailed(format!(
372                "unsupported archive format: {}",
373                ext
374            ))),
375        }
376    }
377
378    /// Extract a tar.zst archive
379    fn extract_tar_zst(&self, archive_data: &[u8], install_path: &Path) -> Result<()> {
380        use std::io::Cursor;
381
382        // Decompress zstd
383        let cursor = Cursor::new(archive_data);
384        let decoder = zstd::Decoder::new(cursor)
385            .map_err(|e| Error::ExtractionFailed(format!("zstd decode error: {}", e)))?;
386
387        // Extract tar
388        let mut archive = tar::Archive::new(decoder);
389
390        // Create a temp directory for extraction
391        let temp_dir = install_path.with_extension("tmp");
392        fs::create_dir_all(&temp_dir).map_err(Error::Io)?;
393
394        archive
395            .unpack(&temp_dir)
396            .map_err(|e| Error::ExtractionFailed(format!("tar extraction failed: {}", e)))?;
397
398        // The archive contains python/install/* - move the install directory
399        let extracted = temp_dir.join("python").join("install");
400        if extracted.exists() {
401            fs::rename(&extracted, install_path).map_err(|e| {
402                Error::ExtractionFailed(format!("failed to move extracted files: {}", e))
403            })?;
404        } else {
405            // Fallback: maybe the structure is different, try to find the python directory
406            for entry in fs::read_dir(&temp_dir).map_err(Error::Io)? {
407                let entry = entry.map_err(Error::Io)?;
408                let path = entry.path();
409                if path.is_dir() {
410                    // Check if this looks like a Python installation
411                    let bin = path.join("bin").join("python3");
412                    let install_subdir = path.join("install");
413                    if bin.exists() {
414                        fs::rename(&path, install_path).map_err(|e| {
415                            Error::ExtractionFailed(format!(
416                                "failed to move extracted files: {}",
417                                e
418                            ))
419                        })?;
420                        break;
421                    } else if install_subdir.exists() {
422                        fs::rename(&install_subdir, install_path).map_err(|e| {
423                            Error::ExtractionFailed(format!(
424                                "failed to move extracted files: {}",
425                                e
426                            ))
427                        })?;
428                        break;
429                    }
430                }
431            }
432        }
433
434        // Clean up temp directory
435        let _ = fs::remove_dir_all(&temp_dir);
436
437        Ok(())
438    }
439
440    /// Extract a zip archive (Windows)
441    fn extract_zip(&self, archive_data: &[u8], install_path: &Path) -> Result<()> {
442        use std::io::Cursor;
443
444        let cursor = Cursor::new(archive_data);
445        let mut archive = zip::ZipArchive::new(cursor)
446            .map_err(|e| Error::ExtractionFailed(format!("zip open error: {}", e)))?;
447
448        // Create a temp directory for extraction
449        let temp_dir = install_path.with_extension("tmp");
450        fs::create_dir_all(&temp_dir).map_err(Error::Io)?;
451
452        archive
453            .extract(&temp_dir)
454            .map_err(|e| Error::ExtractionFailed(format!("zip extraction failed: {}", e)))?;
455
456        // Find and move the Python installation directory
457        let extracted = temp_dir.join("python").join("install");
458        if extracted.exists() {
459            fs::rename(&extracted, install_path).map_err(|e| {
460                Error::ExtractionFailed(format!("failed to move extracted files: {}", e))
461            })?;
462        } else {
463            // Try alternative structures
464            for entry in fs::read_dir(&temp_dir).map_err(Error::Io)? {
465                let entry = entry.map_err(Error::Io)?;
466                let path = entry.path();
467                if path.is_dir() {
468                    let python_exe = path.join("python.exe");
469                    let install_subdir = path.join("install");
470                    if python_exe.exists() {
471                        fs::rename(&path, install_path).map_err(|e| {
472                            Error::ExtractionFailed(format!(
473                                "failed to move extracted files: {}",
474                                e
475                            ))
476                        })?;
477                        break;
478                    } else if install_subdir.exists() {
479                        fs::rename(&install_subdir, install_path).map_err(|e| {
480                            Error::ExtractionFailed(format!(
481                                "failed to move extracted files: {}",
482                                e
483                            ))
484                        })?;
485                        break;
486                    }
487                }
488            }
489        }
490
491        // Clean up
492        let _ = fs::remove_dir_all(&temp_dir);
493
494        Ok(())
495    }
496
497    /// Resolve the Python executable to use for a project
498    ///
499    /// Checks (in order):
500    /// 1. .python-version in project
501    /// 2. Global config
502    /// 3. System Python
503    pub fn resolve_python(&self, project_dir: &Path) -> Result<PathBuf> {
504        // Check project pin
505        if let Some(version) = self.read_pin(project_dir)? {
506            if let Some(installed) = self.find_matching(&version.to_string_full())? {
507                return Ok(installed.executable());
508            }
509        }
510
511        // Check global config
512        if let Some(version) = self.get_global()? {
513            if let Some(installed) = self.find_matching(&version.to_string_full())? {
514                return Ok(installed.executable());
515            }
516        }
517
518        // Fall back to system Python
519        self.find_system_python()
520    }
521
522    /// Find system Python
523    fn find_system_python(&self) -> Result<PathBuf> {
524        let candidates = ["python3", "python"];
525
526        for candidate in candidates {
527            let output = Command::new("which").arg(candidate).output();
528
529            if let Ok(output) = output {
530                if output.status.success() {
531                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
532                    if !path.is_empty() {
533                        return Ok(PathBuf::from(path));
534                    }
535                }
536            }
537
538            // Try running directly
539            if let Ok(output) = Command::new(candidate).arg("--version").output() {
540                if output.status.success() {
541                    return Ok(PathBuf::from(candidate));
542                }
543            }
544        }
545
546        Err(Error::VenvError("could not find Python interpreter".into()))
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use tempfile::tempdir;
554
555    #[test]
556    fn test_build_download_url() {
557        // This test may need adjustment based on current platform
558        if let Ok(manager) = PythonManager::new() {
559            let version = AvailableVersion::new(PythonVersion::new(3, 12, Some(5)), "20240814");
560            let url = manager.build_download_url(&version);
561            assert!(url.contains("cpython-3.12.5"));
562            assert!(url.contains("20240814"));
563        }
564    }
565
566    #[test]
567    fn test_pin_and_read() {
568        let temp_dir = tempdir().unwrap();
569        let manager = PythonManager::with_dirs(
570            temp_dir.path().join("python"),
571            temp_dir.path().join("config"),
572        )
573        .unwrap();
574
575        // Pin a version
576        manager.pin("3.12", temp_dir.path()).unwrap();
577
578        // Read it back
579        let pinned = manager.read_pin(temp_dir.path()).unwrap();
580        assert!(pinned.is_some());
581        let version = pinned.unwrap();
582        assert_eq!(version.major, 3);
583        assert_eq!(version.minor, 12);
584    }
585
586    #[test]
587    fn test_global_config() {
588        let temp_dir = tempdir().unwrap();
589        let manager = PythonManager::with_dirs(
590            temp_dir.path().join("python"),
591            temp_dir.path().join("config"),
592        )
593        .unwrap();
594
595        // Set global
596        manager.set_global("3.11").unwrap();
597
598        // Read it back
599        let global = manager.get_global().unwrap();
600        assert!(global.is_some());
601        let version = global.unwrap();
602        assert_eq!(version.major, 3);
603        assert_eq!(version.minor, 11);
604    }
605
606    #[test]
607    fn test_list_available() {
608        if let Ok(manager) = PythonManager::new() {
609            let available = manager.list_available();
610            assert!(!available.is_empty());
611
612            // Should have Python 3.12
613            assert!(available.iter().any(|v| v.version.minor == 12));
614        }
615    }
616}