Skip to content

Commit 5d95a1e

Browse files
committed
Add support for uvx python
1 parent 9fc24b2 commit 5d95a1e

File tree

8 files changed

+421
-90
lines changed

8 files changed

+421
-90
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3702,6 +3702,9 @@ pub enum ToolCommand {
37023702
/// e.g., `uv tool run [email protected]`. If more complex version specification is desired or if the
37033703
/// command is provided by a different package, use `--from`.
37043704
///
3705+
/// `uvx` can be used to invoke Python, e.g., with `uvx python` or `uvx python@<version`. A
3706+
/// Python interpreter will be started in an isolated virtual environment.
3707+
///
37053708
/// If the tool was previously installed, i.e., via `uv tool install`, the installed version
37063709
/// will be used unless a version is requested or the `--isolated` flag is used.
37073710
///

crates/uv-tool/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pub enum Error {
5555
MissingToolPackage(PackageName),
5656
#[error(transparent)]
5757
Serialization(#[from] toml_edit::ser::Error),
58+
#[error("Target `python` cannot be used with `--from`")]
59+
PythonFrom,
5860
}
5961

6062
/// A collection of uv-managed tools installed on the current system.

crates/uv/src/commands/tool/install.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ pub(crate) async fn install(
221221
}
222222
};
223223

224+
if from.name.as_str().eq_ignore_ascii_case("python") {
225+
return Err(anyhow::anyhow!("Cannot install Python with `uv tool install`. Did you mean to use `uv python install`?"));
226+
}
227+
224228
// If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable.
225229
let settings = if target.is_latest() {
226230
ResolverInstallerSettings {

crates/uv/src/commands/tool/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ impl<'a> Target<'a> {
112112
}
113113
}
114114

115+
/// Returns whether the target package is Python.
116+
pub(crate) fn is_python(&self) -> bool {
117+
let name = match self {
118+
Self::Unspecified(name) => name,
119+
Self::Version(name, _) => name,
120+
Self::Latest(name) => name,
121+
Self::FromVersion(_, name, _) => name,
122+
Self::FromLatest(_, name) => name,
123+
Self::From(_, name) => name,
124+
};
125+
name.eq_ignore_ascii_case("python") || cfg!(windows) && name.eq_ignore_ascii_case("pythonw")
126+
}
127+
115128
/// Returns `true` if the target is `latest`.
116129
fn is_latest(&self) -> bool {
117130
matches!(self, Self::Latest(_) | Self::FromLatest(_, _))

crates/uv/src/commands/tool/run.rs

Lines changed: 171 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ use uv_cache_info::Timestamp;
1515
use uv_cli::ExternalCommand;
1616
use uv_client::{BaseClientBuilder, Connectivity};
1717
use uv_configuration::{Concurrency, PreviewMode, TrustedHost};
18+
use uv_distribution_types::UnresolvedRequirement;
1819
use uv_distribution_types::{Name, UnresolvedRequirementSpecification};
1920
use uv_installer::{SatisfiesResult, SitePackages};
2021
use uv_normalize::PackageName;
2122
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
2223
use uv_pep508::MarkerTree;
2324
use uv_pypi_types::{Requirement, RequirementSource};
25+
use uv_python::VersionRequest;
2426
use uv_python::{
2527
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
2628
PythonPreference, PythonRequest,
@@ -183,12 +185,17 @@ pub(crate) async fn run(
183185

184186
// We check if the provided command is not part of the executables for the `from` package.
185187
// If the command is found in other packages, we warn the user about the correct package to use.
186-
warn_executable_not_provided_by_package(
187-
executable,
188-
&from.name,
189-
&site_packages,
190-
invocation_source,
191-
);
188+
match &from {
189+
ToolRequirement::Python => {}
190+
ToolRequirement::Package(from) => {
191+
warn_executable_not_provided_by_package(
192+
executable,
193+
&from.name,
194+
&site_packages,
195+
invocation_source,
196+
);
197+
}
198+
}
192199

193200
let handle = match process.spawn() {
194201
Ok(handle) => Ok(handle),
@@ -216,11 +223,15 @@ pub(crate) async fn run(
216223
/// Returns an exit status if the caller should exit after hinting.
217224
fn hint_on_not_found(
218225
executable: &str,
219-
from: &Requirement,
226+
from: &ToolRequirement,
220227
site_packages: &SitePackages,
221228
invocation_source: ToolRunCommand,
222229
printer: Printer,
223230
) -> anyhow::Result<Option<ExitStatus>> {
231+
let from = match from {
232+
ToolRequirement::Python => return Ok(None),
233+
ToolRequirement::Package(from) => from,
234+
};
224235
match get_entrypoints(&from.name, site_packages) {
225236
Ok(entrypoints) => {
226237
writeln!(
@@ -397,6 +408,23 @@ fn warn_executable_not_provided_by_package(
397408
}
398409
}
399410

411+
// Clippy isn't happy about the difference in size between these variants, but
412+
// [`ToolRequirement::Package`] is the more common case and it seems annoying to box it.
413+
#[allow(clippy::large_enum_variant)]
414+
pub(crate) enum ToolRequirement {
415+
Python,
416+
Package(Requirement),
417+
}
418+
419+
impl std::fmt::Display for ToolRequirement {
420+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421+
match self {
422+
ToolRequirement::Python => write!(f, "python"),
423+
ToolRequirement::Package(requirement) => write!(f, "{requirement}"),
424+
}
425+
}
426+
}
427+
400428
/// Get or create a [`PythonEnvironment`] in which to run the specified tools.
401429
///
402430
/// If the target tool is already installed in a compatible environment, returns that
@@ -420,15 +448,50 @@ async fn get_or_create_environment(
420448
cache: &Cache,
421449
printer: Printer,
422450
preview: PreviewMode,
423-
) -> Result<(Requirement, PythonEnvironment), ProjectError> {
451+
) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> {
424452
let client_builder = BaseClientBuilder::new()
425453
.connectivity(connectivity)
426454
.native_tls(native_tls)
427455
.allow_insecure_host(allow_insecure_host.to_vec());
428456

429457
let reporter = PythonDownloadReporter::single(printer);
430458

431-
let python_request = python.map(PythonRequest::parse);
459+
// Check if the target is `python`
460+
let python_request = if target.is_python() {
461+
let target_request = match target {
462+
Target::Unspecified(_) => None,
463+
Target::Version(_, version) | Target::FromVersion(_, _, version) => {
464+
Some(PythonRequest::Version(
465+
VersionRequest::from_str(&version.to_string()).map_err(anyhow::Error::from)?,
466+
))
467+
}
468+
// TODO(zanieb): Add `PythonRequest::Latest`
469+
Target::Latest(_) | Target::FromLatest(_, _) => {
470+
return Err(anyhow::anyhow!(
471+
"Requesting the 'latest' Python version is not yet supported"
472+
)
473+
.into())
474+
}
475+
// From the definition of `is_python`, this can only be a bare `python`
476+
Target::From(_, from) => {
477+
debug_assert_eq!(*from, "python");
478+
None
479+
}
480+
};
481+
482+
if let Some(target_request) = &target_request {
483+
if let Some(python) = python {
484+
return Err(anyhow::anyhow!(
485+
"Received multiple Python version requests: `{python}` and `{target_request}`"
486+
)
487+
.into());
488+
}
489+
}
490+
491+
target_request.or_else(|| python.map(PythonRequest::parse))
492+
} else {
493+
python.map(PythonRequest::parse)
494+
};
432495

433496
// Discover an interpreter.
434497
let interpreter = PythonInstallation::find_or_download(
@@ -448,66 +511,80 @@ async fn get_or_create_environment(
448511
// Initialize any shared state.
449512
let state = PlatformState::default();
450513

451-
// Resolve the `--from` requirement.
452-
let from = match target {
453-
// Ex) `ruff`
454-
Target::Unspecified(name) => Requirement {
455-
name: PackageName::from_str(name)?,
456-
extras: vec![],
457-
groups: vec![],
458-
marker: MarkerTree::default(),
459-
source: RequirementSource::Registry {
460-
specifier: VersionSpecifiers::empty(),
461-
index: None,
462-
conflict: None,
514+
let from = if target.is_python() {
515+
ToolRequirement::Python
516+
} else {
517+
ToolRequirement::Package(match target {
518+
// Ex) `ruff`
519+
Target::Unspecified(name) => Requirement {
520+
name: PackageName::from_str(name)?,
521+
extras: vec![],
522+
groups: vec![],
523+
marker: MarkerTree::default(),
524+
source: RequirementSource::Registry {
525+
specifier: VersionSpecifiers::empty(),
526+
index: None,
527+
conflict: None,
528+
},
529+
origin: None,
463530
},
464-
origin: None,
465-
},
466-
467-
Target::Version(name, version) | Target::FromVersion(_, name, version) => Requirement {
468-
name: PackageName::from_str(name)?,
469-
extras: vec![],
470-
groups: vec![],
471-
marker: MarkerTree::default(),
472-
source: RequirementSource::Registry {
473-
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
474-
version.clone(),
475-
)),
476-
index: None,
477-
conflict: None,
531+
532+
Target::Version(name, version) | Target::FromVersion(_, name, version) => Requirement {
533+
name: PackageName::from_str(name)?,
534+
extras: vec![],
535+
groups: vec![],
536+
marker: MarkerTree::default(),
537+
source: RequirementSource::Registry {
538+
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
539+
version.clone(),
540+
)),
541+
index: None,
542+
conflict: None,
543+
},
544+
origin: None,
478545
},
479-
origin: None,
480-
},
481-
// Ex) `ruff@latest`
482-
Target::Latest(name) | Target::FromLatest(_, name) => Requirement {
483-
name: PackageName::from_str(name)?,
484-
extras: vec![],
485-
groups: vec![],
486-
marker: MarkerTree::default(),
487-
source: RequirementSource::Registry {
488-
specifier: VersionSpecifiers::empty(),
489-
index: None,
490-
conflict: None,
546+
// Ex) `ruff@latest`
547+
Target::Latest(name) | Target::FromLatest(_, name) => Requirement {
548+
name: PackageName::from_str(name)?,
549+
extras: vec![],
550+
groups: vec![],
551+
marker: MarkerTree::default(),
552+
source: RequirementSource::Registry {
553+
specifier: VersionSpecifiers::empty(),
554+
index: None,
555+
conflict: None,
556+
},
557+
origin: None,
491558
},
492-
origin: None,
493-
},
494-
// Ex) `ruff>=0.6.0`
495-
Target::From(_, from) => resolve_names(
496-
vec![RequirementsSpecification::parse_package(from)?],
497-
&interpreter,
498-
settings,
499-
&state,
500-
connectivity,
501-
concurrency,
502-
native_tls,
503-
allow_insecure_host,
504-
cache,
505-
printer,
506-
preview,
507-
)
508-
.await?
509-
.pop()
510-
.unwrap(),
559+
// Ex) `ruff>=0.6.0`
560+
Target::From(_, from) => {
561+
let spec = RequirementsSpecification::parse_package(from)?;
562+
if let UnresolvedRequirement::Named(requirement) = &spec.requirement {
563+
if requirement.name.as_str() == "python" {
564+
return Err(anyhow::anyhow!(
565+
"Using `--from python<specifier>` is not supported. Use `python@<version>` instead."
566+
)
567+
.into());
568+
}
569+
}
570+
resolve_names(
571+
vec![spec],
572+
&interpreter,
573+
settings,
574+
&state,
575+
connectivity,
576+
concurrency,
577+
native_tls,
578+
allow_insecure_host,
579+
cache,
580+
printer,
581+
preview,
582+
)
583+
.await?
584+
.pop()
585+
.unwrap()
586+
}
587+
})
511588
};
512589

513590
// Read the `--with` requirements.
@@ -522,7 +599,10 @@ async fn get_or_create_environment(
522599
// Resolve the `--from` and `--with` requirements.
523600
let requirements = {
524601
let mut requirements = Vec::with_capacity(1 + with.len());
525-
requirements.push(from.clone());
602+
match &from {
603+
ToolRequirement::Python => {}
604+
ToolRequirement::Package(requirement) => requirements.push(requirement.clone()),
605+
}
526606
requirements.extend(
527607
resolve_names(
528608
spec.requirements.clone(),
@@ -547,35 +627,36 @@ async fn get_or_create_environment(
547627
let installed_tools = InstalledTools::from_settings()?.init()?;
548628
let _lock = installed_tools.lock().await?;
549629

550-
let existing_environment =
551-
installed_tools
552-
.get_environment(&from.name, cache)?
630+
if let ToolRequirement::Package(requirement) = &from {
631+
let existing_environment = installed_tools
632+
.get_environment(&requirement.name, cache)?
553633
.filter(|environment| {
554634
python_request.as_ref().map_or(true, |python_request| {
555635
python_request.satisfied(environment.interpreter(), cache)
556636
})
557637
});
558-
if let Some(environment) = existing_environment {
559-
// Check if the installed packages meet the requirements.
560-
let site_packages = SitePackages::from_environment(&environment)?;
638+
if let Some(environment) = existing_environment {
639+
// Check if the installed packages meet the requirements.
640+
let site_packages = SitePackages::from_environment(&environment)?;
561641

562-
let requirements = requirements
563-
.iter()
564-
.cloned()
565-
.map(UnresolvedRequirementSpecification::from)
566-
.collect::<Vec<_>>();
567-
let constraints = [];
568-
569-
if matches!(
570-
site_packages.satisfies(
571-
&requirements,
572-
&constraints,
573-
&interpreter.resolver_marker_environment()
574-
),
575-
Ok(SatisfiesResult::Fresh { .. })
576-
) {
577-
debug!("Using existing tool `{}`", from.name);
578-
return Ok((from, environment));
642+
let requirements = requirements
643+
.iter()
644+
.cloned()
645+
.map(UnresolvedRequirementSpecification::from)
646+
.collect::<Vec<_>>();
647+
let constraints = [];
648+
649+
if matches!(
650+
site_packages.satisfies(
651+
&requirements,
652+
&constraints,
653+
&interpreter.resolver_marker_environment()
654+
),
655+
Ok(SatisfiesResult::Fresh { .. })
656+
) {
657+
debug!("Using existing tool `{}`", requirement.name);
658+
return Ok((from, environment));
659+
}
579660
}
580661
}
581662
}

0 commit comments

Comments
 (0)