1use 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
16const PBS_RELEASE_URL: &str =
18 "https://github.com/astral-sh/python-build-standalone/releases/download";
19
20#[derive(Debug, Clone)]
22pub struct InstalledPython {
23 pub version: PythonVersion,
24 pub path: PathBuf,
25}
26
27impl InstalledPython {
28 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 pub fn is_valid(&self) -> bool {
42 self.executable().exists()
43 }
44}
45
46pub struct PythonManager {
50 install_dir: PathBuf,
52 config_dir: PathBuf,
54 platform: Platform,
56}
57
58impl PythonManager {
59 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 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 pub fn install_dir(&self) -> &Path {
84 &self.install_dir
85 }
86
87 pub fn config_dir(&self) -> &Path {
89 &self.config_dir
90 }
91
92 pub async fn install(&self, version_spec: &str) -> Result<InstalledPython> {
96 let spec = PythonVersion::parse(version_spec)?;
97
98 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 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 let archive_data = self.download(&available).await?;
117
118 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 pub fn uninstall(&self, version_spec: &str) -> Result<()> {
135 let spec = PythonVersion::parse(version_spec)?;
136
137 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 fs::remove_dir_all(&installed.path).map_err(Error::Io)?;
150
151 tracing::info!("Python {} uninstalled", installed.version);
152
153 Ok(())
154 }
155
156 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 installed.sort_by(|a, b| b.version.cmp(&a.version));
185
186 Ok(installed)
187 }
188
189 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 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 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 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 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 fs::create_dir_all(&self.config_dir).map_err(Error::Io)?;
259
260 let config_file = self.config_dir.join("config.toml");
261
262 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 config.insert(
272 "default_python".to_string(),
273 toml::Value::String(version_str.clone()),
274 );
275
276 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 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 pub fn list_available(&self) -> Vec<AvailableVersion> {
306 available_versions()
307 }
308
309 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 fn build_download_url(&self, version: &AvailableVersion) -> String {
337 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 fn extract(&self, archive_data: &[u8], install_path: &Path) -> Result<()> {
363 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 fn extract_tar_zst(&self, archive_data: &[u8], install_path: &Path) -> Result<()> {
380 use std::io::Cursor;
381
382 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 let mut archive = tar::Archive::new(decoder);
389
390 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 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 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 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 let _ = fs::remove_dir_all(&temp_dir);
436
437 Ok(())
438 }
439
440 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 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 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 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 let _ = fs::remove_dir_all(&temp_dir);
493
494 Ok(())
495 }
496
497 pub fn resolve_python(&self, project_dir: &Path) -> Result<PathBuf> {
504 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 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 self.find_system_python()
520 }
521
522 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 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 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 manager.pin("3.12", temp_dir.path()).unwrap();
577
578 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 manager.set_global("3.11").unwrap();
597
598 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 assert!(available.iter().any(|v| v.version.minor == 12));
614 }
615 }
616}