diff --git a/Cargo.lock b/Cargo.lock index 3ff7ad6d006aa..ee107c008d7be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5627,7 +5627,6 @@ dependencies = [ "indoc", "insta", "itertools 0.14.0", - "once_cell", "owo-colors", "procfs", "ref-cast", diff --git a/Cargo.toml b/Cargo.toml index 2c32ce8d00161..136738a8615b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -128,7 +128,6 @@ memchr = { version = "2.7.4" } miette = { version = "7.2.0", features = ["fancy-no-backtrace"] } nanoid = { version = "0.4.0" } nix = { version = "0.30.0", features = ["signal"] } -once_cell = { version = "1.20.2" } owo-colors = { version = "4.1.0" } path-slash = { version = "0.2.1" } pathdiff = { version = "0.2.1" } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index a846aec59643a..9c6023e92837f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4920,7 +4920,7 @@ pub struct PythonListArgs { /// URL pointing to JSON of custom Python installations. /// - /// Note that currently, only local paths are supported. + /// This can be a local path or file://, http://, or https:// URL. #[arg(long, env = EnvVars::UV_PYTHON_DOWNLOADS_JSON_URL)] pub python_downloads_json_url: Option, } @@ -5010,7 +5010,7 @@ pub struct PythonInstallArgs { /// URL pointing to JSON of custom Python installations. /// - /// Note that currently, only local paths are supported. + /// This can be a local path or file://, http://, or https:// URL. #[arg(long, env = EnvVars::UV_PYTHON_DOWNLOADS_JSON_URL)] pub python_downloads_json_url: Option, @@ -5083,7 +5083,7 @@ pub struct PythonUpgradeArgs { /// URL pointing to JSON of custom Python installations. /// - /// Note that currently, only local paths are supported. + /// This can be a local path or file://, http://, or https:// URL. #[arg(long, env = EnvVars::UV_PYTHON_DOWNLOADS_JSON_URL)] pub python_downloads_json_url: Option, } diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index d008b2d4e942a..a57173b925aa0 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -66,7 +66,6 @@ tokio-util = { workspace = true, features = ["compat"] } tracing = { workspace = true } url = { workspace = true } which = { workspace = true } -once_cell = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] procfs = { workspace = true } diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index ad516d096e92b..5d675462d4d73 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -10,12 +10,11 @@ use std::{env, io}; use futures::TryStreamExt; use itertools::Itertools; -use once_cell::sync::OnceCell; use owo_colors::OwoColorize; use reqwest_retry::{RetryError, RetryPolicy}; use serde::Deserialize; use thiserror::Error; -use tokio::io::{AsyncRead, AsyncWriteExt, BufWriter, ReadBuf}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufWriter, ReadBuf}; use tokio_util::compat::FuturesAsyncReadCompatExt; use tokio_util::either::Either; use tracing::{debug, instrument}; @@ -99,10 +98,10 @@ pub enum Error { Mirror(&'static str, &'static str), #[error("Failed to determine the libc used on the current platform")] LibcDetection(#[from] LibcDetectionError), - #[error("Remote Python downloads JSON is not yet supported, please use a local path")] - RemoteJSONNotSupported, - #[error("The JSON of the python downloads is invalid: {0}")] - InvalidPythonDownloadsJSON(PathBuf, #[source] serde_json::Error), + #[error("Unable to parse the JSON Python download list at {0}")] + InvalidPythonDownloadsJSON(String, #[source] serde_json::Error), + #[error("This version of uv is too old to support the JSON Python download list at {0}")] + UnsupportedPythonDownloadsJSON(String), #[error("An offline Python installation was requested, but {file} (from {url}) is missing in {}", python_builds_dir.user_display())] OfflinePythonMissing { file: Box, @@ -368,15 +367,6 @@ impl PythonDownloadRequest { self.libc.as_ref() } - /// Iterate over all [`PythonDownload`]'s that match this request. - pub fn iter_downloads<'a>( - &'a self, - python_downloads_json_url: Option<&'a str>, - ) -> Result + use<'a>, Error> { - Ok(ManagedPythonDownload::iter_all(python_downloads_json_url)? - .filter(move |download| self.satisfied_by_download(download))) - } - /// Whether this request is satisfied by an installation key. pub fn satisfied_by_key(&self, key: &PythonInstallationKey) -> bool { if let Some(os) = &self.os { @@ -594,9 +584,11 @@ impl FromStr for PythonDownloadRequest { } } -const BUILTIN_PYTHON_DOWNLOADS_JSON: &str = include_str!("download-metadata-minified.json"); -static PYTHON_DOWNLOADS: OnceCell> = - OnceCell::new(); +const BUILTIN_PYTHON_DOWNLOADS_JSON: &[u8] = include_bytes!("download-metadata-minified.json"); + +pub struct ManagedPythonDownloadList { + downloads: Vec, +} #[derive(Debug, Deserialize, Clone)] struct JsonPythonDownload { @@ -625,24 +617,33 @@ pub enum DownloadResult { Fetched(PathBuf), } -impl ManagedPythonDownload { +impl ManagedPythonDownloadList { + /// Iterate over all [`ManagedPythonDownload`]s. + fn iter_all(&self) -> impl Iterator { + self.downloads.iter() + } + + /// Iterate over all [`ManagedPythonDownload`]s that match the request. + pub fn iter_matching( + &self, + request: &PythonDownloadRequest, + ) -> impl Iterator { + self.iter_all() + .filter(move |download| request.satisfied_by_download(download)) + } + /// Return the first [`ManagedPythonDownload`] matching a request, if any. /// /// If there is no stable version matching the request, a compatible pre-release version will /// be searched for — even if a pre-release was not explicitly requested. - pub fn from_request( - request: &PythonDownloadRequest, - python_downloads_json_url: Option<&str>, - ) -> Result<&'static ManagedPythonDownload, Error> { - if let Some(download) = request.iter_downloads(python_downloads_json_url)?.next() { + pub fn find(&self, request: &PythonDownloadRequest) -> Result<&ManagedPythonDownload, Error> { + if let Some(download) = self.iter_matching(request).next() { return Ok(download); } if !request.allows_prereleases() { - if let Some(download) = request - .clone() - .with_prereleases(true) - .iter_downloads(python_downloads_json_url)? + if let Some(download) = self + .iter_matching(&request.clone().with_prereleases(true)) .next() { return Ok(download); @@ -651,49 +652,88 @@ impl ManagedPythonDownload { Err(Error::NoDownloadFound(request.clone())) } - //noinspection RsUnresolvedPath - RustRover can't see through the `include!` - /// Iterate over all [`ManagedPythonDownload`]s. + /// Load available Python distributions from a provided source or the compiled-in list. + /// + /// `python_downloads_json_url` can be either `None`, to use the default list (taken from + /// `crates/uv-python/download-metadata.json`), or `Some` local path + /// or file://, http://, or https:// URL. /// - /// Note: The list is generated on the first call to this function. - /// so `python_downloads_json_url` is only used in the first call to this function. - pub fn iter_all( + /// Returns an error if the provided list could not be opened, if the JSON is invalid, or if it + /// does not parse into the expected data structure. + pub async fn new( + client: &BaseClient, python_downloads_json_url: Option<&str>, - ) -> Result, Error> { - let downloads = PYTHON_DOWNLOADS.get_or_try_init(|| { - let json_downloads: HashMap = if let Some(json_source) = - python_downloads_json_url - { - // Windows paths are also valid URLs - let json_source = if let Ok(url) = Url::parse(json_source) { - if let Ok(path) = url.to_file_path() { - Cow::Owned(path) - } else if matches!(url.scheme(), "http" | "https") { - return Err(Error::RemoteJSONNotSupported); - } else { - Cow::Borrowed(Path::new(json_source)) - } - } else { - Cow::Borrowed(Path::new(json_source)) - }; - - let file = fs_err::File::open(json_source.as_ref())?; + ) -> Result { + // Although read_url() handles file:// URLs and converts them to local file reads, here we + // want to also support parsing bare filenames like "/tmp/py.json", not just + // "file:///tmp/py.json". Note that "C:\Temp\py.json" should be considered a filename, even + // though Url::parse would successfully misparse it as a URL with scheme "C". + enum Source<'a> { + BuiltIn, + Path(Cow<'a, Path>), + Http(Url), + } - serde_json::from_reader(file) - .map_err(|e| Error::InvalidPythonDownloadsJSON(json_source.to_path_buf(), e))? + let json_source = if let Some(url_or_path) = python_downloads_json_url { + if let Ok(url) = Url::parse(url_or_path) { + match url.scheme() { + "http" | "https" => Source::Http(url), + "file" => Source::Path(Cow::Owned( + url.to_file_path().or(Err(Error::InvalidUrlFormat(url)))?, + )), + _ => Source::Path(Cow::Borrowed(Path::new(url_or_path))), + } } else { - serde_json::from_str(BUILTIN_PYTHON_DOWNLOADS_JSON).map_err(|e| { - Error::InvalidPythonDownloadsJSON(PathBuf::from("EMBEDDED IN THE BINARY"), e) - })? - }; + Source::Path(Cow::Borrowed(Path::new(url_or_path))) + } + } else { + Source::BuiltIn + }; - let result = parse_json_downloads(json_downloads); - Ok(Cow::Owned(result)) - })?; + let buf: Cow<'_, [u8]> = match json_source { + Source::BuiltIn => BUILTIN_PYTHON_DOWNLOADS_JSON.into(), + Source::Path(ref path) => fs_err::read(path.as_ref())?.into(), + Source::Http(ref url) => { + let (mut reader, size) = read_url(url, client).await?; + let capacity = size.and_then(|s| s.try_into().ok()).unwrap_or(1_048_576); + let mut buf = Vec::with_capacity(capacity); + reader.read_to_end(&mut buf).await?; + buf.into() + } + }; + let json_downloads: HashMap = serde_json::from_slice(&buf) + .map_err( + // As an explicit compatibility mechanism, if there's a top-level "version" key, it + // means it's a newer format than we know how to deal with. Before reporting a + // parse error about the format of JsonPythonDownload, check for that key. We can do + // this by parsing into a Map which allows any valid JSON on the + // value side. (Because it's zero-sized, Clippy suggests Set, but that won't + // have the same parsing effect.) + #[allow(clippy::zero_sized_map_values)] + |e| { + let source = match json_source { + Source::BuiltIn => "EMBEDDED IN THE BINARY".to_owned(), + Source::Path(path) => path.to_string_lossy().to_string(), + Source::Http(url) => url.to_string(), + }; + if let Ok(keys) = + serde_json::from_slice::>(&buf) + && keys.contains_key("version") + { + Error::UnsupportedPythonDownloadsJSON(source) + } else { + Error::InvalidPythonDownloadsJSON(source, e) + } + }, + )?; - Ok(downloads.iter()) + let result = parse_json_downloads(json_downloads); + Ok(Self { downloads: result }) } +} +impl ManagedPythonDownload { pub fn url(&self) -> &'static str { self.url } diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index a5dbb55f23625..1f13d19c48a80 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -7,14 +7,17 @@ use ref_cast::RefCast; use tracing::{debug, info}; use uv_cache::Cache; -use uv_client::BaseClientBuilder; +use uv_client::{BaseClient, BaseClientBuilder}; use uv_configuration::PreviewMode; use uv_pep440::{Prerelease, Version}; use crate::discovery::{ EnvironmentPreference, PythonRequest, find_best_python_installation, find_python_installation, }; -use crate::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest, Reporter}; +use crate::downloads::{ + DownloadResult, ManagedPythonDownload, ManagedPythonDownloadList, PythonDownloadRequest, + Reporter, +}; use crate::implementation::LenientImplementationName; use crate::managed::{ManagedPythonInstallation, ManagedPythonInstallations}; use crate::platform::{Arch, Libc, Os}; @@ -125,9 +128,13 @@ impl PythonInstallation { && python_downloads.is_automatic() && client_builder.connectivity.is_online(); - let download = download_request.clone().fill().map(|request| { - ManagedPythonDownload::from_request(&request, python_downloads_json_url) - }); + let client = client_builder.build(); + let download_list = + ManagedPythonDownloadList::new(&client, python_downloads_json_url).await?; + let download = download_request + .clone() + .fill() + .map(|request| download_list.find(&request)); // Regardless of whether downloads are enabled, we want to determine if the download is // available to power error messages. However, if downloads aren't enabled, we don't want to @@ -202,7 +209,7 @@ impl PythonInstallation { Self::fetch( download, - client_builder, + &client, cache, reporter, python_install_mirror, @@ -214,8 +221,8 @@ impl PythonInstallation { /// Download and install the requested installation. pub async fn fetch( - download: &'static ManagedPythonDownload, - client_builder: &BaseClientBuilder<'_>, + download: &ManagedPythonDownload, + client: &BaseClient, cache: &Cache, reporter: Option<&dyn Reporter>, python_install_mirror: Option<&str>, @@ -227,12 +234,10 @@ impl PythonInstallation { let scratch_dir = installations.scratch(); let _lock = installations.lock().await?; - let client = client_builder.build(); - info!("Fetching requested Python..."); let result = download .fetch_with_retry( - &client, + client, installations_dir, &scratch_dir, false, diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index d80ccce2f77a2..ee4675a48bc64 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -830,7 +830,7 @@ pub struct PythonInstallMirrors { /// URL pointing to JSON of custom Python installations. /// - /// Note that currently, only local paths are supported. + /// This can be a local path or file://, http://, or https:// URL. #[option( default = "None", value_type = "str", diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index ae981cac375a5..4f69272b65acb 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -268,10 +268,11 @@ impl EnvVars { /// Managed Python installations information is hardcoded in the `uv` binary. /// - /// This variable can be set to a URL pointing to JSON to use as a list for Python installations. - /// This will allow for setting each property of the Python installation, mostly the url part for offline mirror. + /// This variable can be set to a local path or file://, http://, or / https:// URL pointing to + /// a JSON list of Python installations to override the hardcoded list. /// - /// Note that currently, only local paths are supported. + /// This allows customizing the URLs for downloads or using slightly older or newer versions + /// of Python than the ones hardcoded into this build of `uv`. pub const UV_PYTHON_DOWNLOADS_JSON_URL: &'static str = "UV_PYTHON_DOWNLOADS_JSON_URL"; /// Specifies the directory for caching the archives of managed Python installations before diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index feb0cf7c78260..0d2231dc2f63d 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -17,7 +17,8 @@ use tracing::{debug, trace}; use uv_configuration::PreviewMode; use uv_fs::Simplified; use uv_python::downloads::{ - self, ArchRequest, DownloadResult, ManagedPythonDownload, PythonDownloadRequest, + self, ArchRequest, DownloadResult, ManagedPythonDownload, ManagedPythonDownloadList, + PythonDownloadRequest, }; use uv_python::managed::{ ManagedPythonInstallation, ManagedPythonInstallations, PythonMinorVersionLink, @@ -39,17 +40,17 @@ use crate::printer::Printer; use crate::settings::NetworkSettings; #[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct InstallRequest { +struct InstallRequest<'a> { /// The original request from the user request: PythonRequest, /// A download request corresponding to the `request` with platform information filled download_request: PythonDownloadRequest, /// A download that satisfies the request - download: &'static ManagedPythonDownload, + download: &'a ManagedPythonDownload, } -impl InstallRequest { - fn new(request: PythonRequest, python_downloads_json_url: Option<&str>) -> Result { +impl<'a> InstallRequest<'a> { + fn new(request: PythonRequest, download_list: &'a ManagedPythonDownloadList) -> Result { // Make sure the request is a valid download request and fill platform information let download_request = PythonDownloadRequest::from_request(&request) .ok_or_else(|| { @@ -61,22 +62,20 @@ impl InstallRequest { .fill()?; // Find a matching download - let download = - match ManagedPythonDownload::from_request(&download_request, python_downloads_json_url) + let download = match download_list.find(&download_request) { + Ok(download) => download, + Err(downloads::Error::NoDownloadFound(request)) + if request.libc().is_some_and(Libc::is_musl) + && request + .arch() + .is_some_and(|arch| Arch::is_arm(&arch.inner())) => { - Ok(download) => download, - Err(downloads::Error::NoDownloadFound(request)) - if request.libc().is_some_and(Libc::is_musl) - && request - .arch() - .is_some_and(|arch| Arch::is_arm(&arch.inner())) => - { - return Err(anyhow::anyhow!( - "uv does not yet provide musl Python distributions on aarch64." - )); - } - Err(err) => return Err(err.into()), - }; + return Err(anyhow::anyhow!( + "uv does not yet provide musl Python distributions on aarch64." + )); + } + Err(err) => return Err(err.into()), + }; Ok(Self { request, @@ -94,7 +93,7 @@ impl InstallRequest { } } -impl std::fmt::Display for InstallRequest { +impl std::fmt::Display for InstallRequest<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.request) } @@ -197,16 +196,23 @@ pub(crate) async fn install( // Resolve the requests let mut is_default_install = false; let mut is_unspecified_upgrade = false; + let client = uv_client::BaseClientBuilder::new() + .retries_from_env()? + .connectivity(network_settings.connectivity) + .native_tls(network_settings.native_tls) + .allow_insecure_host(network_settings.allow_insecure_host.clone()) + .build(); + let download_list = + ManagedPythonDownloadList::new(&client, python_downloads_json_url.as_deref()).await?; let requests: Vec<_> = if targets.is_empty() { if upgrade { is_unspecified_upgrade = true; let mut minor_version_requests = IndexSet::::default(); for installation in &existing_installations { let request = VersionRequest::major_minor_request_from_key(installation.key()); - if let Ok(request) = InstallRequest::new( - PythonRequest::Version(request), - python_downloads_json_url.as_deref(), - ) { + if let Ok(request) = + InstallRequest::new(PythonRequest::Version(request), &download_list) + { minor_version_requests.insert(request); } } @@ -231,14 +237,14 @@ pub(crate) async fn install( }] }) .into_iter() - .map(|a| InstallRequest::new(a, python_downloads_json_url.as_deref())) + .map(|request| InstallRequest::new(request, &download_list)) .collect::>>()? } } else { targets .iter() .map(|target| PythonRequest::parse(target.as_str())) - .map(|a| InstallRequest::new(a, python_downloads_json_url.as_deref())) + .map(|request| InstallRequest::new(request, &download_list)) .collect::>>()? }; @@ -303,7 +309,7 @@ pub(crate) async fn install( // Construct an install request matching the existing installation match InstallRequest::new( PythonRequest::Key(installation.into()), - python_downloads_json_url.as_deref(), + &download_list, ) { Ok(request) => { debug!("Will reinstall `{}`", installation.key().green()); @@ -385,12 +391,6 @@ pub(crate) async fn install( .collect::>(); // Download and unpack the Python versions concurrently - let client = uv_client::BaseClientBuilder::new() - .retries_from_env()? - .connectivity(network_settings.connectivity) - .native_tls(network_settings.native_tls) - .allow_insecure_host(network_settings.allow_insecure_host.clone()) - .build(); let reporter = PythonDownloadReporter::new(printer, downloads.len() as u64); let mut tasks = FuturesUnordered::new(); diff --git a/crates/uv/src/commands/python/list.rs b/crates/uv/src/commands/python/list.rs index 17528a11e1e0a..f4ff8258bcd49 100644 --- a/crates/uv/src/commands/python/list.rs +++ b/crates/uv/src/commands/python/list.rs @@ -11,7 +11,7 @@ use owo_colors::OwoColorize; use rustc_hash::FxHashSet; use uv_cache::Cache; use uv_fs::Simplified; -use uv_python::downloads::PythonDownloadRequest; +use uv_python::downloads::{ManagedPythonDownloadList, PythonDownloadRequest}; use uv_python::{ DiscoveryError, EnvironmentPreference, PythonDownloads, PythonInstallation, PythonNotFound, PythonPreference, PythonRequest, PythonSource, find_python_installations, @@ -19,7 +19,7 @@ use uv_python::{ use crate::commands::ExitStatus; use crate::printer::Printer; -use crate::settings::PythonListKinds; +use crate::settings::{NetworkSettings, PythonListKinds}; #[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord)] enum Kind { @@ -61,6 +61,7 @@ pub(crate) async fn list( show_urls: bool, output_format: PythonListFormat, python_downloads_json_url: Option, + network_settings: NetworkSettings, python_preference: PythonPreference, python_downloads: PythonDownloads, cache: &Cache, @@ -104,10 +105,17 @@ pub(crate) async fn list( // Include pre-release versions .map(|request| request.with_prereleases(true)); + let client = uv_client::BaseClientBuilder::new() + .retries_from_env()? + .connectivity(network_settings.connectivity) + .native_tls(network_settings.native_tls) + .allow_insecure_host(network_settings.allow_insecure_host.clone()) + .build(); + let download_list = + ManagedPythonDownloadList::new(&client, python_downloads_json_url.as_deref()).await?; let downloads = download_request .as_ref() - .map(|a| PythonDownloadRequest::iter_downloads(a, python_downloads_json_url.as_deref())) - .transpose()? + .map(|request| download_list.iter_matching(request)) .into_iter() .flatten(); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 384f48ac4a941..bf16f83baf881 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1379,6 +1379,7 @@ async fn run(mut cli: Cli) -> Result { args.show_urls, args.output_format, args.python_downloads_json_url, + globals.network_settings, globals.python_preference, globals.python_downloads, &cache, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 66c46ae0c8d91..881c359c9a349 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2720,7 +2720,7 @@ uv python list [OPTIONS] [REQUEST]

See --directory to change the working directory entirely.

This setting has no effect when used in the uv pip interface.

May also be set with the UV_PROJECT environment variable.

--python-downloads-json-url python-downloads-json-url

URL pointing to JSON of custom Python installations.

-

Note that currently, only local paths are supported.

+

This can be a local path or file://, http://, or https:// URL.

May also be set with the UV_PYTHON_DOWNLOADS_JSON_URL environment variable.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

--show-urls

Show the URLs of available Python downloads.

@@ -2817,7 +2817,7 @@ uv python install [OPTIONS] [TARGETS]...

The provided URL will replace https://downloads.python.org/pypy in, e.g., https://downloads.python.org/pypy/pypy3.8-v7.3.7-osx64.tar.bz2.

Distributions can be read from a local directory by using the file:// URL scheme.

May also be set with the UV_PYPY_INSTALL_MIRROR environment variable.

--python-downloads-json-url python-downloads-json-url

URL pointing to JSON of custom Python installations.

-

Note that currently, only local paths are supported.

+

This can be a local path or file://, http://, or https:// URL.

May also be set with the UV_PYTHON_DOWNLOADS_JSON_URL environment variable.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

--reinstall, -r

Reinstall the requested Python version, if it's already installed.

@@ -2904,7 +2904,7 @@ uv python upgrade [OPTIONS] [TARGETS]...

The provided URL will replace https://downloads.python.org/pypy in, e.g., https://downloads.python.org/pypy/pypy3.8-v7.3.7-osx64.tar.bz2.

Distributions can be read from a local directory by using the file:// URL scheme.

May also be set with the UV_PYPY_INSTALL_MIRROR environment variable.

--python-downloads-json-url python-downloads-json-url

URL pointing to JSON of custom Python installations.

-

Note that currently, only local paths are supported.

+

This can be a local path or file://, http://, or https:// URL.

May also be set with the UV_PYTHON_DOWNLOADS_JSON_URL environment variable.

--quiet, -q

Use quiet output.

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

--verbose, -v

Use verbose output.

diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 47e4d8db919fb..73a212303405f 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -367,10 +367,11 @@ Equivalent to the Managed Python installations information is hardcoded in the `uv` binary. -This variable can be set to a URL pointing to JSON to use as a list for Python installations. -This will allow for setting each property of the Python installation, mostly the url part for offline mirror. +This variable can be set to a local path or file://, http://, or / https:// URL pointing to +a JSON list of Python installations to override the hardcoded list. -Note that currently, only local paths are supported. +This allows customizing the URLs for downloads or using slightly older or newer versions +of Python than the ones hardcoded into this build of `uv`. ### `UV_PYTHON_INSTALL_DIR` diff --git a/docs/reference/settings.md b/docs/reference/settings.md index bdee1e4a1308d..890fa98f5d13a 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -1789,7 +1789,7 @@ Whether to allow Python downloads. URL pointing to JSON of custom Python installations. -Note that currently, only local paths are supported. +This can be a local path or file://, http://, or https:// URL. **Default value**: `None` diff --git a/uv.schema.json b/uv.schema.json index e418f37f0d440..be7b45a94875f 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -457,7 +457,7 @@ ] }, "python-downloads-json-url": { - "description": "URL pointing to JSON of custom Python installations.\n\nNote that currently, only local paths are supported.", + "description": "URL pointing to JSON of custom Python installations.\n\nThis can be a local path or file://, http://, or https:// URL.", "type": [ "string", "null"