diff --git a/Cargo.lock b/Cargo.lock index d846cc936011..7218f91fa67f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5040,7 +5040,6 @@ dependencies = [ "pathdiff", "pep440_rs", "pep508_rs", - "pypi-types", "serde", "thiserror", "toml", diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 1b284034d0a9..28ee52eed9c4 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -314,6 +314,13 @@ impl RequirementsSpecification { Ok(spec) } + /// Parse an individual package requirement. + pub fn parse_package(name: &str) -> Result { + let requirement = RequirementsTxtRequirement::parse(name, std::env::current_dir()?, false) + .with_context(|| format!("Failed to parse: `{name}`"))?; + Ok(UnresolvedRequirementSpecification::from(requirement)) + } + /// Read the requirements from a set of sources. pub async fn from_simple_sources( requirements: &[RequirementsSource], diff --git a/crates/uv-tool/Cargo.toml b/crates/uv-tool/Cargo.toml index 33426caba2e8..2ed6b431a3db 100644 --- a/crates/uv-tool/Cargo.toml +++ b/crates/uv-tool/Cargo.toml @@ -16,7 +16,6 @@ workspace = true install-wheel-rs = { workspace = true } pep440_rs = { workspace = true } pep508_rs = { workspace = true } -pypi-types = { workspace = true } uv-cache = { workspace = true } uv-fs = { workspace = true } uv-state = { workspace = true } diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index 9e2cc58e3107..a24b94572972 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use path_slash::PathBufExt; -use pypi_types::VerbatimParsedUrl; use serde::Deserialize; use toml_edit::value; use toml_edit::Array; @@ -14,11 +13,11 @@ use toml_edit::Value; #[serde(rename_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct Tool { - // The requirements requested by the user during installation. - requirements: Vec>, + /// The requirements requested by the user during installation. + requirements: Vec, /// The Python requested by the user during installation. python: Option, - // A mapping of entry point names to their metadata. + /// A mapping of entry point names to their metadata. entrypoints: Vec, } @@ -59,7 +58,7 @@ fn each_element_on_its_line_array(elements: impl Iterator>, + requirements: Vec, python: Option, entrypoints: impl Iterator, ) -> Self { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 466a4936d935..a2cca0b8d901 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -4,17 +4,18 @@ use itertools::Itertools; use owo_colors::OwoColorize; use tracing::debug; -use distribution_types::Resolution; +use distribution_types::{Resolution, UnresolvedRequirementSpecification}; use pep440_rs::Version; +use pypi_types::Requirement; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode, SetupPyStrategy}; use uv_dispatch::BuildDispatch; -use uv_distribution::Workspace; +use uv_distribution::{DistributionDatabase, Workspace}; use uv_fs::Simplified; use uv_git::GitResolver; use uv_installer::{SatisfiesResult, SitePackages}; -use uv_requirements::RequirementsSpecification; +use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_resolver::{FlatIndex, InMemoryIndex, OptionsBuilder, PythonRequirement, RequiresPython}; use uv_toolchain::{ request_from_version_file, EnvironmentPreference, Interpreter, PythonEnvironment, Toolchain, @@ -23,6 +24,7 @@ use uv_toolchain::{ use uv_types::{BuildIsolation, HashStrategy, InFlight}; use crate::commands::pip; +use crate::commands::reporters::ResolverReporter; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -282,6 +284,89 @@ pub(crate) struct SharedState { index: InMemoryIndex, } +/// Resolve any [`UnresolvedRequirementSpecification`] into a fully-qualified [`Requirement`]. +pub(crate) async fn resolve_names( + requirements: Vec, + interpreter: &Interpreter, + settings: &ResolverInstallerSettings, + state: &SharedState, + preview: PreviewMode, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + cache: &Cache, + printer: Printer, +) -> anyhow::Result> { + // Extract the project settings. + let ResolverInstallerSettings { + index_locations, + index_strategy, + keyring_provider, + resolution: _, + prerelease: _, + config_setting, + exclude_newer, + link_mode, + compile_bytecode: _, + upgrade: _, + reinstall: _, + build_options, + } = settings; + + // Initialize the registry client. + let client = RegistryClientBuilder::new(cache.clone()) + .native_tls(native_tls) + .connectivity(connectivity) + .index_urls(index_locations.index_urls()) + .index_strategy(*index_strategy) + .keyring(*keyring_provider) + .markers(interpreter.markers()) + .platform(interpreter.platform()) + .build(); + + // Initialize any shared state. + let in_flight = InFlight::default(); + + // TODO(charlie): These are all default values. We should consider whether we want to make them + // optional on the downstream APIs. + let build_isolation = BuildIsolation::default(); + let hasher = HashStrategy::default(); + let setup_py = SetupPyStrategy::default(); + let flat_index = FlatIndex::default(); + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + cache, + interpreter, + index_locations, + &flat_index, + &state.index, + &state.git, + &in_flight, + *index_strategy, + setup_py, + config_setting, + build_isolation, + *link_mode, + build_options, + *exclude_newer, + concurrency, + preview, + ); + + // Initialize the resolver. + let resolver = NamedRequirementsResolver::new( + requirements, + &hasher, + &state.index, + DistributionDatabase::new(&client, &build_dispatch, concurrency.downloads, preview), + ) + .with_reporter(ResolverReporter::from(printer)); + + Ok(resolver.resolve().await?) +} + /// Update a [`PythonEnvironment`] to satisfy a set of [`RequirementsSource`]s. pub(crate) async fn update_environment( venv: PythonEnvironment, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index d19da2656ddc..43c992c0e6c3 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -28,12 +28,12 @@ use crate::settings::ResolverInstallerSettings; /// Run a command. pub(crate) async fn run( - extras: ExtrasSpecification, - dev: bool, command: ExternalCommand, requirements: Vec, - python: Option, package: Option, + extras: ExtrasSpecification, + dev: bool, + python: Option, settings: ResolverInstallerSettings, isolated: bool, preview: PreviewMode, diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 52f197e42ad0..3ba7b1cb5ba6 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -4,11 +4,11 @@ use std::fmt::Write; use std::str::FromStr; use anyhow::{bail, Context, Result}; -use distribution_types::Name; use itertools::Itertools; - -use pypi_types::VerbatimParsedUrl; use tracing::debug; + +use distribution_types::Name; +use pypi_types::Requirement; use uv_cache::Cache; use uv_client::Connectivity; use uv_configuration::{Concurrency, PreviewMode, Reinstall}; @@ -16,13 +16,16 @@ use uv_configuration::{Concurrency, PreviewMode, Reinstall}; use uv_fs::replace_symlink; use uv_fs::Simplified; use uv_installer::SitePackages; +use uv_normalize::PackageName; use uv_requirements::RequirementsSpecification; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; -use uv_toolchain::{EnvironmentPreference, Toolchain, ToolchainPreference, ToolchainRequest}; +use uv_toolchain::{ + EnvironmentPreference, Interpreter, Toolchain, ToolchainPreference, ToolchainRequest, +}; use uv_warnings::warn_user_once; use crate::commands::project::{update_environment, SharedState}; -use crate::commands::ExitStatus; +use crate::commands::{project, ExitStatus}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -46,29 +49,71 @@ pub(crate) async fn install( warn_user_once!("`uv tool install` is experimental and may change without warning."); } + let interpreter = Toolchain::find( + &python + .as_deref() + .map(ToolchainRequest::parse) + .unwrap_or_default(), + EnvironmentPreference::OnlySystem, + toolchain_preference, + cache, + )? + .into_interpreter(); + + // Initialize any shared state. + let state = SharedState::default(); + + // Resolve the `from` requirement. let from = if let Some(from) = from { - let from_requirement = pep508_rs::Requirement::::from_str(&from)?; - // Check if the user provided more than just a name positionally or if that name conflicts with `--from` - if from_requirement.name.to_string() != package { - // Determine if its an entirely different package or a conflicting specification - let package_requirement = - pep508_rs::Requirement::::from_str(&package)?; - if from_requirement.name == package_requirement.name { - bail!( - "Package requirement `{}` provided with `--from` conflicts with install request `{}`", - from, - package - ); - } + // Parse the positional name. If the user provided more than a package name, it's an error + // (e.g., `uv install foo==1.0 --from foo`). + let Ok(package) = PackageName::from_str(&package) else { + bail!("Package requirement `{from}` provided with `--from` conflicts with install request `{package}`") + }; + + let from_requirement = resolve_requirements( + std::iter::once(from.as_str()), + &interpreter, + &settings, + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await? + .pop() + .unwrap(); + + // Check if the positional name conflicts with `--from`. + if from_requirement.name != package { + // Determine if it's an entirely different package (e.g., `uv install foo --from bar`). bail!( "Package name `{}` provided with `--from` does not match install request `{}`", from_requirement.name, package ); } + from_requirement } else { - pep508_rs::Requirement::::from_str(&package)? + resolve_requirements( + std::iter::once(package.as_str()), + &interpreter, + &settings, + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await? + .pop() + .unwrap() }; let name = from.name.to_string(); @@ -100,37 +145,28 @@ pub(crate) async fn install( false }; - let requirements = [Ok(from.clone())] - .into_iter() - .chain( - with.iter() - .map(|name| pep508_rs::Requirement::from_str(name)), - ) - .collect::>, _>>()?; - - let spec = RequirementsSpecification::from_requirements( + // Combine the `from` and `with` requirements. + let requirements = { + let mut requirements = Vec::with_capacity(1 + with.len()); + requirements.push(from.clone()); + requirements.extend( + resolve_requirements( + with.iter().map(String::as_str), + &interpreter, + &settings, + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await?, + ); requirements - .iter() - .cloned() - .map(pypi_types::Requirement::from) - .collect(), - ); - - let Some(from) = requirements.first().cloned() else { - bail!("Expected at least one requirement") }; - let interpreter = Toolchain::find( - &python - .as_deref() - .map(ToolchainRequest::parse) - .unwrap_or_default(), - EnvironmentPreference::OnlySystem, - toolchain_preference, - cache, - )? - .into_interpreter(); - // TODO(zanieb): Build the environment in the cache directory then copy into the tool directory // This lets us confirm the environment is valid before removing an existing install let environment = installed_tools.environment( @@ -142,6 +178,7 @@ pub(crate) async fn install( )?; // Install the ephemeral requirements. + let spec = RequirementsSpecification::from_requirements(requirements.clone()); let environment = update_environment( environment, spec, @@ -260,7 +297,10 @@ pub(crate) async fn install( debug!("Adding receipt for tool `{name}`"); let installed_tools = installed_tools.init()?; let tool = Tool::new( - requirements, + requirements + .into_iter() + .map(pep508_rs::Requirement::from) + .collect(), python, target_entry_points .into_iter() @@ -270,3 +310,41 @@ pub(crate) async fn install( Ok(ExitStatus::Success) } + +/// Resolve any [`UnnamedRequirements`]. +pub(crate) async fn resolve_requirements( + requirements: impl Iterator, + interpreter: &Interpreter, + settings: &ResolverInstallerSettings, + state: &SharedState, + preview: PreviewMode, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + cache: &Cache, + printer: Printer, +) -> Result> { + // Parse the requirements. + let requirements = { + let mut parsed = vec![]; + for requirement in requirements { + parsed.push(RequirementsSpecification::parse_package(requirement)?); + } + parsed + }; + + // Resolve the parsed requirements. + project::resolve_names( + requirements, + interpreter, + settings, + state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await +} diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index cc58b162b398..881ecf7bfada 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -13,6 +13,7 @@ use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{Concurrency, PreviewMode}; +use uv_normalize::PackageName; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_toolchain::{ EnvironmentPreference, PythonEnvironment, Toolchain, ToolchainPreference, ToolchainRequest, @@ -27,9 +28,9 @@ use crate::settings::ResolverInstallerSettings; /// Run a command. pub(crate) async fn run( command: ExternalCommand, - python: Option, from: Option, with: Vec, + python: Option, settings: ResolverInstallerSettings, _isolated: bool, preview: PreviewMode, @@ -193,6 +194,12 @@ fn parse_target(target: &OsString) -> Result<(Cow, Cow)> { return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); } + // e.g. ignore `git+https://github.com/uv/uv.git@main` + if PackageName::from_str(name).is_err() { + debug!("Ignoring non-package name `{}` in command", name); + return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); + } + // e.g. `uv@0.1.0`, convert to `uv==0.1.0` if let Ok(version) = Version::from_str(version) { return Ok(( diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index f265057bfc88..847ab2fc09d8 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -649,12 +649,12 @@ async fn run() -> Result { .collect::>(); commands::run( - args.extras, - args.dev, args.command, requirements, - args.python, args.package, + args.extras, + args.dev, + args.python, args.settings, globals.isolated, globals.preview, @@ -789,9 +789,9 @@ async fn run() -> Result { commands::tool_run( args.command, - args.python, args.from, args.with, + args.python, args.settings, globals.isolated, globals.preview, diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 8989d28d25ff..77cf1bf9ee7d 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -883,3 +883,285 @@ fn tool_install_no_entrypoints() { error: No entry points found for tool `iniconfig` "###); } + +/// Test installing a tool with a bare URL requirement. +#[test] +fn tool_install_unnamed_package() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Prepared 6 packages in [TIME] + Installed 6 packages in [TIME] + + black==24.4.2 (from https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl) + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed: black, blackd + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("black") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + assert!(executable.exists()); + + // On Windows, we can't snapshot an executable file. + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import patched_main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(patched_main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black @ https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black, 24.4.2 (compiled: no) + Python (CPython) 3.12.[X] + + ----- stderr ----- + "###); +} + +/// Test installing a tool with a bare URL requirement using `--from`, where the URL and the package +/// name conflict. +#[test] +fn tool_install_unnamed_conflict() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--from") + .arg("https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + error: Package name `iniconfig` provided with `--from` does not match install request `black` + "###); +} + +/// Test installing a tool with a bare URL requirement using `--from`. +#[test] +fn tool_install_unnamed_from() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--from") + .arg("https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Prepared 6 packages in [TIME] + Installed 6 packages in [TIME] + + black==24.4.2 (from https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl) + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed: black, blackd + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("black") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + assert!(executable.exists()); + + // On Windows, we can't snapshot an executable file. + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import patched_main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(patched_main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black @ https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black, 24.4.2 (compiled: no) + Python (CPython) 3.12.[X] + + ----- stderr ----- + "###); +} + +/// Test installing a tool with a bare URL requirement using `--with`. +#[test] +fn tool_install_unnamed_with() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--with") + .arg("https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 7 packages in [TIME] + Prepared 7 packages in [TIME] + Installed 7 packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + iniconfig==2.0.0 (from https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl) + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed: black, blackd + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("black") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + assert!(executable.exists()); + + // On Windows, we can't snapshot an executable file. + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import patched_main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(patched_main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [ + "black", + "iniconfig @ https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + ] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black, 24.3.0 (compiled: yes) + Python (CPython) 3.12.[X] + + ----- stderr ----- + "###); +}