Skip to content
220 changes: 178 additions & 42 deletions crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,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),
Expand Down Expand Up @@ -1388,55 +1392,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. `[email protected]`
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() {
Expand All @@ -1447,7 +1432,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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive by: this is what was intended right?

#[cfg(windows)]
if value_as_path.extension().is_none() {
let value_as_path = value_as_path.with_extension(EXE_SUFFIX);
Expand Down Expand Up @@ -1490,6 +1475,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. `[email protected]`).
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.
Expand Down Expand Up @@ -2360,6 +2464,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')
Expand Down Expand Up @@ -3321,4 +3431,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());
}
}
37 changes: 23 additions & 14 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 {
Expand All @@ -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}`)",
Expand Down Expand Up @@ -165,7 +168,10 @@ pub(crate) async fn install(
requirement
}
// Ex) `[email protected]`
Target::Version(.., name, extras, version) => {
ToolRequest::Package {
target: Target::Version(.., name, extras, version),
..
} => {
if editable {
bail!("`--editable` is only supported for local packages");
}
Expand All @@ -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");
}
Expand All @@ -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 {
Expand Down
Loading
Loading