diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index f0aaee9de499..873526472003 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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@`. 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. /// diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index cd66349a308b..9f23cdc2e175 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -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 { diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index ee07609dcf36..951e038b409c 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -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(_, _)) diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 690925984d1a..4fd7c921a536 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -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, @@ -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), @@ -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> { + let from = match from { + ToolRequirement::Python => return Ok(None), + ToolRequirement::Package(from) => from, + }; match get_entrypoints(&from.name, site_packages) { Ok(entrypoints) => { writeln!( @@ -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 @@ -420,7 +448,7 @@ 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) @@ -428,7 +456,44 @@ async fn get_or_create_environment( 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()); + } + } + + target_request.or_else(|| python.map(PythonRequest::parse)) + } else { + python.map(PythonRequest::parse) + }; // Discover an interpreter. let interpreter = PythonInstallation::find_or_download( @@ -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 { - // 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".cyan(), + "python@".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. @@ -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(), @@ -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::>(); - 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::>(); + 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)); + } } } } diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 822c4699f650..7f76312c32d3 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -3321,3 +3321,41 @@ fn tool_install_overrides() -> Result<()> { Ok(()) } + +/// `uv tool install python` is not allowed +#[test] +fn tool_install_python() { + let context = TestContext::new("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `python` + uv_snapshot!(context.filters(), context.tool_install() + .arg("python") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Cannot install Python with `uv tool install`. Did you mean to use `uv python install`? + "###); + + // Install `python@` + uv_snapshot!(context.filters(), context.tool_install() + .arg("python@3.12") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Cannot install Python with `uv tool install`. Did you mean to use `uv python install`? + "###); +} diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index 7c752901bc90..2867e64de5f7 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -1395,3 +1395,191 @@ fn tool_run_latest() { ----- stderr ----- "###); } + +#[test] +fn tool_run_python() { + let context = TestContext::new("3.12").with_filtered_counts(); + uv_snapshot!(context.filters(), context.tool_run() + .arg("python") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + Resolved in [TIME] + Audited in [TIME] + "###); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("python") + .arg("-c") + .arg("print('Hello, world!')"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello, world! + + ----- stderr ----- + Resolved in [TIME] + "###); +} + +#[test] +fn tool_run_python_at_version() { + let context = TestContext::new_with_versions(&["3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_python_sources(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("python") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + Resolved in [TIME] + Audited in [TIME] + "###); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("python@3.12") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + Resolved in [TIME] + "###); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("python@3.11") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + Audited in [TIME] + "###); + + // Request a version via `-p` + uv_snapshot!(context.filters(), context.tool_run() + .arg("-p") + .arg("3.11") + .arg("python") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + "###); + + // Request a version in the tool and `-p` + uv_snapshot!(context.filters(), context.tool_run() + .arg("-p") + .arg("3.12") + .arg("python@3.11") + .arg("--version"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Received multiple Python version requests: `3.12` and `3.11` + "###); + + // Request a version that does not exist + uv_snapshot!(context.filters(), context.tool_run() + .arg("python@3.12.99"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python 3.12.[X] in [PYTHON SOURCES] + "###); + + // Request an invalid version + uv_snapshot!(context.filters(), context.tool_run() + .arg("python@3.300"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Invalid version request: 3.300 + "###); + + // Request `@latest` (not yet supported) + uv_snapshot!(context.filters(), context.tool_run() + .arg("python@latest") + .arg("--version"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Requesting the 'latest' Python version is not yet supported + "###); +} + +#[test] +fn tool_run_python_from() { + let context = TestContext::new_with_versions(&["3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_python_sources(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("python") + .arg("python") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + Resolved in [TIME] + Audited in [TIME] + "###); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("python@3.11") + .arg("python") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Resolved in [TIME] + Audited in [TIME] + "###); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("python>=3.12") + .arg("python") + .arg("--version"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Using `--from python` is not supported. Use `python@` instead. + "###); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f9bc58597544..b1368141664a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3037,6 +3037,8 @@ By default, the package to install is assumed to match the command name. The name of the command can include an exact version in the format `@`, 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@`. 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. `uvx` is provided as a convenient alias for `uv tool run`, their behavior is identical.