Skip to content

Commit 45df889

Browse files
authored
Implement Toolchain::find_or_fetch and use in uv venv --preview (#4138)
Extends #4121 Part of #2607 Adds support for managed toolchain fetching to `uv venv`, e.g. ``` ❯ cargo run -q -- venv --python 3.9.18 --preview -v DEBUG Searching for Python 3.9.18 in search path or managed toolchains DEBUG Searching for managed toolchains at `/Users/zb/Library/Application Support/uv/toolchains` DEBUG Found CPython 3.12.3 at `/opt/homebrew/bin/python3` (search path) DEBUG Found CPython 3.9.6 at `/usr/bin/python3` (search path) DEBUG Found CPython 3.12.3 at `/opt/homebrew/bin/python3` (search path) DEBUG Requested Python not found, checking for available download... DEBUG Using registry request timeout of 30s INFO Fetching requested toolchain... DEBUG Downloading https://github.com/indygreg/python-build-standalone/releases/download/20240224/cpython-3.9.18%2B20240224-aarch64-apple-darwin-pgo%2Blto-full.tar.zst to temporary location /Users/zb/Library/Application Support/uv/toolchains/.tmpgohKwp DEBUG Extracting cpython-3.9.18%2B20240224-aarch64-apple-darwin-pgo%2Blto-full.tar.zst DEBUG Moving /Users/zb/Library/Application Support/uv/toolchains/.tmpgohKwp/python to /Users/zb/Library/Application Support/uv/toolchains/cpython-3.9.18-macos-aarch64-none Using Python 3.9.18 interpreter at: /Users/zb/Library/Application Support/uv/toolchains/cpython-3.9.18-macos-aarch64-none/install/bin/python3 Creating virtualenv at: .venv INFO Removing existing directory Activate with: source .venv/bin/activate ``` The preview flag is required. The fetch is performed if we can't find an interpreter that satisfies the request. Once fetched, the toolchain will be available for later invocations that include the `--preview` flag. There will be follow-ups to improve toolchain management in general, there is still outstanding work from the initial implementation.
1 parent 04c4da4 commit 45df889

File tree

9 files changed

+219
-87
lines changed

9 files changed

+219
-87
lines changed

Cargo.lock

-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-dev/Cargo.toml

-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ anyhow = { workspace = true }
4242
clap = { workspace = true, features = ["derive", "wrap_help"] }
4343
fs-err = { workspace = true, features = ["tokio"] }
4444
futures = { workspace = true }
45-
itertools = { workspace = true }
4645
owo-colors = { workspace = true }
4746
poloto = { version = "19.1.2", optional = true }
4847
pretty_assertions = { version = "1.4.0" }

crates/uv-dev/src/fetch_python.rs

+6-47
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
use anyhow::Result;
22
use clap::Parser;
33
use fs_err as fs;
4-
#[cfg(unix)]
5-
use fs_err::tokio::symlink;
64
use futures::StreamExt;
7-
#[cfg(unix)]
8-
use itertools::Itertools;
9-
use std::str::FromStr;
10-
#[cfg(unix)]
11-
use std::{collections::HashMap, path::PathBuf};
125
use tokio::time::Instant;
136
use tracing::{info, info_span, Instrument};
7+
use uv_toolchain::ToolchainRequest;
148

159
use uv_fs::Simplified;
1610
use uv_toolchain::downloads::{DownloadResult, Error, PythonDownload, PythonDownloadRequest};
@@ -37,17 +31,16 @@ pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> {
3731
let requests = versions
3832
.iter()
3933
.map(|version| {
40-
PythonDownloadRequest::from_str(version).and_then(PythonDownloadRequest::fill)
34+
PythonDownloadRequest::from_request(ToolchainRequest::parse(version))
35+
// Populate platform information on the request
36+
.and_then(PythonDownloadRequest::fill)
4137
})
4238
.collect::<Result<Vec<_>, Error>>()?;
4339

4440
let downloads = requests
4541
.iter()
46-
.map(|request| match PythonDownload::from_request(request) {
47-
Some(download) => download,
48-
None => panic!("No download found for request {request:?}"),
49-
})
50-
.collect::<Vec<_>>();
42+
.map(PythonDownload::from_request)
43+
.collect::<Result<Vec<_>, Error>>()?;
5144

5245
let client = uv_client::BaseClientBuilder::new().build();
5346

@@ -91,40 +84,6 @@ pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> {
9184
info!("All versions downloaded already.");
9285
};
9386

94-
// Order matters here, as we overwrite previous links
95-
info!("Installing to `{}`...", toolchain_dir.user_display());
96-
97-
// On Windows, linking the executable generally results in broken installations
98-
// and each toolchain path will need to be added to the PATH separately in the
99-
// desired order
100-
#[cfg(unix)]
101-
{
102-
let mut links: HashMap<PathBuf, PathBuf> = HashMap::new();
103-
for (version, path) in results {
104-
// TODO(zanieb): This path should be a part of the download metadata
105-
let executable = path.join("install").join("bin").join("python3");
106-
for target in [
107-
toolchain_dir.join(format!("python{}", version.python_full_version())),
108-
toolchain_dir.join(format!("python{}.{}", version.major(), version.minor())),
109-
toolchain_dir.join(format!("python{}", version.major())),
110-
toolchain_dir.join("python"),
111-
] {
112-
// Attempt to remove it, we'll fail on link if we couldn't remove it for some reason
113-
// but if it's missing we don't want to error
114-
let _ = fs::remove_file(&target);
115-
symlink(&executable, &target).await?;
116-
links.insert(target, executable.clone());
117-
}
118-
}
119-
for (target, executable) in links.iter().sorted() {
120-
info!(
121-
"Linked `{}` to `{}`",
122-
target.user_display(),
123-
executable.user_display()
124-
);
125-
}
126-
};
127-
12887
info!("Installed {} versions", requests.len());
12988

13089
Ok(())

crates/uv-toolchain/src/discovery.rs

+11
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,17 @@ impl VersionRequest {
10591059
}
10601060
}
10611061

1062+
pub(crate) fn matches_major_minor_patch(self, major: u8, minor: u8, patch: u8) -> bool {
1063+
match self {
1064+
Self::Any => true,
1065+
Self::Major(self_major) => self_major == major,
1066+
Self::MajorMinor(self_major, self_minor) => (self_major, self_minor) == (major, minor),
1067+
Self::MajorMinorPatch(self_major, self_minor, self_patch) => {
1068+
(self_major, self_minor, self_patch) == (major, minor, patch)
1069+
}
1070+
}
1071+
}
1072+
10621073
/// Return true if a patch version is present in the request.
10631074
fn has_patch(self) -> bool {
10641075
match self {

crates/uv-toolchain/src/downloads.rs

+75-22
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use std::fmt::Display;
22
use std::io;
3+
use std::num::ParseIntError;
34
use std::path::{Path, PathBuf};
45
use std::str::FromStr;
56

67
use crate::implementation::{Error as ImplementationError, ImplementationName};
78
use crate::platform::{Arch, Error as PlatformError, Libc, Os};
8-
use crate::PythonVersion;
9+
use crate::{PythonVersion, ToolchainRequest, VersionRequest};
910
use thiserror::Error;
1011
use uv_client::BetterReqwestError;
1112

@@ -25,13 +26,13 @@ pub enum Error {
2526
#[error(transparent)]
2627
ImplementationError(#[from] ImplementationError),
2728
#[error("Invalid python version: {0}")]
28-
InvalidPythonVersion(String),
29+
InvalidPythonVersion(ParseIntError),
2930
#[error("Download failed")]
3031
NetworkError(#[from] BetterReqwestError),
3132
#[error("Download failed")]
3233
NetworkMiddlewareError(#[source] anyhow::Error),
33-
#[error(transparent)]
34-
ExtractError(#[from] uv_extract::Error),
34+
#[error("Failed to extract archive: {0}")]
35+
ExtractError(String, #[source] uv_extract::Error),
3536
#[error("Invalid download url")]
3637
InvalidUrl(#[from] url::ParseError),
3738
#[error("Failed to create download directory")]
@@ -50,6 +51,11 @@ pub enum Error {
5051
},
5152
#[error("Failed to parse toolchain directory name: {0}")]
5253
NameError(String),
54+
#[error("Cannot download toolchain for request: {0}")]
55+
InvalidRequestKind(ToolchainRequest),
56+
// TODO(zanieb): Implement display for `PythonDownloadRequest`
57+
#[error("No download found for request: {0:?}")]
58+
NoDownloadFound(PythonDownloadRequest),
5359
}
5460

5561
#[derive(Debug, PartialEq)]
@@ -66,9 +72,9 @@ pub struct PythonDownload {
6672
sha256: Option<&'static str>,
6773
}
6874

69-
#[derive(Debug)]
75+
#[derive(Debug, Clone, Default)]
7076
pub struct PythonDownloadRequest {
71-
version: Option<PythonVersion>,
77+
version: Option<VersionRequest>,
7278
implementation: Option<ImplementationName>,
7379
arch: Option<Arch>,
7480
os: Option<Os>,
@@ -77,7 +83,7 @@ pub struct PythonDownloadRequest {
7783

7884
impl PythonDownloadRequest {
7985
pub fn new(
80-
version: Option<PythonVersion>,
86+
version: Option<VersionRequest>,
8187
implementation: Option<ImplementationName>,
8288
arch: Option<Arch>,
8389
os: Option<Os>,
@@ -98,6 +104,12 @@ impl PythonDownloadRequest {
98104
self
99105
}
100106

107+
#[must_use]
108+
pub fn with_version(mut self, version: VersionRequest) -> Self {
109+
self.version = Some(version);
110+
self
111+
}
112+
101113
#[must_use]
102114
pub fn with_arch(mut self, arch: Arch) -> Self {
103115
self.arch = Some(arch);
@@ -116,6 +128,27 @@ impl PythonDownloadRequest {
116128
self
117129
}
118130

131+
pub fn from_request(request: ToolchainRequest) -> Result<Self, Error> {
132+
let result = Self::default();
133+
let result = match request {
134+
ToolchainRequest::Version(version) => result.with_version(version),
135+
ToolchainRequest::Implementation(implementation) => {
136+
result.with_implementation(implementation)
137+
}
138+
ToolchainRequest::ImplementationVersion(implementation, version) => result
139+
.with_implementation(implementation)
140+
.with_version(version),
141+
ToolchainRequest::Any => result,
142+
// We can't download a toolchain for these request kinds
143+
ToolchainRequest::Directory(_)
144+
| ToolchainRequest::ExecutableName(_)
145+
| ToolchainRequest::File(_) => {
146+
return Err(Error::InvalidRequestKind(request));
147+
}
148+
};
149+
Ok(result)
150+
}
151+
119152
pub fn fill(mut self) -> Result<Self, Error> {
120153
if self.implementation.is_none() {
121154
self.implementation = Some(ImplementationName::CPython);
@@ -133,12 +166,34 @@ impl PythonDownloadRequest {
133166
}
134167
}
135168

169+
impl Display for PythonDownloadRequest {
170+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171+
let mut parts = Vec::new();
172+
if let Some(version) = self.version {
173+
parts.push(version.to_string());
174+
}
175+
if let Some(implementation) = self.implementation {
176+
parts.push(implementation.to_string());
177+
}
178+
if let Some(os) = &self.os {
179+
parts.push(os.to_string());
180+
}
181+
if let Some(arch) = self.arch {
182+
parts.push(arch.to_string());
183+
}
184+
if let Some(libc) = self.libc {
185+
parts.push(libc.to_string());
186+
}
187+
write!(f, "{}", parts.join("-"))
188+
}
189+
}
190+
136191
impl FromStr for PythonDownloadRequest {
137192
type Err = Error;
138193

139194
fn from_str(s: &str) -> Result<Self, Self::Err> {
140195
// TODO(zanieb): Implement parsing of additional request parts
141-
let version = PythonVersion::from_str(s).map_err(Error::InvalidPythonVersion)?;
196+
let version = VersionRequest::from_str(s).map_err(Error::InvalidPythonVersion)?;
142197
Ok(Self::new(Some(version), None, None, None, None))
143198
}
144199
}
@@ -156,7 +211,7 @@ impl PythonDownload {
156211
PYTHON_DOWNLOADS.iter().find(|&value| value.key == key)
157212
}
158213

159-
pub fn from_request(request: &PythonDownloadRequest) -> Option<&'static PythonDownload> {
214+
pub fn from_request(request: &PythonDownloadRequest) -> Result<&'static PythonDownload, Error> {
160215
for download in PYTHON_DOWNLOADS {
161216
if let Some(arch) = &request.arch {
162217
if download.arch != *arch {
@@ -174,21 +229,17 @@ impl PythonDownload {
174229
}
175230
}
176231
if let Some(version) = &request.version {
177-
if download.major != version.major() {
178-
continue;
179-
}
180-
if download.minor != version.minor() {
232+
if !version.matches_major_minor_patch(
233+
download.major,
234+
download.minor,
235+
download.patch,
236+
) {
181237
continue;
182238
}
183-
if let Some(patch) = version.patch() {
184-
if download.patch != patch {
185-
continue;
186-
}
187-
}
188239
}
189-
return Some(download);
240+
return Ok(download);
190241
}
191-
None
242+
Err(Error::NoDownloadFound(request.clone()))
192243
}
193244

194245
pub fn url(&self) -> &str {
@@ -232,13 +283,15 @@ impl PythonDownload {
232283
.into_async_read();
233284

234285
debug!("Extracting {filename}");
235-
uv_extract::stream::archive(reader.compat(), filename, temp_dir.path()).await?;
286+
uv_extract::stream::archive(reader.compat(), filename, temp_dir.path())
287+
.await
288+
.map_err(|err| Error::ExtractError(filename.to_string(), err))?;
236289

237290
// Extract the top-level directory.
238291
let extracted = match uv_extract::strip_component(temp_dir.path()) {
239292
Ok(top_level) => top_level,
240293
Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.into_path(),
241-
Err(err) => return Err(err.into()),
294+
Err(err) => return Err(Error::ExtractError(filename.to_string(), err)),
242295
};
243296

244297
// Persist it to the target

crates/uv-toolchain/src/lib.rs

+6
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ pub enum Error {
5656
#[error(transparent)]
5757
PyLauncher(#[from] py_launcher::Error),
5858

59+
#[error(transparent)]
60+
ManagedToolchain(#[from] managed::Error),
61+
62+
#[error(transparent)]
63+
Download(#[from] downloads::Error),
64+
5965
#[error(transparent)]
6066
NotFound(#[from] ToolchainNotFound),
6167
}

0 commit comments

Comments
 (0)