From 7310ea75da16e6b8c86bbc11c12d6c46a15b8f8e Mon Sep 17 00:00:00 2001 From: Jack O'Connor Date: Fri, 30 May 2025 09:12:39 -0700 Subject: [PATCH] allow running non-default Python interpreters directly via uvx (#13583) Previously if you wanted to run e.g. PyPy via `uvx`, you had to spell it like `uvx -p pypy python`. Now we reuse some of the `PythonRequest::parse` machinery to handle the executable, so all of the following examples work: - `uvx python3.8` - `uvx 'python>3.7,<3.9'` - `uvx --from python3.8 python` (or e.g. `bash`) - `uvx pypy38` - `uvx graalpy@38` The `python` (and on Windows only, `pythonw`) special cases are retained, which normally aren't allowed values of `-p`/`--python`. Closes https://github.com/astral-sh/uv/issues/13536. --------- Co-authored-by: Zanie Blue Co-authored-by: konsti --- crates/uv-python/src/discovery.rs | 220 +++++++++++++++---- crates/uv/src/commands/tool/install.rs | 37 ++-- crates/uv/src/commands/tool/mod.rs | 75 ++++--- crates/uv/src/commands/tool/run.rs | 279 +++++++++++++------------ crates/uv/tests/it/tool_run.rs | 139 +++++++++++- 5 files changed, 521 insertions(+), 229 deletions(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 09022f03b..274cb51d4 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -239,6 +239,10 @@ pub enum Error { #[error("Invalid version request: {0}")] InvalidVersionRequest(String), + /// The @latest version request was given + #[error("Requesting the 'latest' Python version is not yet supported")] + LatestVersionRequest, + // TODO(zanieb): Is this error case necessary still? We should probably drop it. #[error("Interpreter discovery for `{0}` requires `{1}` but only `{2}` is allowed")] SourceNotAllowed(PythonRequest, PythonSource, PythonPreference), @@ -1389,55 +1393,36 @@ impl PythonVariant { impl PythonRequest { /// Create a request from a string. /// - /// This cannot fail, which means weird inputs will be parsed as [`PythonRequest::File`] or [`PythonRequest::ExecutableName`]. + /// This cannot fail, which means weird inputs will be parsed as [`PythonRequest::File`] or + /// [`PythonRequest::ExecutableName`]. + /// + /// This is intended for parsing the argument to the `--python` flag. See also + /// [`try_from_tool_name`][Self::try_from_tool_name] below. pub fn parse(value: &str) -> Self { + let lowercase_value = &value.to_ascii_lowercase(); + // Literals, e.g. `any` or `default` - if value.eq_ignore_ascii_case("any") { + if lowercase_value == "any" { return Self::Any; } - if value.eq_ignore_ascii_case("default") { + if lowercase_value == "default" { return Self::Default; } - // e.g. `3.12.1`, `312`, or `>=3.12` - if let Ok(version) = VersionRequest::from_str(value) { - return Self::Version(version); - } - // e.g. `python3.12.1` - if let Some(remainder) = value.strip_prefix("python") { - if let Ok(version) = VersionRequest::from_str(remainder) { - return Self::Version(version); - } - } - // e.g. `pypy@3.12` - if let Some((first, second)) = value.split_once('@') { - if let Ok(implementation) = ImplementationName::from_str(first) { - if let Ok(version) = VersionRequest::from_str(second) { - return Self::ImplementationVersion(implementation, version); - } - } - } - for implementation in - ImplementationName::long_names().chain(ImplementationName::short_names()) - { - if let Some(remainder) = value.to_ascii_lowercase().strip_prefix(implementation) { - // e.g. `pypy` - if remainder.is_empty() { - return Self::Implementation( - // Safety: The name matched the possible names above - ImplementationName::from_str(implementation).unwrap(), - ); - } - // e.g. `pypy3.12` or `pp312` - if let Ok(version) = VersionRequest::from_str(remainder) { - return Self::ImplementationVersion( - // Safety: The name matched the possible names above - ImplementationName::from_str(implementation).unwrap(), - version, - ); - } - } + // the prefix of e.g. `python312` and the empty prefix of bare versions, e.g. `312` + let abstract_version_prefixes = ["python", ""]; + let all_implementation_names = + ImplementationName::long_names().chain(ImplementationName::short_names()); + // Abstract versions like `python@312`, `python312`, or `312`, plus implementations and + // implementation versions like `pypy`, `pypy@312` or `pypy312`. + if let Ok(Some(request)) = Self::parse_versions_and_implementations( + abstract_version_prefixes, + all_implementation_names, + lowercase_value, + ) { + return request; } + let value_as_path = PathBuf::from(value); // e.g. /path/to/.venv if value_as_path.is_dir() { @@ -1448,7 +1433,7 @@ impl PythonRequest { return Self::File(value_as_path); } - // e.g. path/to/python on Windows, where path/to/python is the true path + // e.g. path/to/python on Windows, where path/to/python.exe is the true path #[cfg(windows)] if value_as_path.extension().is_none() { let value_as_path = value_as_path.with_extension(EXE_SUFFIX); @@ -1491,6 +1476,125 @@ impl PythonRequest { Self::ExecutableName(value.to_string()) } + /// Try to parse a tool name as a Python version, e.g. `uvx python311`. + /// + /// The `PythonRequest::parse` constructor above is intended for the `--python` flag, where the + /// value is unambiguously a Python version. This alternate constructor is intended for `uvx` + /// or `uvx --from`, where the executable could be either a Python version or a package name. + /// There are several differences in behavior: + /// + /// - This only supports long names, including e.g. `pypy39` but **not** `pp39` or `39`. + /// - On Windows only, this allows `pythonw` as an alias for `python`. + /// - This allows `python` by itself (and on Windows, `pythonw`) as an alias for `default`. + /// + /// This can only return `Err` if `@` is used. Otherwise, if no match is found, it returns + /// `Ok(None)`. + pub fn try_from_tool_name(value: &str) -> Result, Error> { + let lowercase_value = &value.to_ascii_lowercase(); + // Omitting the empty string from these lists excludes bare versions like "39". + let abstract_version_prefixes = if cfg!(windows) { + &["python", "pythonw"][..] + } else { + &["python"][..] + }; + // e.g. just `python` + if abstract_version_prefixes.contains(&lowercase_value.as_str()) { + return Ok(Some(Self::Default)); + } + Self::parse_versions_and_implementations( + abstract_version_prefixes.iter().copied(), + ImplementationName::long_names(), + lowercase_value, + ) + } + + /// Take a value like `"python3.11"`, check whether it matches a set of abstract python + /// prefixes (e.g. `"python"`, `"pythonw"`, or even `""`) or a set of specific Python + /// implementations (e.g. `"cpython"` or `"pypy"`, possibly with abbreviations), and if so try + /// to parse its version. + /// + /// This can only return `Err` if `@` is used, see + /// [`try_split_prefix_and_version`][Self::try_split_prefix_and_version] below. Otherwise, if + /// no match is found, it returns `Ok(None)`. + fn parse_versions_and_implementations<'a>( + // typically "python", possibly also "pythonw" or "" (for bare versions) + abstract_version_prefixes: impl IntoIterator, + // expected to be either long_names() or all names + implementation_names: impl IntoIterator, + // the string to parse + lowercase_value: &str, + ) -> Result, Error> { + for prefix in abstract_version_prefixes { + if let Some(version_request) = + Self::try_split_prefix_and_version(prefix, lowercase_value)? + { + // e.g. `python39` or `python@39` + // Note that e.g. `python` gets handled elsewhere, if at all. (It's currently + // allowed in tool executables but not in --python flags.) + return Ok(Some(Self::Version(version_request))); + } + } + for implementation in implementation_names { + if lowercase_value == implementation { + return Ok(Some(Self::Implementation( + // e.g. `pypy` + // Safety: The name matched the possible names above + ImplementationName::from_str(implementation).unwrap(), + ))); + } + if let Some(version_request) = + Self::try_split_prefix_and_version(implementation, lowercase_value)? + { + // e.g. `pypy39` + return Ok(Some(Self::ImplementationVersion( + // Safety: The name matched the possible names above + ImplementationName::from_str(implementation).unwrap(), + version_request, + ))); + } + } + Ok(None) + } + + /// Take a value like `"python3.11"`, check whether it matches a target prefix (e.g. + /// `"python"`, `"pypy"`, or even `""`), and if so try to parse its version. + /// + /// Failing to match the prefix (e.g. `"notpython3.11"`) or failing to parse a version (e.g. + /// `"python3notaversion"`) is not an error, and those cases return `Ok(None)`. The `@` + /// separator is optional, and this function can only return `Err` if `@` is used. There are + /// two error cases: + /// + /// - The value starts with `@` (e.g. `@3.11`). + /// - The prefix is a match, but the version is invalid (e.g. `python@3.not.a.version`). + fn try_split_prefix_and_version( + prefix: &str, + lowercase_value: &str, + ) -> Result, Error> { + if lowercase_value.starts_with('@') { + return Err(Error::InvalidVersionRequest(lowercase_value.to_string())); + } + let Some(rest) = lowercase_value.strip_prefix(prefix) else { + return Ok(None); + }; + // Just the prefix by itself (e.g. "python") is handled elsewhere. + if rest.is_empty() { + return Ok(None); + } + // The @ separator is optional. If it's present, the right half must be a version, and + // parsing errors are raised to the caller. + if let Some(after_at) = rest.strip_prefix('@') { + if after_at == "latest" { + // Handle `@latest` as a special case. It's still an error for now, but we plan to + // support it. TODO(zanieb): Add `PythonRequest::Latest` + return Err(Error::LatestVersionRequest); + } + return after_at.parse().map(Some); + } + // The @ was not present, so if the version fails to parse just return Ok(None). For + // example, python3stuff. + Ok(rest.parse().ok()) + } + /// 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. @@ -2361,6 +2465,12 @@ impl FromStr for VersionRequest { type Err = Error; fn from_str(s: &str) -> Result { + // Stripping the 't' suffix produces awkward error messages if the user tries a version + // like "latest". HACK: If the version is all letters, don't even try to parse it further. + if s.chars().all(char::is_alphabetic) { + return Err(Error::InvalidVersionRequest(s.to_string())); + } + // Check if the version request is for a free-threaded Python version let (s, variant) = s .strip_suffix('t') @@ -3322,4 +3432,30 @@ mod tests { &["python3.13rc2", "python3.13", "python3", "python"], ); } + + #[test] + fn test_try_split_prefix_and_version() { + assert!(matches!( + PythonRequest::try_split_prefix_and_version("prefix", "prefix"), + Ok(None), + )); + assert!(matches!( + PythonRequest::try_split_prefix_and_version("prefix", "prefix3"), + Ok(Some(_)), + )); + assert!(matches!( + PythonRequest::try_split_prefix_and_version("prefix", "prefix@3"), + Ok(Some(_)), + )); + assert!(matches!( + PythonRequest::try_split_prefix_and_version("prefix", "prefix3notaversion"), + Ok(None), + )); + // Version parsing errors are only raised if @ is present. + assert!( + PythonRequest::try_split_prefix_and_version("prefix", "prefix@3notaversion").is_err() + ); + // @ is not allowed if the prefix is empty. + assert!(PythonRequest::try_split_prefix_and_version("", "@3").is_err()); + } } diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index bf05142f9..86b0d4bc6 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -99,7 +99,7 @@ pub(crate) async fn install( .allow_insecure_host(network_settings.allow_insecure_host.clone()); // Parse the input requirement. - let request = ToolRequest::parse(&package, from.as_deref()); + let request = ToolRequest::parse(&package, from.as_deref())?; // If the user passed, e.g., `ruff@latest`, refresh the cache. let cache = if request.is_latest() { @@ -109,9 +109,12 @@ pub(crate) async fn install( }; // Resolve the `--from` requirement. - let from = match &request.target { + let from = match &request { // Ex) `ruff` - Target::Unspecified(from) => { + ToolRequest::Package { + executable, + target: Target::Unspecified(from), + } => { let source = if editable { RequirementsSource::from_editable(from)? } else { @@ -122,7 +125,7 @@ pub(crate) async fn install( .requirements; // If the user provided an executable name, verify that it matches the `--from` requirement. - let executable = if let Some(executable) = request.executable { + let executable = if let Some(executable) = executable { let Ok(executable) = PackageName::from_str(executable) else { bail!( "Package requirement (`{from}`) provided with `--from` conflicts with install request (`{executable}`)", @@ -165,7 +168,10 @@ pub(crate) async fn install( requirement } // Ex) `ruff@0.6.0` - Target::Version(.., name, extras, version) => { + ToolRequest::Package { + target: Target::Version(.., name, extras, version), + .. + } => { if editable { bail!("`--editable` is only supported for local packages"); } @@ -186,7 +192,10 @@ pub(crate) async fn install( } } // Ex) `ruff@latest` - Target::Latest(.., name, extras) => { + ToolRequest::Package { + target: Target::Latest(.., name, extras), + .. + } => { if editable { bail!("`--editable` is only supported for local packages"); } @@ -204,16 +213,16 @@ pub(crate) async fn install( origin: None, } } + // Ex) `python` + ToolRequest::Python { .. } => { + return Err(anyhow::anyhow!( + "Cannot install Python with `{}`. Did you mean to use `{}`?", + "uv tool install".cyan(), + "uv python install".cyan(), + )); + } }; - if from.name.as_str().eq_ignore_ascii_case("python") { - return Err(anyhow::anyhow!( - "Cannot install Python with `{}`. Did you mean to use `{}`?", - "uv tool install".cyan(), - "uv python install".cyan(), - )); - } - // If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable. let settings = if request.is_latest() { ResolverInstallerSettings { diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index c690136e5..474f27d89 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -4,6 +4,7 @@ use tracing::debug; use uv_normalize::{ExtraName, PackageName}; use uv_pep440::Version; +use uv_python::PythonRequest; mod common; pub(crate) mod dir; @@ -16,44 +17,60 @@ pub(crate) mod upgrade; /// A request to run or install a tool (e.g., `uvx ruff@latest`). #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ToolRequest<'a> { - /// The executable name (e.g., `ruff`), if specified explicitly. - pub(crate) executable: Option<&'a str>, - /// The target to install or run (e.g., `ruff@latest` or `ruff==0.6.0`). - pub(crate) target: Target<'a>, +pub(crate) enum ToolRequest<'a> { + // Running the interpreter directly e.g. `uvx python` or `uvx pypy@3.8` + Python { + /// The executable name (e.g., `bash`), if the interpreter was given via --from. + executable: Option<&'a str>, + // The interpreter to install or run (e.g., `python@3.8` or `pypy311`. + request: PythonRequest, + }, + // Running a Python package + Package { + /// The executable name (e.g., `ruff`), if the target was given via --from. + executable: Option<&'a str>, + /// The target to install or run (e.g., `ruff@latest` or `ruff==0.6.0`). + target: Target<'a>, + }, } impl<'a> ToolRequest<'a> { /// Parse a tool request into an executable name and a target. - pub(crate) fn parse(command: &'a str, from: Option<&'a str>) -> Self { - if let Some(from) = from { - let target = Target::parse(from); - Self { - executable: Some(command), - target, - } - } else { - let target = Target::parse(command); - Self { - executable: None, - target, - } - } - } - - /// Returns whether the target package is Python. - pub(crate) fn is_python(&self) -> bool { - let name = match self.target { - Target::Unspecified(name) => name, - Target::Version(name, ..) => name, - Target::Latest(name, ..) => name, + pub(crate) fn parse(command: &'a str, from: Option<&'a str>) -> anyhow::Result { + // If --from is used, the command could be an arbitrary binary in the PATH (e.g. `bash`), + // and we don't try to parse it. + let (component_to_parse, executable) = match from { + Some(from) => (from, Some(command)), + None => (command, None), }; - name.eq_ignore_ascii_case("python") || cfg!(windows) && name.eq_ignore_ascii_case("pythonw") + + // First try parsing the command as a Python interpreter, like `python`, `python39`, or + // `pypy@39`. `pythonw` is also allowed on Windows. This overlaps with how `--python` flag + // values are parsed, but see `PythonRequest::parse` vs `PythonRequest::try_from_tool_name` + // for the differences. + if let Some(python_request) = PythonRequest::try_from_tool_name(component_to_parse)? { + Ok(Self::Python { + request: python_request, + executable, + }) + } else { + // Otherwise the command is a Python package, like `ruff` or `ruff@0.6.0`. + Ok(Self::Package { + target: Target::parse(component_to_parse), + executable, + }) + } } /// Returns `true` if the target is `latest`. pub(crate) fn is_latest(&self) -> bool { - matches!(self.target, Target::Latest(..)) + matches!( + self, + Self::Package { + target: Target::Latest(..), + .. + } + ) } } diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 42f9e99a5..4d270c445 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -29,7 +29,6 @@ use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; -use uv_python::VersionRequest; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, @@ -249,7 +248,7 @@ pub(crate) async fn run( } } - let request = ToolRequest::parse(target, from.as_deref()); + let request = ToolRequest::parse(target, from.as_deref())?; // If the user passed, e.g., `ruff@latest`, refresh the cache. let cache = if request.is_latest() { @@ -322,7 +321,7 @@ pub(crate) async fn run( // Check if the provided command is not part of the executables for the `from` package, // and if it's provided by another package in the environment. let provider_hints = match &from { - ToolRequirement::Python => None, + ToolRequirement::Python { .. } => None, ToolRequirement::Package { requirement, .. } => Some(ExecutableProviderHints::new( executable, requirement, @@ -637,7 +636,9 @@ impl std::fmt::Display for ExecutableProviderHints<'_> { #[derive(Debug)] #[allow(clippy::large_enum_variant)] pub(crate) enum ToolRequirement { - Python, + Python { + executable: String, + }, Package { executable: String, requirement: Requirement, @@ -647,7 +648,7 @@ pub(crate) enum ToolRequirement { impl ToolRequirement { fn executable(&self) -> &str { match self { - ToolRequirement::Python => "python", + ToolRequirement::Python { executable, .. } => executable, ToolRequirement::Package { executable, .. } => executable, } } @@ -656,7 +657,7 @@ impl ToolRequirement { impl std::fmt::Display for ToolRequirement { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ToolRequirement::Python => write!(f, "python"), + ToolRequirement::Python { .. } => write!(f, "python"), ToolRequirement::Package { requirement, .. } => write!(f, "{requirement}"), } } @@ -695,36 +696,43 @@ async fn get_or_create_environment( let reporter = PythonDownloadReporter::single(printer); - // Check if the target is `python` - let python_request = if request.is_python() { - let target_request = match &request.target { - Target::Unspecified(_) => None, - Target::Version(_, _, _, version) => Some(PythonRequest::Version( - VersionRequest::from_str(&version.to_string()).map_err(anyhow::Error::from)?, - )), - // TODO(zanieb): Add `PythonRequest::Latest` - Target::Latest(_, _, _) => { - return Err(anyhow::anyhow!( - "Requesting the 'latest' Python version is not yet supported" - ) - .into()); - } - }; + // Figure out what Python we're targeting, either explicitly like `uvx python@3`, or via the + // -p/--python flag. + let python_request = match request { + ToolRequest::Python { + request: tool_python_request, + .. + } => { + match python { + None => Some(tool_python_request.clone()), - if let Some(target_request) = &target_request { - if let Some(python) = python { - return Err(anyhow::anyhow!( - "Received multiple Python version requests: `{}` and `{}`", - python.to_string().cyan(), - target_request.to_canonical_string().cyan(), - ) - .into()); + // The user is both invoking a python interpreter directly and also supplying the + // -p/--python flag. Cases like `uvx -p pypy python` are allowed, for two reasons: + // 1) Previously this was the only way to invoke e.g. PyPy via `uvx`, and it's nice + // to remain compatible with that. 2) A script might define an alias like `uvx + // --python $MY_PYTHON ...`, and it's nice to be able to run the interpreter + // directly while sticking to that alias. + // + // However, we want to error out if we see conflicting or redundant versions like + // `uvx -p python38 python39`. + // + // Note that a command like `uvx default` doesn't bring us here. ToolRequest::parse + // returns ToolRequest::Package rather than ToolRequest::Python in that case. See + // PythonRequest::try_from_tool_name. + Some(python_flag) => { + if tool_python_request != &PythonRequest::Default { + return Err(anyhow::anyhow!( + "Received multiple Python version requests: `{}` and `{}`", + python_flag.to_string().cyan(), + tool_python_request.to_canonical_string().cyan() + ) + .into()); + } + Some(PythonRequest::parse(python_flag)) + } } } - - target_request.or_else(|| python.map(PythonRequest::parse)) - } else { - python.map(PythonRequest::parse) + ToolRequest::Package { .. } => python.map(PythonRequest::parse), }; // Discover an interpreter. @@ -747,117 +755,112 @@ async fn get_or_create_environment( let state = PlatformState::default(); let workspace_cache = WorkspaceCache::default(); - let from = if request.is_python() { - ToolRequirement::Python - } else { - let (executable, requirement) = match &request.target { - // Ex) `ruff>=0.6.0` - Target::Unspecified(requirement) => { - let spec = RequirementsSpecification::parse_package(requirement)?; + let from = match request { + ToolRequest::Python { + executable: request_executable, + .. + } => ToolRequirement::Python { + executable: request_executable.unwrap_or("python").to_string(), + }, + ToolRequest::Package { + executable: request_executable, + target, + } => { + let (executable, requirement) = match target { + // Ex) `ruff>=0.6.0` + Target::Unspecified(requirement) => { + let spec = RequirementsSpecification::parse_package(requirement)?; - // Extract the verbatim executable name, if possible. - let name = match &spec.requirement { - UnresolvedRequirement::Named(..) => { - // Identify the package name from the PEP 508 specifier. - // - // For example, given `ruff>=0.6.0`, extract `ruff`, to use as the executable name. - let content = requirement.trim(); - let index = content - .find(|c| !matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.')) - .unwrap_or(content.len()); - Some(&content[..index]) - } - UnresolvedRequirement::Unnamed(..) => None, - }; + // Extract the verbatim executable name, if possible. + let name = match &spec.requirement { + UnresolvedRequirement::Named(..) => { + // Identify the package name from the PEP 508 specifier. + // + // For example, given `ruff>=0.6.0`, extract `ruff`, to use as the executable name. + let content = requirement.trim(); + let index = content + .find(|c| !matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.')) + .unwrap_or(content.len()); + Some(&content[..index]) + } + UnresolvedRequirement::Unnamed(..) => None, + }; - if let UnresolvedRequirement::Named(requirement) = &spec.requirement { - if requirement.name.as_str() == "python" { - return Err(anyhow::anyhow!( - "Using `{}` is not supported. Use `{}` instead.", - "--from python".cyan(), - "python@".cyan(), - ) - .into()); - } + let requirement = resolve_names( + vec![spec], + &interpreter, + settings, + network_settings, + &state, + concurrency, + cache, + &workspace_cache, + printer, + preview, + ) + .await? + .pop() + .unwrap(); + + // Prefer, in order: + // 1. The verbatim executable provided by the user, independent of the requirement (as in: `uvx --from package executable`). + // 2. The verbatim executable provided by the user as a named requirement (as in: `uvx change_wheel_version`). + // 3. The resolved package name (as in: `uvx git+https://github.com/pallets/flask`). + let executable = request_executable + .map(ToString::to_string) + .or_else(|| name.map(ToString::to_string)) + .unwrap_or_else(|| requirement.name.to_string()); + + (executable, requirement) } + // Ex) `ruff@0.6.0` + Target::Version(executable, name, extras, version) => { + let executable = request_executable + .map(ToString::to_string) + .unwrap_or_else(|| (*executable).to_string()); + let requirement = Requirement { + name: name.clone(), + extras: extras.clone(), + groups: Box::new([]), + marker: MarkerTree::default(), + source: RequirementSource::Registry { + specifier: VersionSpecifiers::from(VersionSpecifier::equals_version( + version.clone(), + )), + index: None, + conflict: None, + }, + origin: None, + }; - let requirement = resolve_names( - vec![spec], - &interpreter, - settings, - network_settings, - &state, - concurrency, - cache, - &workspace_cache, - printer, - preview, - ) - .await? - .pop() - .unwrap(); + (executable, requirement) + } + // Ex) `ruff@latest` + Target::Latest(executable, name, extras) => { + let executable = request_executable + .map(ToString::to_string) + .unwrap_or_else(|| (*executable).to_string()); + let requirement = Requirement { + name: name.clone(), + extras: extras.clone(), + groups: Box::new([]), + marker: MarkerTree::default(), + source: RequirementSource::Registry { + specifier: VersionSpecifiers::empty(), + index: None, + conflict: None, + }, + origin: None, + }; - // Prefer, in order: - // 1. The verbatim executable provided by the user, independent of the requirement (as in: `uvx --from package executable`). - // 2. The verbatim executable provided by the user as a named requirement (as in: `uvx change_wheel_version`). - // 3. The resolved package name (as in: `uvx git+https://github.com/pallets/flask`). - let executable = request - .executable - .map(ToString::to_string) - .or_else(|| name.map(ToString::to_string)) - .unwrap_or_else(|| requirement.name.to_string()); + (executable, requirement) + } + }; - (executable, requirement) + ToolRequirement::Package { + executable, + requirement, } - // Ex) `ruff@0.6.0` - Target::Version(executable, name, extras, version) => { - let executable = request - .executable - .map(ToString::to_string) - .unwrap_or_else(|| (*executable).to_string()); - let requirement = Requirement { - name: name.clone(), - extras: extras.clone(), - groups: Box::new([]), - marker: MarkerTree::default(), - source: RequirementSource::Registry { - specifier: VersionSpecifiers::from(VersionSpecifier::equals_version( - version.clone(), - )), - index: None, - conflict: None, - }, - origin: None, - }; - - (executable, requirement) - } - // Ex) `ruff@latest` - Target::Latest(executable, name, extras) => { - let executable = request - .executable - .map(ToString::to_string) - .unwrap_or_else(|| (*executable).to_string()); - let requirement = Requirement { - name: name.clone(), - extras: extras.clone(), - groups: Box::new([]), - marker: MarkerTree::default(), - source: RequirementSource::Registry { - specifier: VersionSpecifiers::empty(), - index: None, - conflict: None, - }, - origin: None, - }; - - (executable, requirement) - } - }; - - ToolRequirement::Package { - executable, - requirement, } }; @@ -875,7 +878,7 @@ async fn get_or_create_environment( let requirements = { let mut requirements = Vec::with_capacity(1 + with.len()); match &from { - ToolRequirement::Python => {} + ToolRequirement::Python { .. } => {} ToolRequirement::Package { requirement, .. } => requirements.push(requirement.clone()), } requirements.extend( diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index bf63b2add..a8bcd5a05 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -1889,6 +1889,73 @@ fn tool_run_python_at_version() { Audited in [TIME] "###); + // The @ is optional. + uv_snapshot!(context.filters(), context.tool_run() + .arg("python3.11") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + "###); + + // Dotless syntax also works. + uv_snapshot!(context.filters(), context.tool_run() + .arg("python311") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + "###); + + // Other implementations like PyPy also work. PyPy isn't currently in the test suite, so + // specify CPython and rely on the fact that they go through the same codepath. + uv_snapshot!(context.filters(), context.tool_run() + .arg("cpython311") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + "###); + + // But short names don't work in the executable position (as opposed to with -p/--python). We + // interpret those as package names. + uv_snapshot!(context.filters(), context.tool_run() + .arg("cp311") + .arg("--version"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving tool dependencies: + ╰─▶ Because cp311 was not found in the package registry and you require cp311, we can conclude that your requirements are unsatisfiable. + "); + + // Bare versions don't work either. Again we interpret them as package names. + uv_snapshot!(context.filters(), context.tool_run() + .arg("311") + .arg("--version"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving tool dependencies: + ╰─▶ Because 311 was not found in the package registry and you require 311, we can conclude that your requirements are unsatisfiable. + "); + // Request a version via `-p` uv_snapshot!(context.filters(), context.tool_run() .arg("-p") @@ -1904,6 +1971,35 @@ fn tool_run_python_at_version() { Resolved in [TIME] "###); + // @ syntax is also allowed here. + uv_snapshot!(context.filters(), context.tool_run() + .arg("-p") + .arg("python@311") + .arg("python") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + "###); + + // But @ with nothing in front of it is not. + uv_snapshot!(context.filters(), context.tool_run() + .arg("-p") + .arg("@311") + .arg("python") + .arg("--version"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for executable name `@311` in [PYTHON SOURCES] + "); + // Request a version in the tool and `-p` uv_snapshot!(context.filters(), context.tool_run() .arg("-p") @@ -1991,16 +2087,47 @@ fn tool_run_python_from() { uv_snapshot!(context.filters(), context.tool_run() .arg("--from") - .arg("python>=3.12") + .arg("python311") .arg("python") - .arg("--version"), @r###" - success: false - exit_code: 2 + .arg("--version"), @r" + success: true + exit_code: 0 ----- stdout ----- + Python 3.11.[X] ----- stderr ----- - error: Using `--from python` is not supported. Use `python@` instead. - "###); + Resolved in [TIME] + "); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("python>3.11,<3.13") + .arg("python") + .arg("--version"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + Resolved in [TIME] + "); + + // The executed command isn't necessarily Python, but Python is in the PATH. + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("python@3.11") + .arg("bash") + .arg("-c") + .arg("python --version"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + "); } #[test]