mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35:00 +00:00
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:
parent
4cc5291c08
commit
7310ea75da
5 changed files with 521 additions and 229 deletions
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(..),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue