Skip to content

Commit

Permalink
Add support for uvx python
Browse files Browse the repository at this point in the history
  • Loading branch information
zanieb committed Jan 29, 2025
1 parent 9fc24b2 commit 94b059f
Show file tree
Hide file tree
Showing 7 changed files with 419 additions and 90 deletions.
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
4 changes: 4 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,10 @@ pub(crate) async fn install(
}
};

if from.name.as_str().eq_ignore_ascii_case("python") {
return Err(anyhow::anyhow!("Cannot install Python with `uv tool install`. Did you mean to use `uv python install`?"));
}

// 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
261 changes: 171 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 {
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,50 @@ 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: `{python}` and `{target_request}`"
)
.into());
}
}

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 +511,80 @@ async fn get_or_create_environment(
// Initialize any shared state.
let state = PlatformState::default();

// Resolve the `--from` requirement.
let from = 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,
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 `--from python<specifier>` is not supported. Use `python@<version>` instead."
)
.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 +599,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 +627,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

0 comments on commit 94b059f

Please sign in to comment.