Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/sync-python-releases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: Sync Python Releases
run: |
uv run -- fetch-download-metadata.py
uv run -- template-download-metadata.py
uv run -- minify-download-metadata.py
working-directory: ./crates/uv-python
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -35,7 +35,7 @@ jobs:
commit-message: "Sync latest Python releases"
add-paths: |
crates/uv-python/download-metadata.json
crates/uv-python/src/downloads.inc
crates/uv-python/src/download-metadata-minified.json
branch: "sync-python-releases"
title: "Sync latest Python releases"
body: "Automated update for Python releases."
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ memchr = { version = "2.7.4" }
miette = { version = "7.2.0", features = ["fancy-no-backtrace"] }
nanoid = { version = "0.4.0" }
nix = { version = "0.29.0" }
once_cell = { version = "1.20.2" }
owo-colors = { version = "4.1.0" }
path-slash = { version = "0.2.1" }
pathdiff = { version = "0.2.1" }
Expand Down
1 change: 1 addition & 0 deletions crates/uv-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ 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 }
Expand Down
43 changes: 43 additions & 0 deletions crates/uv-python/minify-download-metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# /// script
# requires-python = ">=3.12"
# ///
"""
Generate minified Python version download metadata json to embed in the binary.
Generates the `download-metadata-minified.json` file from the `download-metadata.json` file.
Usage:
uv run -- crates/uv-python/minify-download-metadata.py
Comment on lines +5 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooh i like this approach as a compromise!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW we can later improve it by moving this logic to the compile time and then select only the versions that are relevant to the compiled platform

"""

import json
from pathlib import Path

CRATE_ROOT = Path(__file__).parent
VERSION_METADATA = CRATE_ROOT / "download-metadata.json"
TARGET = CRATE_ROOT / "src" / "download-metadata-minified.json"


def process_json(data: dict) -> dict:
out_data = {}

for key, value in data.items():
# Exclude debug variants for now, we don't support them in the Rust side
if value["variant"] == "debug":
continue

out_data[key] = value

return out_data


def main() -> None:
json_data = json.loads(Path(VERSION_METADATA).read_text())
json_data = process_json(json_data)
json_string = json.dumps(json_data, separators=(",", ":"))
TARGET.write_text(json_string)


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions crates/uv-python/src/download-metadata-minified.json

Large diffs are not rendered by default.

19,073 changes: 0 additions & 19,073 deletions crates/uv-python/src/downloads.inc

This file was deleted.

39 changes: 0 additions & 39 deletions crates/uv-python/src/downloads.inc.mustache

This file was deleted.

186 changes: 178 additions & 8 deletions crates/uv-python/src/downloads.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Display;
use std::io;
use std::path::{Path, PathBuf};
Expand All @@ -7,8 +9,11 @@ use std::task::{Context, Poll};
use std::time::{Duration, SystemTime};

use futures::TryStreamExt;
use itertools::Itertools;
use once_cell::sync::OnceCell;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use std::cell::OnceCell instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to, but get_or_try_init is nightly-only

use owo_colors::OwoColorize;
use reqwest_retry::RetryPolicy;
use serde::Deserialize;
use thiserror::Error;
use tokio::io::{AsyncRead, ReadBuf};
use tokio_util::compat::FuturesAsyncReadCompatExt;
Expand All @@ -30,6 +35,7 @@ use crate::installation::PythonInstallationKey;
use crate::libc::LibcDetectionError;
use crate::managed::ManagedPythonInstallation;
use crate::platform::{self, Arch, Libc, Os};
use crate::PythonVariant;
use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest};

#[derive(Error, Debug)]
Expand Down Expand Up @@ -86,9 +92,13 @@ pub enum Error {
Mirror(&'static str, &'static str),
#[error(transparent)]
LibcDetection(#[from] LibcDetectionError),
#[error("Remote python downloads JSON is not yet supported, please use a local path (without `file://` prefix)")]
RemoteJSONNotSupported(),
#[error("The json of the python downloads is invalid: {0}")]
InvalidPythonDownloadsJSON(String, #[source] serde_json::Error),
}

#[derive(Debug, PartialEq)]
#[derive(Debug, PartialEq, Clone)]
pub struct ManagedPythonDownload {
key: PythonInstallationKey,
url: &'static str,
Expand Down Expand Up @@ -245,9 +255,11 @@ impl PythonDownloadRequest {
}

/// Iterate over all [`PythonDownload`]'s that match this request.
pub fn iter_downloads(&self) -> impl Iterator<Item = &'static ManagedPythonDownload> + '_ {
ManagedPythonDownload::iter_all()
.filter(move |download| self.satisfied_by_download(download))
pub fn iter_downloads(
&self,
) -> Result<impl Iterator<Item = &'static ManagedPythonDownload> + use<'_>, Error> {
Ok(ManagedPythonDownload::iter_all()?
.filter(move |download| self.satisfied_by_download(download)))
}

/// Whether this request is satisfied by an installation key.
Expand Down Expand Up @@ -445,7 +457,30 @@ impl FromStr for PythonDownloadRequest {
}
}

include!("downloads.inc");
const BUILTIN_PYTHON_DOWNLOADS_JSON: &str = include_str!("download-metadata-minified.json");
static PYTHON_DOWNLOADS: OnceCell<std::borrow::Cow<'static, [ManagedPythonDownload]>> =
OnceCell::new();

#[derive(Debug, Deserialize, Clone)]
struct JsonPythonDownload {
name: String,
arch: JsonArch,
os: String,
libc: String,
major: u8,
minor: u8,
patch: u8,
Comment on lines +470 to +472
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely these should be more like u64?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we need a u64 for Python version, do we? I feel like we're many years from 3.256.0?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm fair. Still u8 feels needlessly spicey, especially for a temp deserialization format.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PythonInstallationKey uses too u8s though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What failure mode are you worried about? I'm open to changing it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also use u8s for arbitrary Python versions, e.g.,

/// Return the major version of this Python version.
pub fn major(&self) -> u8 {
u8::try_from(self.0.release().first().copied().unwrap_or(0)).expect("invalid major version")
}
/// Return the minor version of this Python version.
pub fn minor(&self) -> u8 {
u8::try_from(self.0.release().get(1).copied().unwrap_or(0)).expect("invalid minor version")
}
/// Return the patch version of this Python version, if set.
pub fn patch(&self) -> Option<u8> {
self.0
.release()
.get(2)
.copied()
.map(|patch| u8::try_from(patch).expect("invalid patch version"))
}

prerelease: Option<String>,
url: String,
sha256: Option<String>,
variant: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
struct JsonArch {
family: String,
variant: Option<String>,
}

#[derive(Debug, Clone)]
pub enum DownloadResult {
Expand All @@ -459,14 +494,40 @@ impl ManagedPythonDownload {
request: &PythonDownloadRequest,
) -> Result<&'static ManagedPythonDownload, Error> {
request
.iter_downloads()
.iter_downloads()?
.next()
.ok_or(Error::NoDownloadFound(request.clone()))
}

/// Iterate over all [`ManagedPythonDownload`]s.
pub fn iter_all() -> impl Iterator<Item = &'static ManagedPythonDownload> {
PYTHON_DOWNLOADS.iter()
pub fn iter_all() -> Result<impl Iterator<Item = &'static ManagedPythonDownload>, Error> {
let runtime_source = std::env::var(EnvVars::UV_PYTHON_DOWNLOADS_JSON_URL);

let downloads = PYTHON_DOWNLOADS.get_or_try_init(|| {
let json_downloads: HashMap<String, JsonPythonDownload> =
if let Ok(json_source) = &runtime_source {
if Url::parse(json_source).is_ok() {
return Err(Error::RemoteJSONNotSupported());
}

let file = match fs_err::File::open(json_source) {
Ok(file) => file,
Err(e) => { Err(Error::Io(e)) }?,
};

serde_json::from_reader(file)
.map_err(|e| Error::InvalidPythonDownloadsJSON(json_source.clone(), e))?
} else {
serde_json::from_str(BUILTIN_PYTHON_DOWNLOADS_JSON).map_err(|e| {
Error::InvalidPythonDownloadsJSON("EMBEDDED IN THE BINARY".to_string(), e)
})?
};

let result = parse_json_downloads(json_downloads);
Ok(Cow::Owned(result))
})?;

Ok(downloads.iter())
}

pub fn url(&self) -> &'static str {
Expand Down Expand Up @@ -702,6 +763,115 @@ impl ManagedPythonDownload {
}
}

fn parse_json_downloads(
json_downloads: HashMap<String, JsonPythonDownload>,
) -> Vec<ManagedPythonDownload> {
json_downloads
.into_iter()
.filter_map(|(key, entry)| {
let implementation = match entry.name.as_str() {
"cpython" => LenientImplementationName::Known(ImplementationName::CPython),
"pypy" => LenientImplementationName::Known(ImplementationName::PyPy),
_ => LenientImplementationName::Unknown(entry.name.clone()),
};
Comment on lines +772 to +776
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, in the old implementation an unknown name was treated as a hard error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to preserve the "hard error", or leave it as it is now?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this was just processing our input I'd be strict to make sure that upstream PBS features get proper support in uv. But when taking user input being lenient is probably fair... maybe this function can take a boolean flag so the two differ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding a Boolean parameter to this function is not so clean (what would it be called?), but I don't have a strong feeling about it, so feel free to modify it as you wish

Also, I'm not sure if it's the right place to enforce it; maybe a unit test will be a better fit (that that check we support all the wanted implementations)


let arch_str = match entry.arch.family.as_str() {
"armv5tel" => "armv5te".to_string(),
// The `gc` variant of riscv64 is the common base instruction set and
// is the target in `python-build-standalone`
// See https://github.com/astral-sh/python-build-standalone/issues/504
"riscv64" => "riscv64gc".to_string(),
value => value.to_string(),
};

let arch_str = if let Some(variant) = entry.arch.variant {
format!("{arch_str}_{variant}")
} else {
arch_str
};

let arch = match Arch::from_str(&arch_str) {
Ok(arch) => arch,
Err(e) => {
debug!("Skipping entry {key}: Invalid arch '{arch_str}' - {e}");
return None;
}
};

let os = match Os::from_str(&entry.os) {
Ok(os) => os,
Err(e) => {
debug!("Skipping entry {}: Invalid OS '{}' - {}", key, entry.os, e);
return None;
}
};

let libc = match Libc::from_str(&entry.libc) {
Ok(libc) => libc,
Err(e) => {
debug!(
"Skipping entry {}: Invalid libc '{}' - {}",
key, entry.libc, e
);
return None;
}
};

let variant = match entry
.variant
.as_deref()
.map(PythonVariant::from_str)
.transpose()
{
Ok(Some(variant)) => variant,
Ok(None) => PythonVariant::default(),
Err(()) => {
debug!(
"Skipping entry {key}: Unknown python variant - {}",
entry.variant.unwrap_or_default()
);
return None;
}
};

let version_str = format!(
"{}.{}.{}{}",
entry.major,
entry.minor,
entry.patch,
entry.prerelease.as_deref().unwrap_or_default()
);

let version = match PythonVersion::from_str(&version_str) {
Ok(version) => version,
Err(e) => {
debug!("Skipping entry {key}: Invalid version '{version_str}' - {e}");
return None;
}
};

let url = Box::leak(entry.url.into_boxed_str()) as &'static str;
let sha256 = entry
.sha256
.map(|s| Box::leak(s.into_boxed_str()) as &'static str);

Some(ManagedPythonDownload {
key: PythonInstallationKey::new_from_version(
implementation,
&version,
os,
arch,
libc,
variant,
),
url,
sha256,
})
})
.sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
.collect()
}

impl Error {
pub(crate) fn from_reqwest(url: Url, err: reqwest::Error) -> Self {
Self::NetworkError(url, WrappedReqwestError::from(err))
Expand Down
4 changes: 2 additions & 2 deletions crates/uv-python/src/installation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ impl PythonInstallationKey {
}
}

fn new_from_version(
pub fn new_from_version(
implementation: LenientImplementationName,
version: &PythonVersion,
os: Os,
Expand Down Expand Up @@ -482,6 +482,6 @@ impl Ord for PythonInstallationKey {
.then_with(|| self.os.to_string().cmp(&other.os.to_string()))
.then_with(|| self.arch.to_string().cmp(&other.arch.to_string()))
.then_with(|| self.libc.to_string().cmp(&other.libc.to_string()))
.then_with(|| self.variant.cmp(&other.variant))
.then_with(|| self.variant.cmp(&other.variant).reverse()) // we want Default to come first
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without this change, some tests fail as we choose the wrong version, I tried to investigate why and because what I saw was that we will get rc versions instead of what we wanted, this was the right change

}
}
Loading
Loading