Merge branch 'main' into py314-docker

This commit is contained in:
Zanie Blue 2025-10-07 16:27:41 -05:00
commit 307e0a9b5e
15 changed files with 3645 additions and 706 deletions

View File

@ -1,5 +1,5 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::{env, fs};
fn process_json(data: &serde_json::Value) -> serde_json::Value {
let mut out_data = serde_json::Map::new();
@ -14,9 +14,25 @@ fn process_json(data: &serde_json::Value) -> serde_json::Value {
}
fn main() {
let version_metadata = "download-metadata.json";
println!("cargo::rerun-if-changed={version_metadata}");
let target = Path::new("src/download-metadata-minified.json");
let version_metadata = PathBuf::from_iter([
env::var("CARGO_MANIFEST_DIR").unwrap(),
"download-metadata.json".into(),
]);
let version_metadata_minified = PathBuf::from_iter([
env::var("OUT_DIR").unwrap(),
"download-metadata-minified.json".into(),
]);
println!(
"cargo::rerun-if-changed={}",
version_metadata.to_str().unwrap()
);
println!(
"cargo::rerun-if-changed={}",
version_metadata_minified.to_str().unwrap()
);
let json_data: serde_json::Value = serde_json::from_str(
#[allow(clippy::disallowed_methods)]
@ -28,7 +44,7 @@ fn main() {
#[allow(clippy::disallowed_methods)]
fs::write(
target,
version_metadata_minified,
serde_json::to_string(&filtered_data).expect("Failed to serialize JSON"),
)
.expect("Failed to write minified JSON");

File diff suppressed because it is too large Load Diff

View File

@ -1668,9 +1668,19 @@ fn is_windows_store_shim(_path: &Path) -> bool {
impl PythonVariant {
fn matches_interpreter(self, interpreter: &Interpreter) -> bool {
match self {
// TODO(zanieb): Right now, we allow debug interpreters to be selected by default for
// backwards compatibility, but we may want to change this in the future.
Self::Default => !interpreter.gil_disabled(),
Self::Default => {
// TODO(zanieb): Right now, we allow debug interpreters to be selected by default for
// backwards compatibility, but we may want to change this in the future.
if (interpreter.python_major(), interpreter.python_minor()) >= (3, 14) {
// For Python 3.14+, the free-threaded build is not considered experimental
// and can satisfy the default variant without opt-in
true
} else {
// In Python 3.13 and earlier, the free-threaded build is considered
// experimental and requires explicit opt-in
!interpreter.gil_disabled()
}
}
Self::Debug => interpreter.debug_enabled(),
Self::Freethreaded => interpreter.gil_disabled(),
Self::FreethreadedDebug => interpreter.gil_disabled() && interpreter.debug_enabled(),
@ -1935,6 +1945,24 @@ impl PythonRequest {
}
}
/// Check if this request includes a specific prerelease version.
pub fn includes_prerelease(&self) -> bool {
match self {
Self::Default => false,
Self::Any => false,
Self::Version(version_request) => version_request.prerelease().is_some(),
Self::Directory(..) => false,
Self::File(..) => false,
Self::ExecutableName(..) => false,
Self::Implementation(..) => false,
Self::ImplementationVersion(_, version) => version.prerelease().is_some(),
Self::Key(request) => request
.version
.as_ref()
.is_some_and(|request| request.prerelease().is_some()),
}
}
/// Check if a given interpreter satisfies the interpreter request.
pub fn satisfied(&self, interpreter: &Interpreter, cache: &Cache) -> bool {
/// Returns `true` if the two paths refer to the same interpreter executable.
@ -2555,6 +2583,17 @@ impl VersionRequest {
}
}
/// Return the pre-release segment of the request, if any.
pub(crate) fn prerelease(&self) -> Option<&Prerelease> {
match self {
Self::Any | Self::Default | Self::Range(_, _) => None,
Self::Major(_, _) => None,
Self::MajorMinor(_, _, _) => None,
Self::MajorMinorPatch(_, _, _, _) => None,
Self::MajorMinorPrerelease(_, _, prerelease, _) => Some(prerelease),
}
}
/// Check if the request is for a version supported by uv.
///
/// If not, an `Err` is returned with an explanatory message.
@ -2760,8 +2799,8 @@ impl VersionRequest {
),
Self::MajorMinorPrerelease(self_major, self_minor, self_prerelease, _) => {
// Pre-releases of Python versions are always for the zero patch version
(*self_major, *self_minor, 0) == (major, minor, patch)
&& prerelease.is_none_or(|pre| *self_prerelease == pre)
(*self_major, *self_minor, 0, Some(*self_prerelease))
== (major, minor, patch, prerelease)
}
}
}

View File

@ -792,7 +792,8 @@ impl FromStr for PythonDownloadRequest {
}
}
const BUILTIN_PYTHON_DOWNLOADS_JSON: &str = include_str!("download-metadata-minified.json");
const BUILTIN_PYTHON_DOWNLOADS_JSON: &str =
include_str!(concat!(env!("OUT_DIR"), "/download-metadata-minified.json"));
static PYTHON_DOWNLOADS: OnceCell<std::borrow::Cow<'static, [ManagedPythonDownload]>> =
OnceCell::new();

View File

@ -681,19 +681,22 @@ impl ManagedPythonInstallation {
if (self.key.major, self.key.minor) != (other.key.major, other.key.minor) {
return false;
}
// Require a newer, or equal patch version (for pre-release upgrades)
// If the patch versions are the same, we're handling a pre-release upgrade
if self.key.patch == other.key.patch {
return match (self.key.prerelease, other.key.prerelease) {
// Require a newer pre-release, if present on both
(Some(self_pre), Some(other_pre)) => self_pre > other_pre,
// Allow upgrade from pre-release to stable
(None, Some(_)) => true,
// Do not upgrade from pre-release to stable, or for matching versions
(_, None) => false,
};
}
// Require a newer patch version
if self.key.patch < other.key.patch {
return false;
}
if let Some(other_pre) = other.key.prerelease {
if let Some(self_pre) = self.key.prerelease {
return self_pre > other_pre;
}
// Do not upgrade from non-prerelease to prerelease
return false;
}
// Do not upgrade if the patch versions are the same
self.key.patch != other.key.patch
true
}
pub fn url(&self) -> Option<&str> {
@ -1136,9 +1139,10 @@ mod tests {
PythonVariant::Default,
);
// Stable version should not upgrade from prerelease
assert!(!stable.is_upgrade_of(&prerelease));
// Prerelease should not upgrade to stable (same patch version)
// A stable version is an upgrade from prerelease
assert!(stable.is_upgrade_of(&prerelease));
// Prerelease are not upgrades of stable versions
assert!(!prerelease.is_upgrade_of(&stable));
}

View File

@ -2938,13 +2938,31 @@ impl ForkState {
resolution_strategy,
ResolutionStrategy::Lowest | ResolutionStrategy::LowestDirect(..)
);
if !has_url && missing_lower_bound && strategy_lowest {
warn_user_once!(
"The direct dependency `{name}` is unpinned. \
Consider setting a lower bound when using `--resolution lowest` \
or `--resolution lowest-direct` to avoid using outdated versions.",
name = package.name_no_root().unwrap(),
);
let name = package.name_no_root().unwrap();
// Handle cases where a package is listed both without and with a lower bound.
// Example:
// ```
// "coverage[toml] ; python_version < '3.11'",
// "coverage >= 7.10.0",
// ```
let bound_on_other_package = dependencies.iter().any(|other| {
Some(name) == other.package.name()
&& !other
.version
.bounding_range()
.map(|(lowest, _highest)| lowest == Bound::Unbounded)
.unwrap_or(true)
});
if !bound_on_other_package {
warn_user_once!(
"The direct dependency `{name}` is unpinned. \
Consider setting a lower bound when using `--resolution lowest` \
or `--resolution lowest-direct` to avoid using outdated versions.",
);
}
}
}

View File

@ -285,9 +285,9 @@ pub(crate) async fn install(
.collect::<IndexSet<_>>();
if upgrade
&& requests
.iter()
.any(|request| request.request.includes_patch())
&& requests.iter().any(|request| {
request.request.includes_patch() || request.request.includes_prerelease()
})
{
writeln!(
printer.stderr(),
@ -551,19 +551,28 @@ pub(crate) async fn install(
printer.stderr(),
"There are no installed versions to upgrade"
)?;
} else if requests.len() > 1 {
} else if upgrade && is_unspecified_upgrade {
writeln!(
printer.stderr(),
"All versions already on latest supported patch release"
)?;
} else if let [request] = requests.as_slice() {
// Convert to the inner request
let request = &request.request;
if upgrade {
if is_unspecified_upgrade {
writeln!(
printer.stderr(),
"All versions already on latest supported patch release"
)?;
} else {
writeln!(
printer.stderr(),
"All requested versions already on latest supported patch release"
)?;
}
writeln!(
printer.stderr(),
"{request} is already on the latest supported patch release"
)?;
} else {
writeln!(printer.stderr(), "{request} is already installed")?;
}
} else {
if upgrade {
writeln!(
printer.stderr(),
"All requested versions already on latest supported patch release"
)?;
} else {
writeln!(printer.stderr(), "All requested versions already installed")?;
}

View File

@ -9,9 +9,10 @@ use tracing::{debug, trace};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::{Concurrency, Constraints, DryRun, TargetTriple};
use uv_distribution_types::{ExtraBuildRequires, Requirement};
use uv_distribution_types::{ExtraBuildRequires, Requirement, RequirementSource};
use uv_fs::CWD;
use uv_normalize::PackageName;
use uv_pep440::{Operator, Version};
use uv_preview::Preview;
use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonInstallation, PythonPreference,
@ -19,7 +20,7 @@ use uv_python::{
};
use uv_requirements::RequirementsSpecification;
use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
use uv_tool::InstalledTools;
use uv_tool::{InstalledTools, Tool};
use uv_warnings::write_error_chain;
use uv_workspace::WorkspaceCache;
@ -114,6 +115,9 @@ pub(crate) async fn upgrade(
// Determine whether we applied any upgrades.
let mut did_upgrade_environment = vec![];
// Constraints that caused upgrades to be skipped or altered.
let mut collected_constraints: Vec<(PackageName, UpgradeConstraint)> = Vec::new();
let mut errors = Vec::new();
for (name, constraints) in &names {
debug!("Upgrading tool: `{name}`");
@ -135,14 +139,22 @@ pub(crate) async fn upgrade(
.await;
match result {
Ok(UpgradeOutcome::UpgradeEnvironment) => {
did_upgrade_environment.push(name);
}
Ok(UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::UpgradeTool) => {
did_upgrade_tool.push(name);
}
Ok(UpgradeOutcome::NoOp) => {
debug!("Upgrading `{name}` was a no-op");
Ok(report) => {
match report.outcome {
UpgradeOutcome::UpgradeEnvironment => {
did_upgrade_environment.push(name);
}
UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeDependencies => {
did_upgrade_tool.push(name);
}
UpgradeOutcome::NoOp => {
debug!("Upgrading `{name}` was a no-op");
}
}
if let Some(constraint) = report.constraint.clone() {
collected_constraints.push((name.clone(), constraint));
}
}
Err(err) => {
errors.push((name, err));
@ -187,6 +199,14 @@ pub(crate) async fn upgrade(
}
}
if !collected_constraints.is_empty() {
writeln!(printer.stderr())?;
}
for (name, constraint) in collected_constraints {
constraint.print(&name, printer)?;
}
Ok(ExitStatus::Success)
}
@ -202,6 +222,39 @@ enum UpgradeOutcome {
NoOp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum UpgradeConstraint {
/// The tool remains pinned to an exact version, so an upgrade was skipped.
PinnedVersion { version: Version },
}
impl UpgradeConstraint {
fn print(&self, name: &PackageName, printer: Printer) -> Result<()> {
match self {
Self::PinnedVersion { version } => {
let name = name.to_string();
let reinstall_command = format!("uv tool install {name}@latest");
writeln!(
printer.stderr(),
"hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to upgrade to a new version.",
name.cyan(),
version.to_string().magenta(),
reinstall_command.green(),
)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct UpgradeReport {
outcome: UpgradeOutcome,
constraint: Option<UpgradeConstraint>,
}
/// Upgrade a specific tool.
async fn upgrade_tool(
name: &PackageName,
@ -217,7 +270,7 @@ async fn upgrade_tool(
installer_metadata: bool,
concurrency: Concurrency,
preview: Preview,
) -> Result<UpgradeOutcome> {
) -> Result<UpgradeReport> {
// Ensure the tool is installed.
let existing_tool_receipt = match installed_tools.get_tool_receipt(name) {
Ok(Some(receipt)) => receipt,
@ -398,5 +451,38 @@ async fn upgrade_tool(
)?;
}
Ok(outcome)
let constraint = match &outcome {
UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => {
pinned_requirement_version(&existing_tool_receipt, name)
.map(|version| UpgradeConstraint::PinnedVersion { version })
}
UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None,
};
Ok(UpgradeReport {
outcome,
constraint,
})
}
fn pinned_requirement_version(tool: &Tool, name: &PackageName) -> Option<Version> {
pinned_version_from(tool.requirements(), name)
.or_else(|| pinned_version_from(tool.constraints(), name))
}
fn pinned_version_from(requirements: &[Requirement], name: &PackageName) -> Option<Version> {
requirements
.iter()
.filter(|requirement| requirement.name == *name)
.find_map(|requirement| match &requirement.source {
RequirementSource::Registry { specifier, .. } => {
specifier
.iter()
.find_map(|specifier| match specifier.operator() {
Operator::Equal | Operator::ExactEqual => Some(specifier.version().clone()),
_ => None,
})
}
_ => None,
})
}

View File

@ -31969,3 +31969,34 @@ fn collapsed_error_with_marker_packages() -> Result<()> {
Ok(())
}
/// <https://github.com/astral-sh/uv/issues/16148>
#[test]
fn no_warning_without_and_with_lower_bound() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"anyio[trio]",
"anyio>=4"
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock().arg("--resolution").arg("lowest-direct"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 10 packages in [TIME]
");
Ok(())
}

View File

@ -1,3 +1,4 @@
use assert_cmd::assert::OutputAssertExt;
use assert_fs::prelude::{FileTouch, PathChild};
use assert_fs::{fixture::FileWriteStr, prelude::PathCreateDir};
use indoc::indoc;
@ -1155,7 +1156,7 @@ fn python_find_script_no_such_version() {
script
.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.14"
# requires-python = ">=3.15"
# dependencies = []
# ///
"#})
@ -1167,7 +1168,7 @@ fn python_find_script_no_such_version() {
----- stdout -----
----- stderr -----
No interpreter found for Python >=3.14 in [PYTHON SOURCES]
No interpreter found for Python >=3.15 in [PYTHON SOURCES]
");
}
@ -1256,3 +1257,81 @@ fn python_find_path() {
error: No interpreter found at path `foobar`
");
}
#[test]
fn python_find_freethreaded_313() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_python_sources()
.with_managed_python_dirs()
.with_python_download_cache()
.with_filtered_python_install_bin()
.with_filtered_python_names()
.with_filtered_exe_suffix();
context
.python_install()
.arg("--preview")
.arg("3.13t")
.assert()
.success();
// Request Python 3.13 (without opt-in)
uv_snapshot!(context.filters(), context.python_find().arg("3.13"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python 3.13 in [PYTHON SOURCES]
");
// Request Python 3.13t (with explicit opt-in)
uv_snapshot!(context.filters(), context.python_find().arg("3.13t"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/cpython-3.13+freethreaded-[PLATFORM]/[INSTALL-BIN]/[PYTHON]
----- stderr -----
");
}
#[test]
fn python_find_freethreaded_314() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_python_sources()
.with_managed_python_dirs()
.with_python_download_cache()
.with_filtered_python_install_bin()
.with_filtered_python_names()
.with_filtered_exe_suffix();
context
.python_install()
.arg("--preview")
.arg("3.14t")
.assert()
.success();
// Request Python 3.14 (without opt-in)
uv_snapshot!(context.filters(), context.python_find().arg("3.14"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/cpython-3.14+freethreaded-[PLATFORM]/[INSTALL-BIN]/[PYTHON]
----- stderr -----
");
// Request Python 3.14t (with explicit opt-in)
uv_snapshot!(context.filters(), context.python_find().arg("3.14t"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/cpython-3.14+freethreaded-[PLATFORM]/[INSTALL-BIN]/[PYTHON]
----- stderr -----
");
}

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,7 @@ fn python_upgrade() {
----- stdout -----
----- stderr -----
Python 3.10 is already on the latest supported patch release
");
// Should reinstall on `--reinstall`
@ -83,8 +84,8 @@ fn python_upgrade() {
----- stderr -----
warning: `uv python upgrade` is experimental and may change without warning. Pass `--preview-features python-upgrade` to disable this warning
Installed Python 3.14.0rc3 in [TIME]
+ cpython-3.14.0rc3-[PLATFORM] (python3.14)
Installed Python 3.14.0 in [TIME]
+ cpython-3.14.0-[PLATFORM] (python3.14)
");
}

View File

@ -5035,7 +5035,7 @@ fn run_groups_requires_python() -> Result<()> {
dev = ["sniffio"]
[tool.uv.dependency-groups]
foo = {requires-python=">=3.14"}
foo = {requires-python=">=3.100"}
bar = {requires-python=">=3.13"}
dev = {requires-python=">=3.12"}
"#,
@ -5168,7 +5168,7 @@ fn run_groups_requires_python() -> Result<()> {
----- stdout -----
----- stderr -----
error: No interpreter found for Python >=3.14 in [PYTHON SOURCES]
error: No interpreter found for Python >=3.100 in [PYTHON SOURCES]
");
Ok(())

View File

@ -215,6 +215,109 @@ fn tool_upgrade_multiple_names() {
"###);
}
#[test]
fn tool_upgrade_pinned_hint() {
let context = TestContext::new("3.12")
.with_filtered_counts()
.with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
// Install a specific version of `babel` so the receipt records an exact pin.
uv_snapshot!(context.filters(), context.tool_install()
.arg("babel==2.6.0")
.arg("--index-url")
.arg("https://test.pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ babel==2.6.0
+ pytz==2018.5
Installed 1 executable: pybabel
"###);
// Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why.
uv_snapshot!(context.filters(), context.tool_upgrade()
.arg("babel")
.arg("--index-url")
.arg("https://pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Modified babel environment
- pytz==2018.5
+ pytz==2024.1
hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version.
"###);
}
#[test]
fn tool_upgrade_pinned_hint_with_mixed_constraint() {
let context = TestContext::new("3.12")
.with_filtered_counts()
.with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
// Install a specific version of `babel` with an additional constraint to ensure the requirement
// contains multiple specifiers while still including an exact pin.
uv_snapshot!(context.filters(), context.tool_install()
.arg("babel>=2.0,==2.6.0")
.arg("--index-url")
.arg("https://test.pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ babel==2.6.0
+ pytz==2018.5
Installed 1 executable: pybabel
"###);
// Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why.
uv_snapshot!(context.filters(), context.tool_upgrade()
.arg("babel")
.arg("--index-url")
.arg("https://pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Modified babel environment
- pytz==2018.5
+ pytz==2024.1
hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version.
"###);
}
#[test]
fn tool_upgrade_all() {
let context = TestContext::new("3.12")
@ -683,6 +786,8 @@ fn tool_upgrade_with() {
Modified babel environment
- pytz==2018.5
+ pytz==2024.1
hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version.
"###);
}

View File

@ -99,6 +99,6 @@ However, uv supports `pylock.toml` as an export target and in the `uv pip` CLI.
- To export a `uv.lock` to the `pylock.toml` format, run: `uv export -o pylock.toml`
- To generate a `pylock.toml` file from a set of requirements, run:
`uv pip compile -o pylock.toml -r requirements.in`
`uv pip compile requirements.in -o pylock.toml`
- To install from a `pylock.toml` file, run: `uv pip sync pylock.toml` or
`uv pip install -r pylock.toml`