Skip to content
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

Add support for uvx python #11076

Merged
merged 2 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3702,6 +3702,9 @@ pub enum ToolCommand {
/// e.g., `uv tool run ruff@0.3.0`. If more complex version specification is desired or if the
/// command is provided by a different package, use `--from`.
///
/// `uvx` can be used to invoke Python, e.g., with `uvx python` or `uvx python@<version>`. A
/// Python interpreter will be started in an isolated virtual environment.
///
/// If the tool was previously installed, i.e., via `uv tool install`, the installed version
/// will be used unless a version is requested or the `--isolated` flag is used.
///
Expand Down
8 changes: 8 additions & 0 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ pub(crate) async fn install(
}
};

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 target.is_latest() {
ResolverInstallerSettings {
Expand Down
13 changes: 13 additions & 0 deletions crates/uv/src/commands/tool/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ impl<'a> Target<'a> {
}
}

/// Returns whether the target package is Python.
pub(crate) fn is_python(&self) -> bool {
let name = match self {
Self::Unspecified(name) => name,
Self::Version(name, _) => name,
Self::Latest(name) => name,
Self::FromVersion(_, name, _) => name,
Self::FromLatest(_, name) => name,
Self::From(_, name) => name,
};
name.eq_ignore_ascii_case("python") || cfg!(windows) && name.eq_ignore_ascii_case("pythonw")
}

/// Returns `true` if the target is `latest`.
fn is_latest(&self) -> bool {
matches!(self, Self::Latest(_) | Self::FromLatest(_, _))
Expand Down
265 changes: 175 additions & 90 deletions crates/uv/src/commands/tool/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ use uv_cache_info::Timestamp;
use uv_cli::ExternalCommand;
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{Concurrency, PreviewMode, TrustedHost};
use uv_distribution_types::UnresolvedRequirement;
use uv_distribution_types::{Name, UnresolvedRequirementSpecification};
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName;
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
use uv_pep508::MarkerTree;
use uv_pypi_types::{Requirement, RequirementSource};
use uv_python::VersionRequest;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest,
Expand Down Expand Up @@ -183,12 +185,17 @@ pub(crate) async fn run(

// We check if the provided command is not part of the executables for the `from` package.
// If the command is found in other packages, we warn the user about the correct package to use.
warn_executable_not_provided_by_package(
executable,
&from.name,
&site_packages,
invocation_source,
);
match &from {
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to do anything to ensure that python resolves to the "correct" Python? Is there any risk that the user does, like, uvx --python 3.7 python and we end up picking a Python other than the discovered interpreter?

Copy link
Member Author

Choose a reason for hiding this comment

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

I did not consider that; I will add a guard.

Copy link
Member Author

@zanieb zanieb Jan 30, 2025

Choose a reason for hiding this comment

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

Opted not to do this as it complicates the implementation and I'm pretty sure there can't be a problem in-practice. We do the same thing for actual tool executables.

ToolRequirement::Python => {}
ToolRequirement::Package(from) => {
warn_executable_not_provided_by_package(
executable,
&from.name,
&site_packages,
invocation_source,
);
}
}

let handle = match process.spawn() {
Ok(handle) => Ok(handle),
Expand Down Expand Up @@ -216,11 +223,15 @@ pub(crate) async fn run(
/// Returns an exit status if the caller should exit after hinting.
fn hint_on_not_found(
executable: &str,
from: &Requirement,
from: &ToolRequirement,
site_packages: &SitePackages,
invocation_source: ToolRunCommand,
printer: Printer,
) -> anyhow::Result<Option<ExitStatus>> {
let from = match from {
ToolRequirement::Python => return Ok(None),
ToolRequirement::Package(from) => from,
};
match get_entrypoints(&from.name, site_packages) {
Ok(entrypoints) => {
writeln!(
Expand Down Expand Up @@ -397,6 +408,23 @@ fn warn_executable_not_provided_by_package(
}
}

// Clippy isn't happy about the difference in size between these variants, but
// [`ToolRequirement::Package`] is the more common case and it seems annoying to box it.
#[allow(clippy::large_enum_variant)]
pub(crate) enum ToolRequirement {
Python,
Package(Requirement),
}

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::Package(requirement) => write!(f, "{requirement}"),
}
}
}

/// Get or create a [`PythonEnvironment`] in which to run the specified tools.
///
/// If the target tool is already installed in a compatible environment, returns that
Expand All @@ -420,15 +448,52 @@ async fn get_or_create_environment(
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<(Requirement, PythonEnvironment), ProjectError> {
) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> {
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls)
.allow_insecure_host(allow_insecure_host.to_vec());

let reporter = PythonDownloadReporter::single(printer);

let python_request = python.map(PythonRequest::parse);
// Check if the target is `python`
let python_request = if target.is_python() {
let target_request = match target {
Target::Unspecified(_) => None,
Target::Version(_, version) | Target::FromVersion(_, _, version) => {
Some(PythonRequest::Version(
VersionRequest::from_str(&version.to_string()).map_err(anyhow::Error::from)?,
))
}
// TODO(zanieb): Add `PythonRequest::Latest`
Target::Latest(_) | Target::FromLatest(_, _) => {
return Err(anyhow::anyhow!(
"Requesting the 'latest' Python version is not yet supported"
)
.into())
}
// From the definition of `is_python`, this can only be a bare `python`
Target::From(_, from) => {
debug_assert_eq!(*from, "python");
None
}
};

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());
Copy link
Member

Choose a reason for hiding this comment

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

This is like, uvx --python 3.7 python@3.7?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah. We could check for compatibility or merge them or whatever because uvx --python pypy python@3.7 is "fine" but I don't do anything complicated here.

}
}

target_request.or_else(|| python.map(PythonRequest::parse))
} else {
python.map(PythonRequest::parse)
};

// Discover an interpreter.
let interpreter = PythonInstallation::find_or_download(
Expand All @@ -448,66 +513,82 @@ async fn get_or_create_environment(
// Initialize any shared state.
let state = PlatformState::default();

// Resolve the `--from` requirement.
let from = match target {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is mostly just indented in the if else

// Ex) `ruff`
Target::Unspecified(name) => Requirement {
name: PackageName::from_str(name)?,
extras: vec![],
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
let from = if target.is_python() {
ToolRequirement::Python
} else {
ToolRequirement::Package(match target {
// Ex) `ruff`
Target::Unspecified(name) => Requirement {
name: PackageName::from_str(name)?,
extras: vec![],
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
},
origin: None,
},
origin: None,
},
// Ex) `ruff@0.6.0`
Target::Version(name, version) | Target::FromVersion(_, name, version) => Requirement {
name: PackageName::from_str(name)?,
extras: vec![],
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
version.clone(),
)),
index: None,
conflict: None,
// Ex) `ruff@0.6.0`
Target::Version(name, version) | Target::FromVersion(_, name, version) => Requirement {
name: PackageName::from_str(name)?,
extras: vec![],
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
version.clone(),
)),
index: None,
conflict: None,
},
origin: None,
},
origin: None,
},
// Ex) `ruff@latest`
Target::Latest(name) | Target::FromLatest(_, name) => Requirement {
name: PackageName::from_str(name)?,
extras: vec![],
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
// Ex) `ruff@latest`
Target::Latest(name) | Target::FromLatest(_, name) => Requirement {
name: PackageName::from_str(name)?,
extras: vec![],
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
},
origin: None,
},
origin: None,
},
// Ex) `ruff>=0.6.0`
Target::From(_, from) => resolve_names(
vec![RequirementsSpecification::parse_package(from)?],
&interpreter,
settings,
&state,
connectivity,
concurrency,
native_tls,
allow_insecure_host,
cache,
printer,
preview,
)
.await?
.pop()
.unwrap(),
// Ex) `ruff>=0.6.0`
Target::From(_, from) => {
let spec = RequirementsSpecification::parse_package(from)?;
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());
}
}
resolve_names(
vec![spec],
&interpreter,
settings,
&state,
connectivity,
concurrency,
native_tls,
allow_insecure_host,
cache,
printer,
preview,
)
.await?
.pop()
.unwrap()
}
})
};

// Read the `--with` requirements.
Expand All @@ -522,7 +603,10 @@ async fn get_or_create_environment(
// Resolve the `--from` and `--with` requirements.
let requirements = {
let mut requirements = Vec::with_capacity(1 + with.len());
requirements.push(from.clone());
match &from {
ToolRequirement::Python => {}
ToolRequirement::Package(requirement) => requirements.push(requirement.clone()),
}
requirements.extend(
resolve_names(
spec.requirements.clone(),
Expand All @@ -547,35 +631,36 @@ async fn get_or_create_environment(
let installed_tools = InstalledTools::from_settings()?.init()?;
let _lock = installed_tools.lock().await?;

let existing_environment =
installed_tools
.get_environment(&from.name, cache)?
if let ToolRequirement::Package(requirement) = &from {
let existing_environment = installed_tools
.get_environment(&requirement.name, cache)?
.filter(|environment| {
python_request.as_ref().map_or(true, |python_request| {
python_request.satisfied(environment.interpreter(), cache)
})
});
if let Some(environment) = existing_environment {
// Check if the installed packages meet the requirements.
let site_packages = SitePackages::from_environment(&environment)?;
if let Some(environment) = existing_environment {
// Check if the installed packages meet the requirements.
let site_packages = SitePackages::from_environment(&environment)?;

let requirements = requirements
.iter()
.cloned()
.map(UnresolvedRequirementSpecification::from)
.collect::<Vec<_>>();
let constraints = [];

if matches!(
site_packages.satisfies(
&requirements,
&constraints,
&interpreter.resolver_marker_environment()
),
Ok(SatisfiesResult::Fresh { .. })
) {
debug!("Using existing tool `{}`", from.name);
return Ok((from, environment));
let requirements = requirements
.iter()
.cloned()
.map(UnresolvedRequirementSpecification::from)
.collect::<Vec<_>>();
let constraints = [];

if matches!(
site_packages.satisfies(
&requirements,
&constraints,
&interpreter.resolver_marker_environment()
),
Ok(SatisfiesResult::Fresh { .. })
) {
debug!("Using existing tool `{}`", requirement.name);
return Ok((from, environment));
}
}
}
}
Expand Down
Loading
Loading