mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-30 23:37:24 +00:00
Add support for managed installs of free-threaded Python (#8100)
Closes https://github.com/astral-sh/uv/issues/7193 ``` ❯ cargo run -q -- python uninstall 3.13t Searching for Python versions matching: Python 3.13t Uninstalled Python 3.13.0 in 231ms - cpython-3.13.0+freethreaded-macos-aarch64-none ❯ cargo run -q -- python install 3.13t Searching for Python versions matching: Python 3.13t Installed Python 3.13.0 in 3.54s + cpython-3.13.0+freethreaded-macos-aarch64-none ❯ cargo run -q -- python install 3.12t Searching for Python versions matching: Python 3.12t error: No download found for request: cpython-3.12t-macos-aarch64-none ❯ cargo run -q -- python install 3.13rc3t Searching for Python versions matching: Python 3.13rc3t Found existing installation for Python 3.13rc3t: cpython-3.13.0+freethreaded-macos-aarch64-none ❯ cargo run -q -- run -p 3.13t python -c "import sys; print(sys.base_prefix)" /Users/zb/.local/share/uv/python/cpython-3.13.0+freethreaded-macos-aarch64-none ```
This commit is contained in:
parent
db0f0aec09
commit
5f33915e03
12 changed files with 6319 additions and 4211 deletions
File diff suppressed because it is too large
Load diff
|
@ -106,6 +106,11 @@ class ImplementationName(StrEnum):
|
|||
PYPY = "pypy"
|
||||
|
||||
|
||||
class Variant(StrEnum):
|
||||
FREETHREADED = "freethreaded"
|
||||
DEBUG = "debug"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PythonDownload:
|
||||
version: Version
|
||||
|
@ -115,9 +120,13 @@ class PythonDownload:
|
|||
filename: str
|
||||
url: str
|
||||
sha256: str | None = None
|
||||
variant: Variant | None = None
|
||||
|
||||
def key(self) -> str:
|
||||
return f"{self.implementation}-{self.version}-{self.triple.platform}-{self.triple.arch}-{self.triple.libc}"
|
||||
if self.variant:
|
||||
return f"{self.implementation}-{self.version}+{self.variant}-{self.triple.platform}-{self.triple.arch}-{self.triple.libc}"
|
||||
else:
|
||||
return f"{self.implementation}-{self.version}-{self.triple.platform}-{self.triple.arch}-{self.triple.libc}"
|
||||
|
||||
|
||||
class Finder:
|
||||
|
@ -141,13 +150,6 @@ class CPythonFinder(Finder):
|
|||
"shared-pgo",
|
||||
"shared-noopt",
|
||||
"static-noopt",
|
||||
"pgo+lto",
|
||||
"pgo",
|
||||
"lto",
|
||||
"debug",
|
||||
]
|
||||
HIDDEN_FLAVORS = [
|
||||
"noopt",
|
||||
]
|
||||
SPECIAL_TRIPLES = {
|
||||
"macos": "x86_64-apple-darwin",
|
||||
|
@ -167,26 +169,17 @@ class CPythonFinder(Finder):
|
|||
_filename_re = re.compile(
|
||||
r"""(?x)
|
||||
^
|
||||
cpython-(?P<ver>\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?)
|
||||
(?:\+\d+)?
|
||||
-(?P<triple>.*?)
|
||||
(?:-[\dT]+)?\.tar\.(?:gz|zst)
|
||||
cpython-
|
||||
(?P<ver>\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?)(?:\+\d+)?\+
|
||||
(?P<date>\d+)-
|
||||
(?P<triple>[a-z\d_]+-[a-z\d]+(?>-[a-z\d]+)?-[a-z\d]+)-
|
||||
(?>(?P<build_options>.+)-)?
|
||||
(?P<flavor>.+)
|
||||
\.tar\.(?:gz|zst)
|
||||
$
|
||||
"""
|
||||
)
|
||||
|
||||
_flavor_re = re.compile(
|
||||
r"""(?x)^(.*?)-(%s)$"""
|
||||
% (
|
||||
"|".join(
|
||||
map(
|
||||
re.escape,
|
||||
sorted(FLAVOR_PREFERENCES + HIDDEN_FLAVORS, key=len, reverse=True),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient):
|
||||
self.client = client
|
||||
|
||||
|
@ -197,7 +190,7 @@ class CPythonFinder(Finder):
|
|||
|
||||
async def _fetch_downloads(self, pages: int = 100) -> list[PythonDownload]:
|
||||
"""Fetch all the indygreg downloads from the release API."""
|
||||
results: dict[Version, list[PythonDownload]] = {}
|
||||
downloads_by_version: dict[Version, list[PythonDownload]] = {}
|
||||
|
||||
# Collect all available Python downloads
|
||||
for page in range(1, pages + 1):
|
||||
|
@ -213,24 +206,39 @@ class CPythonFinder(Finder):
|
|||
download = self._parse_download_url(url)
|
||||
if download is None:
|
||||
continue
|
||||
results.setdefault(download.version, []).append(download)
|
||||
logging.debug("Found %s (%s)", download.key(), download.filename)
|
||||
downloads_by_version.setdefault(download.version, []).append(
|
||||
download
|
||||
)
|
||||
|
||||
# Collapse CPython variants to a single URL flavor per triple
|
||||
# Collapse CPython variants to a single URL flavor per triple and variant
|
||||
downloads = []
|
||||
for choices in results.values():
|
||||
flavors: dict[PlatformTriple, tuple[PythonDownload, int]] = {}
|
||||
for choice in choices:
|
||||
priority = self._get_flavor_priority(choice.flavor)
|
||||
existing = flavors.get(choice.triple)
|
||||
for version_downloads in downloads_by_version.values():
|
||||
selected: dict[
|
||||
tuple[PlatformTriple, Variant | None], tuple[PythonDownload, int]
|
||||
] = {}
|
||||
for download in version_downloads:
|
||||
priority = self._get_flavor_priority(download.flavor)
|
||||
existing = selected.get((download.triple, download.variant))
|
||||
if existing:
|
||||
_, existing_priority = existing
|
||||
existing_download, existing_priority = existing
|
||||
# Skip if we have a flavor with higher priority already (indicated by a smaller value)
|
||||
if priority >= existing_priority:
|
||||
logging.debug(
|
||||
"Skipping %s (%s): lower priority than %s (%s)",
|
||||
download.key(),
|
||||
download.flavor,
|
||||
existing_download.key(),
|
||||
existing_download.flavor,
|
||||
)
|
||||
continue
|
||||
flavors[choice.triple] = (choice, priority)
|
||||
selected[(download.triple, download.variant)] = (
|
||||
download,
|
||||
priority,
|
||||
)
|
||||
|
||||
# Drop the priorities
|
||||
downloads.extend([choice for choice, _ in flavors.values()])
|
||||
downloads.extend([download for download, _ in selected.values()])
|
||||
|
||||
return downloads
|
||||
|
||||
|
@ -288,23 +296,23 @@ class CPythonFinder(Finder):
|
|||
|
||||
match = self._filename_re.match(filename)
|
||||
if match is None:
|
||||
logging.debug("Skipping %s: no regex match", filename)
|
||||
return None
|
||||
|
||||
version, triple = match.groups()
|
||||
if triple.endswith("-full"):
|
||||
triple = triple[:-5]
|
||||
version, _date, triple, build_options, flavor = match.groups()
|
||||
|
||||
match = self._flavor_re.match(triple)
|
||||
if match is not None:
|
||||
triple, flavor = match.groups()
|
||||
variants = build_options.split("+") if build_options else []
|
||||
variant: Variant | None
|
||||
for variant in Variant:
|
||||
if variant in variants:
|
||||
break
|
||||
else:
|
||||
flavor = ""
|
||||
if flavor in self.HIDDEN_FLAVORS:
|
||||
return None
|
||||
variant = None
|
||||
|
||||
version = Version.from_str(version)
|
||||
triple = self._normalize_triple(triple)
|
||||
if triple is None:
|
||||
# Skip is logged in `_normalize_triple`
|
||||
return None
|
||||
|
||||
return PythonDownload(
|
||||
|
@ -314,6 +322,7 @@ class CPythonFinder(Finder):
|
|||
implementation=self.implementation,
|
||||
filename=filename,
|
||||
url=url,
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
def _normalize_triple(self, triple: str) -> PlatformTriple | None:
|
||||
|
@ -457,6 +466,16 @@ def render(downloads: list[PythonDownload]) -> None:
|
|||
return 2, int(prerelease[2:])
|
||||
return 3, 0
|
||||
|
||||
def variant_sort_key(variant: Variant | None) -> int:
|
||||
if variant is None:
|
||||
return 0
|
||||
match variant:
|
||||
case Variant.FREETHREADED:
|
||||
return 1
|
||||
case Variant.DEBUG:
|
||||
return 2
|
||||
raise ValueError(f"Missing sort key implementation for variant: {variant}")
|
||||
|
||||
def sort_key(download: PythonDownload) -> tuple:
|
||||
# Sort by implementation, version (latest first), and then by triple.
|
||||
impl_order = [ImplementationName.CPYTHON, ImplementationName.PYPY]
|
||||
|
@ -468,6 +487,7 @@ def render(downloads: list[PythonDownload]) -> None:
|
|||
-download.version.patch,
|
||||
-prerelease[0],
|
||||
-prerelease[1],
|
||||
variant_sort_key(download.variant),
|
||||
download.triple,
|
||||
)
|
||||
|
||||
|
@ -477,7 +497,7 @@ def render(downloads: list[PythonDownload]) -> None:
|
|||
for download in downloads:
|
||||
key = download.key()
|
||||
logging.info(
|
||||
"Found %s%s", key, (" (%s)" % download.flavor) if download.flavor else ""
|
||||
"Selected %s%s", key, (" (%s)" % download.flavor) if download.flavor else ""
|
||||
)
|
||||
results[key] = {
|
||||
"name": download.implementation,
|
||||
|
@ -490,6 +510,7 @@ def render(downloads: list[PythonDownload]) -> None:
|
|||
"prerelease": download.version.prerelease,
|
||||
"url": download.url,
|
||||
"sha256": download.sha256,
|
||||
"variant": download.variant if download.variant else None,
|
||||
}
|
||||
|
||||
VERSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
@ -132,7 +132,7 @@ pub enum EnvironmentPreference {
|
|||
Any,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum PythonVariant {
|
||||
#[default]
|
||||
Default,
|
||||
|
@ -1975,6 +1975,19 @@ impl VersionRequest {
|
|||
Self::Range(specifiers, _) => Self::Range(specifiers, PythonVariant::Default),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the required [`PythonVariant`] of the request.
|
||||
pub(crate) fn variant(&self) -> Option<PythonVariant> {
|
||||
match self {
|
||||
Self::Any => None,
|
||||
Self::Default => Some(PythonVariant::Default),
|
||||
Self::Major(_, variant)
|
||||
| Self::MajorMinor(_, _, variant)
|
||||
| Self::MajorMinorPatch(_, _, _, variant)
|
||||
| Self::MajorMinorPrerelease(_, _, _, variant)
|
||||
| Self::Range(_, variant) => Some(*variant),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for VersionRequest {
|
||||
|
@ -2049,6 +2062,27 @@ impl FromStr for VersionRequest {
|
|||
}
|
||||
}
|
||||
|
||||
impl FromStr for PythonVariant {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"t" | "freethreaded" => Ok(Self::Freethreaded),
|
||||
"" => Ok(Self::Default),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PythonVariant {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Default => f.write_str("default"),
|
||||
Self::Freethreaded => f.write_str("freethreaded"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_version_specifiers_request(
|
||||
s: &str,
|
||||
variant: PythonVariant,
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,9 +1,8 @@
|
|||
// DO NOT EDIT
|
||||
//
|
||||
// Generated with `{{generated_with}}`
|
||||
// From template at `{{generated_from}}`
|
||||
|
||||
use uv_pep440::{Prerelease, PrereleaseKind};
|
||||
use crate::PythonVariant;
|
||||
|
||||
pub(crate) const PYTHON_DOWNLOADS: &[ManagedPythonDownload] = &[
|
||||
{{#versions}}
|
||||
|
@ -22,6 +21,8 @@ pub(crate) const PYTHON_DOWNLOADS: &[ManagedPythonDownload] = &[
|
|||
{{^value.libc}}
|
||||
libc: Libc::None,
|
||||
{{/value.libc}}
|
||||
variant: {{value.variant}}
|
||||
|
||||
},
|
||||
url: "{{value.url}}",
|
||||
{{#value.sha256}}
|
||||
|
|
|
@ -273,9 +273,10 @@ impl PythonDownloadRequest {
|
|||
) {
|
||||
return false;
|
||||
}
|
||||
if version.is_freethreaded() {
|
||||
debug!("Installing managed free-threaded Python is not yet supported");
|
||||
return false;
|
||||
if let Some(variant) = version.variant() {
|
||||
if variant != key.variant {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
|
|
|
@ -16,7 +16,7 @@ use crate::managed::{ManagedPythonInstallation, ManagedPythonInstallations};
|
|||
use crate::platform::{Arch, Libc, Os};
|
||||
use crate::{
|
||||
downloads, Error, ImplementationName, Interpreter, PythonDownloads, PythonPreference,
|
||||
PythonSource, PythonVersion,
|
||||
PythonSource, PythonVariant, PythonVersion,
|
||||
};
|
||||
|
||||
/// A Python interpreter and accompanying tools.
|
||||
|
@ -227,6 +227,7 @@ pub struct PythonInstallationKey {
|
|||
pub(crate) os: Os,
|
||||
pub(crate) arch: Arch,
|
||||
pub(crate) libc: Libc,
|
||||
pub(crate) variant: PythonVariant,
|
||||
}
|
||||
|
||||
impl PythonInstallationKey {
|
||||
|
@ -239,6 +240,7 @@ impl PythonInstallationKey {
|
|||
os: Os,
|
||||
arch: Arch,
|
||||
libc: Libc,
|
||||
variant: PythonVariant,
|
||||
) -> Self {
|
||||
Self {
|
||||
implementation,
|
||||
|
@ -249,6 +251,7 @@ impl PythonInstallationKey {
|
|||
os,
|
||||
arch,
|
||||
libc,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -258,6 +261,7 @@ impl PythonInstallationKey {
|
|||
os: Os,
|
||||
arch: Arch,
|
||||
libc: Libc,
|
||||
variant: PythonVariant,
|
||||
) -> Self {
|
||||
Self {
|
||||
implementation,
|
||||
|
@ -268,6 +272,7 @@ impl PythonInstallationKey {
|
|||
os,
|
||||
arch,
|
||||
libc,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -303,9 +308,13 @@ impl PythonInstallationKey {
|
|||
|
||||
impl fmt::Display for PythonInstallationKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let variant = match self.variant {
|
||||
PythonVariant::Default => String::new(),
|
||||
PythonVariant::Freethreaded => format!("+{}", self.variant),
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"{}-{}.{}.{}{}-{}-{}-{}",
|
||||
"{}-{}.{}.{}{}{}-{}-{}-{}",
|
||||
self.implementation,
|
||||
self.major,
|
||||
self.minor,
|
||||
|
@ -313,6 +322,7 @@ impl fmt::Display for PythonInstallationKey {
|
|||
self.prerelease
|
||||
.map(|pre| pre.to_string())
|
||||
.unwrap_or_default(),
|
||||
variant,
|
||||
self.os,
|
||||
self.arch,
|
||||
self.libc
|
||||
|
@ -349,6 +359,19 @@ impl FromStr for PythonInstallationKey {
|
|||
PythonInstallationKeyError::ParseError(key.to_string(), format!("invalid libc: {err}"))
|
||||
})?;
|
||||
|
||||
let (version, variant) = match version.split_once('+') {
|
||||
Some((version, variant)) => {
|
||||
let variant = PythonVariant::from_str(variant).map_err(|()| {
|
||||
PythonInstallationKeyError::ParseError(
|
||||
key.to_string(),
|
||||
format!("invalid Python variant: {variant}"),
|
||||
)
|
||||
})?;
|
||||
(version, variant)
|
||||
}
|
||||
None => (*version, PythonVariant::Default),
|
||||
};
|
||||
|
||||
let version = PythonVersion::from_str(version).map_err(|err| {
|
||||
PythonInstallationKeyError::ParseError(
|
||||
key.to_string(),
|
||||
|
@ -362,6 +385,7 @@ impl FromStr for PythonInstallationKey {
|
|||
os,
|
||||
arch,
|
||||
libc,
|
||||
variant,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,8 @@ use crate::implementation::LenientImplementationName;
|
|||
use crate::platform::{Arch, Libc, Os};
|
||||
use crate::pointer_size::PointerSize;
|
||||
use crate::{
|
||||
Prefix, PythonInstallationKey, PythonVersion, Target, VersionRequest, VirtualEnvironment,
|
||||
Prefix, PythonInstallationKey, PythonVariant, PythonVersion, Target, VersionRequest,
|
||||
VirtualEnvironment,
|
||||
};
|
||||
|
||||
/// A Python executable and its associated platform markers.
|
||||
|
@ -161,9 +162,18 @@ impl Interpreter {
|
|||
self.os(),
|
||||
self.arch(),
|
||||
self.libc(),
|
||||
self.variant(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn variant(&self) -> PythonVariant {
|
||||
if self.gil_disabled() {
|
||||
PythonVariant::Freethreaded
|
||||
} else {
|
||||
PythonVariant::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the [`Arch`] reported by the interpreter platform tags.
|
||||
pub fn arch(&self) -> Arch {
|
||||
Arch::from(&self.platform().arch())
|
||||
|
|
|
@ -20,7 +20,7 @@ use crate::libc::LibcDetectionError;
|
|||
use crate::platform::Error as PlatformError;
|
||||
use crate::platform::{Arch, Libc, Os};
|
||||
use crate::python_version::PythonVersion;
|
||||
use crate::PythonRequest;
|
||||
use crate::{PythonRequest, PythonVariant};
|
||||
use uv_fs::{LockedFile, Simplified};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
|
@ -329,13 +329,17 @@ impl ManagedPythonInstallation {
|
|||
let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) {
|
||||
self.python_dir().join("Lib")
|
||||
} else {
|
||||
let lib_suffix = match self.key.variant {
|
||||
PythonVariant::Default => "",
|
||||
PythonVariant::Freethreaded => "t",
|
||||
};
|
||||
let python = if matches!(
|
||||
self.key.implementation,
|
||||
LenientImplementationName::Known(ImplementationName::PyPy)
|
||||
) {
|
||||
format!("pypy{}", self.key.version().python_version())
|
||||
} else {
|
||||
format!("python{}", self.key.version().python_version())
|
||||
format!("python{}{lib_suffix}", self.key.version().python_version())
|
||||
};
|
||||
self.python_dir().join("lib").join(python)
|
||||
};
|
||||
|
|
|
@ -50,6 +50,18 @@ def prepare_libc(libc: str) -> str | None:
|
|||
return libc.title()
|
||||
|
||||
|
||||
def prepare_variant(variant: str | None) -> str | None:
|
||||
match variant:
|
||||
case None:
|
||||
return "PythonVariant::Default"
|
||||
case "freethreaded":
|
||||
return "PythonVariant::Freethreaded"
|
||||
case "debug":
|
||||
return "PythonVariant::Debug"
|
||||
case _:
|
||||
raise ValueError(f"Unknown variant: {variant}")
|
||||
|
||||
|
||||
def prepare_arch(arch: str) -> str:
|
||||
match arch:
|
||||
# Special constructors
|
||||
|
@ -78,6 +90,7 @@ def prepare_value(value: dict) -> dict:
|
|||
value["name"] = prepare_name(value["name"])
|
||||
value["libc"] = prepare_libc(value["libc"])
|
||||
value["prerelease"] = prepare_prerelease(value["prerelease"])
|
||||
value["variant"] = prepare_variant(value["variant"])
|
||||
return value
|
||||
|
||||
|
||||
|
@ -90,6 +103,8 @@ def main() -> None:
|
|||
data["versions"] = [
|
||||
{"key": key, "value": prepare_value(value)}
|
||||
for key, value in json.loads(VERSION_METADATA.read_text()).items()
|
||||
# Exclude debug variants for now, we don't support them in the Rust side
|
||||
if value["variant"] != "debug"
|
||||
]
|
||||
|
||||
# Render the template
|
||||
|
@ -100,7 +115,7 @@ def main() -> None:
|
|||
|
||||
# Update the file
|
||||
logging.info(f"Updating `{TARGET}`...")
|
||||
TARGET.write_text(output)
|
||||
TARGET.write_text("// DO NOT EDIT\n//\n" + output)
|
||||
subprocess.check_call(
|
||||
["rustfmt", str(TARGET)],
|
||||
stderr=subprocess.STDOUT,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue