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 <contact@zanie.dev>
Co-authored-by: konsti <konstin@mailbox.org>
This commit is contained in:
Jack O'Connor 2025-05-30 09:12:39 -07:00 committed by GitHub
parent 4cc5291c08
commit 7310ea75da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 521 additions and 229 deletions

View file

@ -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<Option<PythonRequest>, 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<Item = &'a str>,
// expected to be either long_names() or all names
implementation_names: impl IntoIterator<Item = &'a str>,
// the string to parse
lowercase_value: &str,
) -> Result<Option<PythonRequest>, 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<Option<VersionRequest>, 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<Self, Self::Err> {
// 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());
}
}

View file

@ -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 {

View file

@ -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<Self> {
// 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(..),
..
}
)
}
}

View file

@ -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<specifier>".cyan(),
"python@<version>".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(

View file

@ -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<specifier>` is not supported. Use `python@<version>` 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]