-
Notifications
You must be signed in to change notification settings - Fork 2.3k
allow running non-default Python interpreters directly via uvx #13583
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
d8657fc
allow running non-default Python interpreters directly via uvx
oconnor663 448534e
ban a leading `@`, e.g. `@3.11`
oconnor663 7b0a406
wording fix
oconnor663 d230004
formatting fix
oconnor663 db5b087
restore support for `uvx --from python[[@]version]`
oconnor663 3b3e784
improve doc comments
oconnor663 0c20511
use Unicode "alphabetic" instead of ASCII
oconnor663 3b9358e
avoid block-aligned comments
oconnor663 5ac5190
allow Python version ranges, e.g. `uvx 'python>3.8,<3.10'`
oconnor663 753329f
restore the prior @latest error message
oconnor663 45752f8
comment wording
oconnor663 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
|
|
@@ -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() { | ||
|
|
@@ -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 | ||
| #[cfg(windows)] | ||
| if value_as_path.extension().is_none() { | ||
| let value_as_path = value_as_path.with_extension(EXE_SUFFIX); | ||
|
|
@@ -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`. | ||
oconnor663 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// - 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); | ||
| } | ||
oconnor663 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // 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. | ||
|
|
@@ -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') | ||
|
|
@@ -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()); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) `[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"); | ||
| } | ||
|
|
@@ -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 { | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?