From 76a3ceb2ca009c3864e730f18fa169de33cfe116 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 8 May 2024 10:51:51 -0400 Subject: [PATCH] Add basic `uv sync` and `uv lock` commands (#3436) ## Summary These aren't intended for production use; instead, I'm just trying to frame out the overall data flows and code-sharing for these commands. We now have `uv sync` (sync the environment to match the lockfile, without refreshing or resolving) and `uv lock` (generate the lockfile). Both _require_ a virtual environment to exist (something we should change). `uv sync`, `uv run`, and `uv lock` all share code for the underlying subroutines (resolution and installation), so the commands themselves are relatively small (~100 lines) and mostly consist of reading arguments and such. `uv lock` and `uv sync` don't actually really work yet, because we have no way to include the project itself in the lockfile (that's a TODO in the lockfile implementation). Closes https://github.com/astral-sh/uv/issues/3432. --- crates/uv-types/src/builds.rs | 3 +- crates/uv-types/src/hash.rs | 3 +- crates/uv/src/cli.rs | 55 ++ crates/uv/src/commands/mod.rs | 6 +- crates/uv/src/commands/run.rs | 736 ----------------------- crates/uv/src/commands/workspace/lock.rs | 139 +++++ crates/uv/src/commands/workspace/mod.rs | 401 ++++++++++++ crates/uv/src/commands/workspace/run.rs | 340 +++++++++++ crates/uv/src/commands/workspace/sync.rs | 100 +++ crates/uv/src/main.rs | 12 + crates/uv/src/settings.rs | 47 +- crates/uv/tests/pip_install.rs | 18 + crates/uv/tests/pip_sync.rs | 18 - 13 files changed, 1118 insertions(+), 760 deletions(-) delete mode 100644 crates/uv/src/commands/run.rs create mode 100644 crates/uv/src/commands/workspace/lock.rs create mode 100644 crates/uv/src/commands/workspace/mod.rs create mode 100644 crates/uv/src/commands/workspace/run.rs create mode 100644 crates/uv/src/commands/workspace/sync.rs diff --git a/crates/uv-types/src/builds.rs b/crates/uv-types/src/builds.rs index 9cc80f7e2..cf70e737f 100644 --- a/crates/uv-types/src/builds.rs +++ b/crates/uv-types/src/builds.rs @@ -1,8 +1,9 @@ use uv_interpreter::PythonEnvironment; /// Whether to enforce build isolation when building source distributions. -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Default, Copy, Clone)] pub enum BuildIsolation<'a> { + #[default] Isolated, Shared(&'a PythonEnvironment), } diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index ed1c10846..dc912c945 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -11,9 +11,10 @@ use pep508_rs::MarkerEnvironment; use pypi_types::{HashDigest, HashError}; use uv_normalize::PackageName; -#[derive(Debug, Clone)] +#[derive(Debug, Default, Clone)] pub enum HashStrategy { /// No hash policy is specified. + #[default] None, /// Hashes should be generated (specifically, a SHA-256 hash), but not validated. Generate, diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index b03778504..2304388f9 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -133,8 +133,15 @@ pub(crate) enum Commands { /// Clear the cache, removing all entries or those linked to specific packages. #[command(hide = true)] Clean(CleanArgs), + /// Run a command in the project environment. #[clap(hide = true)] Run(RunArgs), + /// Sync the project's dependencies with the environment. + #[clap(hide = true)] + Sync(SyncArgs), + /// Resolve the project requirements into a lockfile. + #[clap(hide = true)] + Lock(LockArgs), /// Display uv's version Version { #[arg(long, value_enum, default_value = "text")] @@ -1852,6 +1859,54 @@ pub(crate) struct RunArgs { pub(crate) python: Option, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct SyncArgs { + /// The Python interpreter to use to build the run environment. + /// + /// By default, `uv` uses the virtual environment in the current working directory or any parent + /// directory, falling back to searching for a Python executable in `PATH`. The `--python` + /// option allows you to specify a different interpreter. + /// + /// Supported formats: + /// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or + /// `python3.10` on Linux and macOS. + /// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`. + /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. + #[arg( + long, + short, + env = "UV_PYTHON", + verbatim_doc_comment, + group = "discovery" + )] + pub(crate) python: Option, +} + +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct LockArgs { + /// The Python interpreter to use to build the run environment. + /// + /// By default, `uv` uses the virtual environment in the current working directory or any parent + /// directory, falling back to searching for a Python executable in `PATH`. The `--python` + /// option allows you to specify a different interpreter. + /// + /// Supported formats: + /// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or + /// `python3.10` on Linux and macOS. + /// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`. + /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. + #[arg( + long, + short, + env = "UV_PYTHON", + verbatim_doc_comment, + group = "discovery" + )] + pub(crate) python: Option, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] struct AddArgs { diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 5b4a166d1..f5afaae38 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -16,7 +16,6 @@ pub(crate) use pip_list::pip_list; pub(crate) use pip_show::pip_show; pub(crate) use pip_sync::pip_sync; pub(crate) use pip_uninstall::pip_uninstall; -pub(crate) use run::run; #[cfg(feature = "self-update")] pub(crate) use self_update::self_update; use uv_cache::Cache; @@ -26,6 +25,9 @@ use uv_interpreter::PythonEnvironment; use uv_normalize::PackageName; pub(crate) use venv::venv; pub(crate) use version::version; +pub(crate) use workspace::lock::lock; +pub(crate) use workspace::run::run; +pub(crate) use workspace::sync::sync; use crate::printer::Printer; @@ -41,11 +43,11 @@ mod pip_show; mod pip_sync; mod pip_uninstall; mod reporters; -mod run; #[cfg(feature = "self-update")] mod self_update; mod venv; mod version; +mod workspace; #[derive(Copy, Clone)] pub(crate) enum ExitStatus { diff --git a/crates/uv/src/commands/run.rs b/crates/uv/src/commands/run.rs deleted file mode 100644 index bc2abaf6b..000000000 --- a/crates/uv/src/commands/run.rs +++ /dev/null @@ -1,736 +0,0 @@ -use crate::commands::reporters::{DownloadReporter, InstallReporter, ResolverReporter}; -use crate::commands::ExitStatus; -use crate::commands::{elapsed, ChangeEvent, ChangeEventKind}; -use crate::printer::Printer; -use anyhow::{Context, Result}; -use distribution_types::{ - IndexLocations, InstalledMetadata, LocalDist, Name, Requirement, Resolution, -}; -use install_wheel_rs::linker::LinkMode; -use itertools::Itertools; -use owo_colors::OwoColorize; -use pep508_rs::{MarkerEnvironment, PackageName}; -use platform_tags::Tags; -use pypi_types::Yanked; -use std::ffi::OsString; -use std::fmt::Write; -use std::path::PathBuf; -use std::{env, iter}; -use tempfile::{tempdir_in, TempDir}; -use tokio::process::Command; -use tracing::debug; -use uv_warnings::warn_user; - -use uv_cache::Cache; -use uv_client::{BaseClientBuilder, RegistryClient, RegistryClientBuilder}; -use uv_configuration::{ - ConfigSettings, Constraints, NoBinary, NoBuild, Overrides, PreviewMode, Reinstall, - SetupPyStrategy, -}; -use uv_dispatch::BuildDispatch; -use uv_fs::Simplified; -use uv_installer::{Downloader, Plan, Planner, SatisfiesResult, SitePackages}; -use uv_interpreter::{Interpreter, PythonEnvironment}; -use uv_requirements::{ - ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource, - RequirementsSpecification, SourceTreeResolver, -}; -use uv_resolver::{ - Exclusions, FlatIndex, InMemoryIndex, Manifest, Options, OptionsBuilder, ResolutionGraph, - Resolver, -}; -use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight}; - -/// Run a command. -#[allow(clippy::unnecessary_wraps, clippy::too_many_arguments)] -pub(crate) async fn run( - target: Option, - mut args: Vec, - mut requirements: Vec, - python: Option, - isolated: bool, - preview: PreviewMode, - cache: &Cache, - printer: Printer, -) -> Result { - if preview.is_disabled() { - warn_user!("`uv run` is experimental and may change without warning."); - } - - let command = if let Some(target) = target { - let target_path = PathBuf::from(&target); - if target_path - .extension() - .map_or(false, |ext| ext.eq_ignore_ascii_case("py")) - && target_path.exists() - { - args.insert(0, target_path.as_os_str().into()); - "python".to_string() - } else { - target - } - } else { - "python".to_string() - }; - - // Copy the requirements into a set of overrides; we'll use this to prioritize - // requested requirements over those discovered in the project. - // We must retain these requirements as direct dependencies too, as overrides - // cannot be applied to transitive dependencies. - let overrides = requirements.clone(); - - if !isolated { - if let Some(workspace_requirements) = find_workspace_requirements()? { - requirements.extend(workspace_requirements); - } - } - - // Detect the current Python interpreter. - // TODO(zanieb): Create ephemeral environments - // TODO(zanieb): Accept `--python` - let run_env = environment_for_run( - &requirements, - &overrides, - python.as_deref(), - isolated, - preview, - cache, - printer, - ) - .await?; - let python_env = run_env.python; - - // Construct the command - let mut process = Command::new(&command); - process.args(&args); - - // Set up the PATH - debug!( - "Using Python {} environment at {}", - python_env.interpreter().python_version(), - python_env.python_executable().user_display().cyan() - ); - let new_path = if let Some(path) = std::env::var_os("PATH") { - let python_env_path = - iter::once(python_env.scripts().to_path_buf()).chain(env::split_paths(&path)); - env::join_paths(python_env_path)? - } else { - OsString::from(python_env.scripts()) - }; - - process.env("PATH", new_path); - - // Spawn and wait for completion - // Standard input, output, and error streams are all inherited - // TODO(zanieb): Throw a nicer error message if the command is not found - let space = if args.is_empty() { "" } else { " " }; - debug!( - "Running `{command}{space}{}`", - args.iter().map(|arg| arg.to_string_lossy()).join(" ") - ); - let mut handle = process.spawn()?; - let status = handle.wait().await?; - - // Exit based on the result of the command - // TODO(zanieb): Do we want to exit with the code of the child process? Probably. - if status.success() { - Ok(ExitStatus::Success) - } else { - Ok(ExitStatus::Failure) - } -} - -struct RunEnvironment { - /// The Python environment to execute the run in. - python: PythonEnvironment, - /// A temporary directory, if a new virtual environment was created. - /// - /// Included to ensure that the temporary directory exists for the length of the operation, but - /// is dropped at the end as appropriate. - _temp_dir_drop: Option, -} - -fn find_workspace_requirements() -> Result>> { - // TODO(zanieb): Add/use workspace logic to load requirements for a workspace - // We cannot use `Workspace::find` yet because it depends on a `[tool.uv]` section - let pyproject_path = std::env::current_dir()?.join("pyproject.toml"); - if pyproject_path.exists() { - debug!( - "Loading requirements from {}", - pyproject_path.user_display() - ); - return Ok(Some(vec![ - RequirementsSource::from_requirements_file(pyproject_path), - RequirementsSource::from_package(".".to_string()), - ])); - } - - Ok(None) -} - -/// Returns an environment for a `run` invocation. -/// -/// Will use the current virtual environment (if any) unless `isolated` is true. -/// Will create virtual environments in a temporary directory (if necessary). -async fn environment_for_run( - requirements: &[RequirementsSource], - overrides: &[RequirementsSource], - python: Option<&str>, - isolated: bool, - preview: PreviewMode, - cache: &Cache, - printer: Printer, -) -> Result { - let current_venv = if isolated { - None - } else { - // Find the active environment if it exists - match PythonEnvironment::from_virtualenv(cache) { - Ok(env) => Some(env), - Err(uv_interpreter::Error::VenvNotFound) => None, - Err(err) => return Err(err.into()), - } - }; - - // TODO(zanieb): Support client configuration - let client_builder = BaseClientBuilder::default(); - - // Read all requirements from the provided sources. - // TODO(zanieb): Consider allowing constraints and extras - // TODO(zanieb): Allow specifying extras somehow - let spec = RequirementsSpecification::from_sources( - requirements, - &[], - overrides, - &ExtrasSpecification::None, - &client_builder, - preview, - ) - .await?; - - // Determine an interpreter to use - let python_env = if let Some(python) = python { - PythonEnvironment::from_requested_python(python, cache)? - } else { - PythonEnvironment::from_default_python(cache)? - }; - - // Check if the current environment satisfies the requirements - if let Some(venv) = current_venv { - // Ensure it matches the selected interpreter - // TODO(zanieb): We should check if a version was requested and see if the environment meets that - // too but this can wait until we refactor interpreter discovery - if venv.root() == python_env.root() { - // Determine the set of installed packages. - let site_packages = SitePackages::from_executable(&venv)?; - - // If the requirements are already satisfied, we're done. Ideally, the resolver would be fast - // enough to let us remove this check. But right now, for large environments, it's an order of - // magnitude faster to validate the environment than to resolve the requirements. - if spec.source_trees.is_empty() { - match site_packages.satisfies( - &spec.requirements, - &spec.editables, - &spec.constraints, - )? { - SatisfiesResult::Fresh { - recursive_requirements, - } => { - debug!( - "All requirements satisfied: {}", - recursive_requirements - .iter() - .map(|entry| entry.requirement.to_string()) - .sorted() - .join(" | ") - ); - debug!( - "All editables satisfied: {}", - spec.editables.iter().map(ToString::to_string).join(", ") - ); - return Ok(RunEnvironment { - python: venv, - _temp_dir_drop: None, - }); - } - SatisfiesResult::Unsatisfied(requirement) => { - debug!("At least one requirement is not satisfied: {requirement}"); - } - } - } - } - } - // Otherwise, we need a new environment - - // Create a virtual environment - // TODO(zanieb): Move this path derivation elsewhere - let uv_state_path = std::env::current_dir()?.join(".uv"); - fs_err::create_dir_all(&uv_state_path)?; - let tmpdir = tempdir_in(uv_state_path)?; - let venv = uv_virtualenv::create_venv( - tmpdir.path(), - python_env.into_interpreter(), - uv_virtualenv::Prompt::None, - false, - false, - )?; - - // Determine the tags, markers, and interpreter to use for resolution. - let interpreter = venv.interpreter().clone(); - let tags = venv.interpreter().tags()?; - let markers = venv.interpreter().markers(); - - // Collect the set of required hashes. - // TODO(zanieb): Support hash checking - let hasher = HashStrategy::None; - - // TODO(zanieb): Support index url configs - let index_locations = IndexLocations::default(); - - // TODO(zanieb): Support client options e.g. offline, tls, etc. - // Initialize the registry client. - let client = RegistryClientBuilder::new(cache.clone()) - .markers(markers) - .platform(interpreter.platform()) - .build(); - - // TODO(zanieb): Consider support for find links - let flat_index = FlatIndex::default(); - - // TODO(zanieb): Consider support for shared builds - // Determine whether to enable build isolation. - let build_isolation = BuildIsolation::Isolated; - - // TODO(zanieb): Consider no-binary and no-build support - let no_build = NoBuild::None; - let no_binary = NoBinary::None; - - // Create a shared in-memory index. - let index = InMemoryIndex::default(); - - // Track in-flight downloads, builds, etc., across resolutions. - let in_flight = InFlight::default(); - - let link_mode = LinkMode::default(); - let config_settings = ConfigSettings::default(); - - // Create a build dispatch. - let build_dispatch = BuildDispatch::new( - &client, - cache, - &interpreter, - &index_locations, - &flat_index, - &index, - &in_flight, - SetupPyStrategy::default(), - &config_settings, - build_isolation, - link_mode, - &no_build, - &no_binary, - ); - // TODO(zanieb): Consider `exclude-newer` support - - // Resolve the requirements from the provided sources. - let requirements = { - // Convert from unnamed to named requirements. - let mut requirements = NamedRequirementsResolver::new( - spec.requirements, - &hasher, - &build_dispatch, - &client, - &index, - ) - .with_reporter(ResolverReporter::from(printer)) - .resolve() - .await?; - - // Resolve any source trees into requirements. - if !spec.source_trees.is_empty() { - requirements.extend( - SourceTreeResolver::new( - spec.source_trees, - &ExtrasSpecification::None, - &hasher, - &build_dispatch, - &client, - &index, - ) - .with_reporter(ResolverReporter::from(printer)) - .resolve() - .await?, - ); - } - - requirements - }; - - let options = OptionsBuilder::new() - // TODO(zanieb): Support resolver options - // .resolution_mode(resolution_mode) - // .prerelease_mode(prerelease_mode) - // .dependency_mode(dependency_mode) - // .exclude_newer(exclude_newer) - .build(); - - // Resolve the requirements. - let resolution = match resolve( - requirements, - spec.project, - &hasher, - &interpreter, - tags, - markers, - &client, - &flat_index, - &index, - &build_dispatch, - options, - printer, - ) - .await - { - Ok(resolution) => Resolution::from(resolution), - Err(err) => return Err(err.into()), - }; - - // Re-initialize the in-flight map. - let in_flight = InFlight::default(); - - // Sync the environment. - install( - &resolution, - SitePackages::from_executable(&venv)?, - &no_binary, - link_mode, - &index_locations, - &hasher, - tags, - &client, - &in_flight, - &build_dispatch, - cache, - &venv, - printer, - ) - .await?; - - Ok(RunEnvironment { - python: venv, - _temp_dir_drop: Some(tmpdir), - }) -} - -/// Resolve a set of requirements, similar to running `pip compile`. -#[allow(clippy::too_many_arguments)] -async fn resolve( - requirements: Vec, - project: Option, - hasher: &HashStrategy, - interpreter: &Interpreter, - tags: &Tags, - markers: &MarkerEnvironment, - client: &RegistryClient, - flat_index: &FlatIndex, - index: &InMemoryIndex, - build_dispatch: &BuildDispatch<'_>, - options: Options, - printer: Printer, -) -> Result { - let start = std::time::Instant::now(); - let exclusions = Exclusions::None; - let preferences = Vec::new(); - let constraints = Constraints::default(); - let overrides = Overrides::default(); - let editables = Vec::new(); - let installed_packages = EmptyInstalledPackages; - - // Determine any lookahead requirements. - let lookaheads = LookaheadResolver::new( - &requirements, - &constraints, - &overrides, - &editables, - hasher, - build_dispatch, - client, - index, - ) - .with_reporter(ResolverReporter::from(printer)) - .resolve(markers) - .await?; - - // Create a manifest of the requirements. - let manifest = Manifest::new( - requirements, - constraints, - overrides, - preferences, - project, - editables, - exclusions, - lookaheads, - ); - - // Resolve the dependencies. - let resolver = Resolver::new( - manifest, - options, - markers, - interpreter, - tags, - client, - flat_index, - index, - hasher, - build_dispatch, - &installed_packages, - )? - .with_reporter(ResolverReporter::from(printer)); - let resolution = resolver.resolve().await?; - - let s = if resolution.len() == 1 { "" } else { "s" }; - writeln!( - printer.stderr(), - "{}", - format!( - "Resolved {} in {}", - format!("{} package{}", resolution.len(), s).bold(), - elapsed(start.elapsed()) - ) - .dimmed() - )?; - - // Notify the user of any diagnostics. - for diagnostic in resolution.diagnostics() { - writeln!( - printer.stderr(), - "{}{} {}", - "warning".yellow().bold(), - ":".bold(), - diagnostic.message().bold() - )?; - } - - Ok(resolution) -} - -/// Install a set of requirements into the current environment. -#[allow(clippy::too_many_arguments)] -async fn install( - resolution: &Resolution, - site_packages: SitePackages<'_>, - no_binary: &NoBinary, - link_mode: LinkMode, - index_urls: &IndexLocations, - hasher: &HashStrategy, - tags: &Tags, - client: &RegistryClient, - in_flight: &InFlight, - build_dispatch: &BuildDispatch<'_>, - cache: &Cache, - venv: &PythonEnvironment, - printer: Printer, -) -> Result<(), Error> { - let start = std::time::Instant::now(); - - let requirements = resolution.requirements(); - - // Partition into those that should be linked from the cache (`local`), those that need to be - // downloaded (`remote`), and those that should be removed (`extraneous`). - let plan = Planner::with_requirements(&requirements) - .build( - site_packages, - &Reinstall::None, - no_binary, - hasher, - index_urls, - cache, - venv, - tags, - ) - .context("Failed to determine installation plan")?; - - let Plan { - cached, - remote, - reinstalls, - installed: _, - extraneous: _, - } = plan; - - // Nothing to do. - if remote.is_empty() && cached.is_empty() { - let s = if resolution.len() == 1 { "" } else { "s" }; - writeln!( - printer.stderr(), - "{}", - format!( - "Audited {} in {}", - format!("{} package{}", resolution.len(), s).bold(), - elapsed(start.elapsed()) - ) - .dimmed() - )?; - return Ok(()); - } - - // Map any registry-based requirements back to those returned by the resolver. - let remote = remote - .iter() - .map(|dist| { - resolution - .get_remote(&dist.name) - .cloned() - .expect("Resolution should contain all packages") - }) - .collect::>(); - - // Download, build, and unzip any missing distributions. - let wheels = if remote.is_empty() { - vec![] - } else { - let start = std::time::Instant::now(); - - let downloader = Downloader::new(cache, tags, hasher, client, build_dispatch) - .with_reporter(DownloadReporter::from(printer).with_length(remote.len() as u64)); - - let wheels = downloader - .download(remote.clone(), in_flight) - .await - .context("Failed to download distributions")?; - - let s = if wheels.len() == 1 { "" } else { "s" }; - writeln!( - printer.stderr(), - "{}", - format!( - "Downloaded {} in {}", - format!("{} package{}", wheels.len(), s).bold(), - elapsed(start.elapsed()) - ) - .dimmed() - )?; - - wheels - }; - - // Install the resolved distributions. - let wheels = wheels.into_iter().chain(cached).collect::>(); - if !wheels.is_empty() { - let start = std::time::Instant::now(); - uv_installer::Installer::new(venv) - .with_link_mode(link_mode) - .with_reporter(InstallReporter::from(printer).with_length(wheels.len() as u64)) - .install(&wheels)?; - - let s = if wheels.len() == 1 { "" } else { "s" }; - writeln!( - printer.stderr(), - "{}", - format!( - "Installed {} in {}", - format!("{} package{}", wheels.len(), s).bold(), - elapsed(start.elapsed()) - ) - .dimmed() - )?; - } - - for event in reinstalls - .into_iter() - .map(|distribution| ChangeEvent { - dist: LocalDist::from(distribution), - kind: ChangeEventKind::Removed, - }) - .chain(wheels.into_iter().map(|distribution| ChangeEvent { - dist: LocalDist::from(distribution), - kind: ChangeEventKind::Added, - })) - .sorted_unstable_by(|a, b| { - a.dist - .name() - .cmp(b.dist.name()) - .then_with(|| a.kind.cmp(&b.kind)) - .then_with(|| a.dist.installed_version().cmp(&b.dist.installed_version())) - }) - { - match event.kind { - ChangeEventKind::Added => { - writeln!( - printer.stderr(), - " {} {}{}", - "+".green(), - event.dist.name().as_ref().bold(), - event.dist.installed_version().to_string().dimmed() - )?; - } - ChangeEventKind::Removed => { - writeln!( - printer.stderr(), - " {} {}{}", - "-".red(), - event.dist.name().as_ref().bold(), - event.dist.installed_version().to_string().dimmed() - )?; - } - } - } - - // TODO(konstin): Also check the cache whether any cached or installed dist is already known to - // have been yanked, we currently don't show this message on the second run anymore - for dist in &remote { - let Some(file) = dist.file() else { - continue; - }; - match &file.yanked { - None | Some(Yanked::Bool(false)) => {} - Some(Yanked::Bool(true)) => { - writeln!( - printer.stderr(), - "{}{} {dist} is yanked.", - "warning".yellow().bold(), - ":".bold(), - )?; - } - Some(Yanked::Reason(reason)) => { - writeln!( - printer.stderr(), - "{}{} {dist} is yanked (reason: \"{reason}\").", - "warning".yellow().bold(), - ":".bold(), - )?; - } - } - } - - Ok(()) -} - -#[derive(thiserror::Error, Debug)] -enum Error { - #[error(transparent)] - Resolve(#[from] uv_resolver::ResolveError), - - #[error(transparent)] - Client(#[from] uv_client::Error), - - #[error(transparent)] - Platform(#[from] platform_tags::PlatformError), - - #[error(transparent)] - Hash(#[from] uv_types::HashStrategyError), - - #[error(transparent)] - Io(#[from] std::io::Error), - - #[error(transparent)] - Fmt(#[from] std::fmt::Error), - - #[error(transparent)] - Lookahead(#[from] uv_requirements::LookaheadError), - - #[error(transparent)] - Anyhow(#[from] anyhow::Error), -} diff --git a/crates/uv/src/commands/workspace/lock.rs b/crates/uv/src/commands/workspace/lock.rs new file mode 100644 index 000000000..bd0177742 --- /dev/null +++ b/crates/uv/src/commands/workspace/lock.rs @@ -0,0 +1,139 @@ +use anstream::eprint; +use anyhow::Result; + +use distribution_types::IndexLocations; +use install_wheel_rs::linker::LinkMode; +use uv_cache::Cache; +use uv_client::{BaseClientBuilder, RegistryClientBuilder}; +use uv_configuration::{ConfigSettings, NoBinary, NoBuild, PreviewMode, SetupPyStrategy}; +use uv_dispatch::BuildDispatch; +use uv_interpreter::PythonEnvironment; +use uv_requirements::{ExtrasSpecification, RequirementsSpecification}; +use uv_resolver::{FlatIndex, InMemoryIndex, OptionsBuilder}; +use uv_types::{BuildIsolation, HashStrategy, InFlight}; +use uv_warnings::warn_user; + +use crate::commands::workspace::Error; +use crate::commands::{workspace, ExitStatus}; +use crate::printer::Printer; + +/// Resolve the project requirements into a lockfile. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn lock( + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv lock` is experimental and may change without warning."); + } + + // TODO(charlie): If the environment doesn't exist, create it. + let venv = PythonEnvironment::from_virtualenv(cache)?; + + // Find the workspace requirements. + let Some(requirements) = workspace::find_workspace()? else { + return Err(anyhow::anyhow!( + "Unable to find `pyproject.toml` for project workspace." + )); + }; + + // TODO(zanieb): Support client configuration + let client_builder = BaseClientBuilder::default(); + + // Read all requirements from the provided sources. + // TODO(zanieb): Consider allowing constraints and extras + // TODO(zanieb): Allow specifying extras somehow + let spec = RequirementsSpecification::from_sources( + &requirements, + &[], + &[], + &ExtrasSpecification::None, + &client_builder, + preview, + ) + .await?; + + // Determine the tags, markers, and interpreter to use for resolution. + let interpreter = venv.interpreter().clone(); + let tags = venv.interpreter().tags()?; + let markers = venv.interpreter().markers(); + + // Initialize the registry client. + // TODO(zanieb): Support client options e.g. offline, tls, etc. + let client = RegistryClientBuilder::new(cache.clone()) + .markers(markers) + .platform(venv.interpreter().platform()) + .build(); + + // TODO(charlie): Respect project configuration. + let build_isolation = BuildIsolation::default(); + let config_settings = ConfigSettings::default(); + let flat_index = FlatIndex::default(); + let hasher = HashStrategy::default(); + let in_flight = InFlight::default(); + let index = InMemoryIndex::default(); + let index_locations = IndexLocations::default(); + let link_mode = LinkMode::default(); + let no_binary = NoBinary::default(); + let no_build = NoBuild::default(); + let setup_py = SetupPyStrategy::default(); + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + cache, + &interpreter, + &index_locations, + &flat_index, + &index, + &in_flight, + setup_py, + &config_settings, + build_isolation, + link_mode, + &no_build, + &no_binary, + ); + + let options = OptionsBuilder::new() + // TODO(zanieb): Support resolver options + // .resolution_mode(resolution_mode) + // .prerelease_mode(prerelease_mode) + // .dependency_mode(dependency_mode) + // .exclude_newer(exclude_newer) + .build(); + + // Resolve the requirements. + let resolution = workspace::resolve( + spec, + &hasher, + &interpreter, + tags, + markers, + &client, + &flat_index, + &index, + &build_dispatch, + options, + printer, + ) + .await; + + let resolution = match resolution { + Err(Error::Resolve(uv_resolver::ResolveError::NoSolution(err))) => { + let report = miette::Report::msg(format!("{err}")) + .context("No solution found when resolving dependencies:"); + eprint!("{report:?}"); + return Ok(ExitStatus::Failure); + } + result => result, + }?; + + // Write the lockfile to disk. + let lock = resolution.lock()?; + let encoded = toml::to_string_pretty(&lock)?; + fs_err::tokio::write("uv.lock", encoded.as_bytes()).await?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/workspace/mod.rs b/crates/uv/src/commands/workspace/mod.rs new file mode 100644 index 000000000..b03dac217 --- /dev/null +++ b/crates/uv/src/commands/workspace/mod.rs @@ -0,0 +1,401 @@ +use std::fmt::Write; + +use anyhow::{Context, Result}; +use itertools::Itertools; +use owo_colors::OwoColorize; +use tracing::debug; + +use distribution_types::{IndexLocations, InstalledMetadata, LocalDist, Name, Resolution}; +use install_wheel_rs::linker::LinkMode; +use pep508_rs::MarkerEnvironment; +use platform_tags::Tags; +use pypi_types::Yanked; +use uv_cache::Cache; +use uv_client::RegistryClient; +use uv_configuration::{Constraints, NoBinary, Overrides, Reinstall}; +use uv_dispatch::BuildDispatch; +use uv_fs::Simplified; +use uv_installer::{Downloader, Plan, Planner, SitePackages}; +use uv_interpreter::{Interpreter, PythonEnvironment}; +use uv_requirements::{ + ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource, + RequirementsSpecification, SourceTreeResolver, +}; +use uv_resolver::{ + Exclusions, FlatIndex, InMemoryIndex, Manifest, Options, ResolutionGraph, Resolver, +}; +use uv_types::{EmptyInstalledPackages, HashStrategy, InFlight}; + +use crate::commands::reporters::{DownloadReporter, InstallReporter, ResolverReporter}; +use crate::commands::{elapsed, ChangeEvent, ChangeEventKind}; +use crate::printer::Printer; + +pub(crate) mod lock; +pub(crate) mod run; +pub(crate) mod sync; + +#[derive(thiserror::Error, Debug)] +pub(crate) enum Error { + #[error(transparent)] + Resolve(#[from] uv_resolver::ResolveError), + + #[error(transparent)] + Client(#[from] uv_client::Error), + + #[error(transparent)] + Platform(#[from] platform_tags::PlatformError), + + #[error(transparent)] + Hash(#[from] uv_types::HashStrategyError), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Fmt(#[from] std::fmt::Error), + + #[error(transparent)] + Lookahead(#[from] uv_requirements::LookaheadError), + + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +/// Find the requirements for the current workspace. +pub(crate) fn find_workspace() -> Result>> { + // TODO(zanieb): Add/use workspace logic to load requirements for a workspace + // We cannot use `Workspace::find` yet because it depends on a `[tool.uv]` section + let pyproject_path = std::env::current_dir()?.join("pyproject.toml"); + if pyproject_path.exists() { + debug!( + "Loading requirements from {}", + pyproject_path.user_display() + ); + return Ok(Some(vec![ + RequirementsSource::from_requirements_file(pyproject_path), + RequirementsSource::from_package(".".to_string()), + ])); + } + + Ok(None) +} + +/// Resolve a set of requirements, similar to running `pip compile`. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn resolve( + spec: RequirementsSpecification, + hasher: &HashStrategy, + interpreter: &Interpreter, + tags: &Tags, + markers: &MarkerEnvironment, + client: &RegistryClient, + flat_index: &FlatIndex, + index: &InMemoryIndex, + build_dispatch: &BuildDispatch<'_>, + options: Options, + printer: Printer, +) -> Result { + let start = std::time::Instant::now(); + + let exclusions = Exclusions::None; + let preferences = Vec::new(); + let constraints = Constraints::default(); + let overrides = Overrides::default(); + let editables = Vec::new(); + let installed_packages = EmptyInstalledPackages; + + // Resolve the requirements from the provided sources. + let requirements = { + // Convert from unnamed to named requirements. + let mut requirements = NamedRequirementsResolver::new( + spec.requirements, + hasher, + build_dispatch, + client, + index, + ) + .with_reporter(ResolverReporter::from(printer)) + .resolve() + .await?; + + // Resolve any source trees into requirements. + if !spec.source_trees.is_empty() { + requirements.extend( + SourceTreeResolver::new( + spec.source_trees, + &ExtrasSpecification::None, + hasher, + build_dispatch, + client, + index, + ) + .with_reporter(ResolverReporter::from(printer)) + .resolve() + .await?, + ); + } + + requirements + }; + + // Determine any lookahead requirements. + let lookaheads = LookaheadResolver::new( + &requirements, + &constraints, + &overrides, + &editables, + hasher, + build_dispatch, + client, + index, + ) + .with_reporter(ResolverReporter::from(printer)) + .resolve(markers) + .await?; + + // Create a manifest of the requirements. + let manifest = Manifest::new( + requirements, + constraints, + overrides, + preferences, + spec.project, + editables, + exclusions, + lookaheads, + ); + + // Resolve the dependencies. + let resolver = Resolver::new( + manifest, + options, + markers, + interpreter, + tags, + client, + flat_index, + index, + hasher, + build_dispatch, + &installed_packages, + )? + .with_reporter(ResolverReporter::from(printer)); + let resolution = resolver.resolve().await?; + + let s = if resolution.len() == 1 { "" } else { "s" }; + writeln!( + printer.stderr(), + "{}", + format!( + "Resolved {} in {}", + format!("{} package{}", resolution.len(), s).bold(), + elapsed(start.elapsed()) + ) + .dimmed() + )?; + + // Notify the user of any diagnostics. + for diagnostic in resolution.diagnostics() { + writeln!( + printer.stderr(), + "{}{} {}", + "warning".yellow().bold(), + ":".bold(), + diagnostic.message().bold() + )?; + } + + Ok(resolution) +} + +/// Install a set of requirements into the current environment. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn install( + resolution: &Resolution, + site_packages: SitePackages<'_>, + no_binary: &NoBinary, + link_mode: LinkMode, + index_urls: &IndexLocations, + hasher: &HashStrategy, + tags: &Tags, + client: &RegistryClient, + in_flight: &InFlight, + build_dispatch: &BuildDispatch<'_>, + cache: &Cache, + venv: &PythonEnvironment, + printer: Printer, +) -> Result<(), Error> { + let start = std::time::Instant::now(); + + let requirements = resolution.requirements(); + + // Partition into those that should be linked from the cache (`local`), those that need to be + // downloaded (`remote`), and those that should be removed (`extraneous`). + let plan = Planner::with_requirements(&requirements) + .build( + site_packages, + &Reinstall::None, + no_binary, + hasher, + index_urls, + cache, + venv, + tags, + ) + .context("Failed to determine installation plan")?; + + let Plan { + cached, + remote, + reinstalls, + installed: _, + extraneous: _, + } = plan; + + // Nothing to do. + if remote.is_empty() && cached.is_empty() { + let s = if resolution.len() == 1 { "" } else { "s" }; + writeln!( + printer.stderr(), + "{}", + format!( + "Audited {} in {}", + format!("{} package{}", resolution.len(), s).bold(), + elapsed(start.elapsed()) + ) + .dimmed() + )?; + return Ok(()); + } + + // Map any registry-based requirements back to those returned by the resolver. + let remote = remote + .iter() + .map(|dist| { + resolution + .get_remote(&dist.name) + .cloned() + .expect("Resolution should contain all packages") + }) + .collect::>(); + + // Download, build, and unzip any missing distributions. + let wheels = if remote.is_empty() { + vec![] + } else { + let start = std::time::Instant::now(); + + let downloader = Downloader::new(cache, tags, hasher, client, build_dispatch) + .with_reporter(DownloadReporter::from(printer).with_length(remote.len() as u64)); + + let wheels = downloader + .download(remote.clone(), in_flight) + .await + .context("Failed to download distributions")?; + + let s = if wheels.len() == 1 { "" } else { "s" }; + writeln!( + printer.stderr(), + "{}", + format!( + "Downloaded {} in {}", + format!("{} package{}", wheels.len(), s).bold(), + elapsed(start.elapsed()) + ) + .dimmed() + )?; + + wheels + }; + + // Install the resolved distributions. + let wheels = wheels.into_iter().chain(cached).collect::>(); + if !wheels.is_empty() { + let start = std::time::Instant::now(); + uv_installer::Installer::new(venv) + .with_link_mode(link_mode) + .with_reporter(InstallReporter::from(printer).with_length(wheels.len() as u64)) + .install(&wheels)?; + + let s = if wheels.len() == 1 { "" } else { "s" }; + writeln!( + printer.stderr(), + "{}", + format!( + "Installed {} in {}", + format!("{} package{}", wheels.len(), s).bold(), + elapsed(start.elapsed()) + ) + .dimmed() + )?; + } + + for event in reinstalls + .into_iter() + .map(|distribution| ChangeEvent { + dist: LocalDist::from(distribution), + kind: ChangeEventKind::Removed, + }) + .chain(wheels.into_iter().map(|distribution| ChangeEvent { + dist: LocalDist::from(distribution), + kind: ChangeEventKind::Added, + })) + .sorted_unstable_by(|a, b| { + a.dist + .name() + .cmp(b.dist.name()) + .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.dist.installed_version().cmp(&b.dist.installed_version())) + }) + { + match event.kind { + ChangeEventKind::Added => { + writeln!( + printer.stderr(), + " {} {}{}", + "+".green(), + event.dist.name().as_ref().bold(), + event.dist.installed_version().to_string().dimmed() + )?; + } + ChangeEventKind::Removed => { + writeln!( + printer.stderr(), + " {} {}{}", + "-".red(), + event.dist.name().as_ref().bold(), + event.dist.installed_version().to_string().dimmed() + )?; + } + } + } + + // TODO(konstin): Also check the cache whether any cached or installed dist is already known to + // have been yanked, we currently don't show this message on the second run anymore + for dist in &remote { + let Some(file) = dist.file() else { + continue; + }; + match &file.yanked { + None | Some(Yanked::Bool(false)) => {} + Some(Yanked::Bool(true)) => { + writeln!( + printer.stderr(), + "{}{} {dist} is yanked.", + "warning".yellow().bold(), + ":".bold(), + )?; + } + Some(Yanked::Reason(reason)) => { + writeln!( + printer.stderr(), + "{}{} {dist} is yanked (reason: \"{reason}\").", + "warning".yellow().bold(), + ":".bold(), + )?; + } + } + } + + Ok(()) +} diff --git a/crates/uv/src/commands/workspace/run.rs b/crates/uv/src/commands/workspace/run.rs new file mode 100644 index 000000000..901e62b99 --- /dev/null +++ b/crates/uv/src/commands/workspace/run.rs @@ -0,0 +1,340 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::{env, iter}; + +use anyhow::Result; +use itertools::Itertools; +use owo_colors::OwoColorize; +use tempfile::{tempdir_in, TempDir}; +use tokio::process::Command; +use tracing::debug; + +use distribution_types::{IndexLocations, Resolution}; +use install_wheel_rs::linker::LinkMode; +use uv_cache::Cache; +use uv_client::{BaseClientBuilder, RegistryClientBuilder}; +use uv_configuration::{ConfigSettings, NoBinary, NoBuild, PreviewMode, SetupPyStrategy}; +use uv_dispatch::BuildDispatch; +use uv_fs::Simplified; +use uv_installer::{SatisfiesResult, SitePackages}; +use uv_interpreter::PythonEnvironment; +use uv_requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification}; +use uv_resolver::{FlatIndex, InMemoryIndex, OptionsBuilder}; +use uv_types::{BuildIsolation, HashStrategy, InFlight}; +use uv_warnings::warn_user; + +use crate::commands::{workspace, ExitStatus}; +use crate::printer::Printer; + +/// Run a command. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run( + target: Option, + mut args: Vec, + mut requirements: Vec, + python: Option, + isolated: bool, + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv run` is experimental and may change without warning."); + } + + let command = if let Some(target) = target { + let target_path = PathBuf::from(&target); + if target_path + .extension() + .map_or(false, |ext| ext.eq_ignore_ascii_case("py")) + && target_path.exists() + { + args.insert(0, target_path.as_os_str().into()); + "python".to_string() + } else { + target + } + } else { + "python".to_string() + }; + + // Copy the requirements into a set of overrides; we'll use this to prioritize + // requested requirements over those discovered in the project. + // We must retain these requirements as direct dependencies too, as overrides + // cannot be applied to transitive dependencies. + let overrides = requirements.clone(); + + if !isolated { + if let Some(workspace_requirements) = workspace::find_workspace()? { + requirements.extend(workspace_requirements); + } + } + + // Detect the current Python interpreter. + // TODO(zanieb): Create ephemeral environments + // TODO(zanieb): Accept `--python` + let run_env = environment_for_run( + &requirements, + &overrides, + python.as_deref(), + isolated, + preview, + cache, + printer, + ) + .await?; + let python_env = run_env.python; + + // Construct the command + let mut process = Command::new(&command); + process.args(&args); + + // Set up the PATH + debug!( + "Using Python {} environment at {}", + python_env.interpreter().python_version(), + python_env.python_executable().user_display().cyan() + ); + let new_path = if let Some(path) = env::var_os("PATH") { + let python_env_path = + iter::once(python_env.scripts().to_path_buf()).chain(env::split_paths(&path)); + env::join_paths(python_env_path)? + } else { + OsString::from(python_env.scripts()) + }; + + process.env("PATH", new_path); + + // Spawn and wait for completion + // Standard input, output, and error streams are all inherited + // TODO(zanieb): Throw a nicer error message if the command is not found + let space = if args.is_empty() { "" } else { " " }; + debug!( + "Running `{command}{space}{}`", + args.iter().map(|arg| arg.to_string_lossy()).join(" ") + ); + let mut handle = process.spawn()?; + let status = handle.wait().await?; + + // Exit based on the result of the command + // TODO(zanieb): Do we want to exit with the code of the child process? Probably. + if status.success() { + Ok(ExitStatus::Success) + } else { + Ok(ExitStatus::Failure) + } +} + +struct RunEnvironment { + /// The Python environment to execute the run in. + python: PythonEnvironment, + /// A temporary directory, if a new virtual environment was created. + /// + /// Included to ensure that the temporary directory exists for the length of the operation, but + /// is dropped at the end as appropriate. + _temp_dir_drop: Option, +} + +/// Returns an environment for a `run` invocation. +/// +/// Will use the current virtual environment (if any) unless `isolated` is true. +/// Will create virtual environments in a temporary directory (if necessary). +async fn environment_for_run( + requirements: &[RequirementsSource], + overrides: &[RequirementsSource], + python: Option<&str>, + isolated: bool, + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + let current_venv = if isolated { + None + } else { + // Find the active environment if it exists + match PythonEnvironment::from_virtualenv(cache) { + Ok(env) => Some(env), + Err(uv_interpreter::Error::VenvNotFound) => None, + Err(err) => return Err(err.into()), + } + }; + + // TODO(zanieb): Support client configuration + let client_builder = BaseClientBuilder::default(); + + // Read all requirements from the provided sources. + // TODO(zanieb): Consider allowing constraints and extras + // TODO(zanieb): Allow specifying extras somehow + let spec = RequirementsSpecification::from_sources( + requirements, + &[], + overrides, + &ExtrasSpecification::None, + &client_builder, + preview, + ) + .await?; + + // Determine an interpreter to use + let python_env = if let Some(python) = python { + PythonEnvironment::from_requested_python(python, cache)? + } else { + PythonEnvironment::from_default_python(cache)? + }; + + // Check if the current environment satisfies the requirements + if let Some(venv) = current_venv { + // Ensure it matches the selected interpreter + // TODO(zanieb): We should check if a version was requested and see if the environment meets that + // too but this can wait until we refactor interpreter discovery + if venv.root() == python_env.root() { + // Determine the set of installed packages. + let site_packages = SitePackages::from_executable(&venv)?; + + // If the requirements are already satisfied, we're done. Ideally, the resolver would be fast + // enough to let us remove this check. But right now, for large environments, it's an order of + // magnitude faster to validate the environment than to resolve the requirements. + if spec.source_trees.is_empty() { + match site_packages.satisfies( + &spec.requirements, + &spec.editables, + &spec.constraints, + )? { + SatisfiesResult::Fresh { + recursive_requirements, + } => { + debug!( + "All requirements satisfied: {}", + recursive_requirements + .iter() + .map(|entry| entry.requirement.to_string()) + .sorted() + .join(" | ") + ); + debug!( + "All editables satisfied: {}", + spec.editables.iter().map(ToString::to_string).join(", ") + ); + return Ok(RunEnvironment { + python: venv, + _temp_dir_drop: None, + }); + } + SatisfiesResult::Unsatisfied(requirement) => { + debug!("At least one requirement is not satisfied: {requirement}"); + } + } + } + } + } + // Otherwise, we need a new environment + + // Create a virtual environment + // TODO(zanieb): Move this path derivation elsewhere + let uv_state_path = env::current_dir()?.join(".uv"); + fs_err::create_dir_all(&uv_state_path)?; + let tmpdir = tempdir_in(uv_state_path)?; + let venv = uv_virtualenv::create_venv( + tmpdir.path(), + python_env.into_interpreter(), + uv_virtualenv::Prompt::None, + false, + false, + )?; + + // Determine the tags, markers, and interpreter to use for resolution. + let interpreter = venv.interpreter().clone(); + let tags = venv.interpreter().tags()?; + let markers = venv.interpreter().markers(); + + // Initialize the registry client. + // TODO(zanieb): Support client options e.g. offline, tls, etc. + let client = RegistryClientBuilder::new(cache.clone()) + .markers(markers) + .platform(venv.interpreter().platform()) + .build(); + + // TODO(charlie): Respect project configuration. + let build_isolation = BuildIsolation::default(); + let config_settings = ConfigSettings::default(); + let flat_index = FlatIndex::default(); + let hasher = HashStrategy::default(); + let in_flight = InFlight::default(); + let index = InMemoryIndex::default(); + let index_locations = IndexLocations::default(); + let link_mode = LinkMode::default(); + let no_binary = NoBinary::default(); + let no_build = NoBuild::default(); + let setup_py = SetupPyStrategy::default(); + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + cache, + &interpreter, + &index_locations, + &flat_index, + &index, + &in_flight, + setup_py, + &config_settings, + build_isolation, + link_mode, + &no_build, + &no_binary, + ); + + let options = OptionsBuilder::new() + // TODO(zanieb): Support resolver options + // .resolution_mode(resolution_mode) + // .prerelease_mode(prerelease_mode) + // .dependency_mode(dependency_mode) + // .exclude_newer(exclude_newer) + .build(); + + // Resolve the requirements. + let resolution = match workspace::resolve( + spec, + &hasher, + &interpreter, + tags, + markers, + &client, + &flat_index, + &index, + &build_dispatch, + options, + printer, + ) + .await + { + Ok(resolution) => Resolution::from(resolution), + Err(err) => return Err(err.into()), + }; + + // Re-initialize the in-flight map. + let in_flight = InFlight::default(); + + // Sync the environment. + workspace::install( + &resolution, + SitePackages::from_executable(&venv)?, + &no_binary, + link_mode, + &index_locations, + &hasher, + tags, + &client, + &in_flight, + &build_dispatch, + cache, + &venv, + printer, + ) + .await?; + + Ok(RunEnvironment { + python: venv, + _temp_dir_drop: Some(tmpdir), + }) +} diff --git a/crates/uv/src/commands/workspace/sync.rs b/crates/uv/src/commands/workspace/sync.rs new file mode 100644 index 000000000..eea42333b --- /dev/null +++ b/crates/uv/src/commands/workspace/sync.rs @@ -0,0 +1,100 @@ +use anyhow::Result; + +use distribution_types::IndexLocations; +use install_wheel_rs::linker::LinkMode; +use uv_cache::Cache; +use uv_client::RegistryClientBuilder; +use uv_configuration::{ConfigSettings, NoBinary, NoBuild, PreviewMode, SetupPyStrategy}; +use uv_dispatch::BuildDispatch; +use uv_installer::SitePackages; +use uv_interpreter::PythonEnvironment; +use uv_normalize::PackageName; +use uv_resolver::{FlatIndex, InMemoryIndex, Lock}; +use uv_types::{BuildIsolation, HashStrategy, InFlight}; +use uv_warnings::warn_user; + +use crate::commands::{workspace, ExitStatus}; +use crate::printer::Printer; + +/// Sync the project environment. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn sync( + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv sync` is experimental and may change without warning."); + } + + // TODO(charlie): If the environment doesn't exist, create it. + let venv = PythonEnvironment::from_virtualenv(cache)?; + let markers = venv.interpreter().markers(); + let tags = venv.interpreter().tags()?; + + // Initialize the registry client. + // TODO(zanieb): Support client options e.g. offline, tls, etc. + let client = RegistryClientBuilder::new(cache.clone()) + .markers(markers) + .platform(venv.interpreter().platform()) + .build(); + + // TODO(charlie): Respect project configuration. + let build_isolation = BuildIsolation::default(); + let config_settings = ConfigSettings::default(); + let flat_index = FlatIndex::default(); + let hasher = HashStrategy::default(); + let in_flight = InFlight::default(); + let index = InMemoryIndex::default(); + let index_locations = IndexLocations::default(); + let link_mode = LinkMode::default(); + let no_binary = NoBinary::default(); + let no_build = NoBuild::default(); + let setup_py = SetupPyStrategy::default(); + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + cache, + venv.interpreter(), + &index_locations, + &flat_index, + &index, + &in_flight, + setup_py, + &config_settings, + build_isolation, + link_mode, + &no_build, + &no_binary, + ); + + // Read the lockfile. + let resolution = { + // TODO(charlie): Read the project name from disk. + let root = PackageName::new("project".to_string())?; + let encoded = fs_err::tokio::read_to_string("uv.lock").await?; + let lock: Lock = toml::from_str(&encoded)?; + lock.to_resolution(markers, tags, &root) + }; + + // Sync the environment. + workspace::install( + &resolution, + SitePackages::from_executable(&venv)?, + &no_binary, + link_mode, + &index_locations, + &hasher, + tags, + &client, + &in_flight, + &build_dispatch, + cache, + &venv, + printer, + ) + .await?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index ab88650e4..edb340c6e 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -528,6 +528,18 @@ async fn run() -> Result { ) .await } + Commands::Sync(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let _args = settings::SyncSettings::resolve(args, workspace); + + commands::sync(globals.preview, &cache, printer).await + } + Commands::Lock(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let _args = settings::LockSettings::resolve(args, workspace); + + commands::lock(globals.preview, &cache, printer).await + } #[cfg(feature = "self-update")] Commands::Self_(SelfNamespace { command: SelfCommand::Update, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 4e507a47f..8efaf41eb 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -16,8 +16,9 @@ use uv_resolver::{AnnotationStyle, DependencyMode, ExcludeNewer, PreReleaseMode, use uv_workspace::{PipOptions, Workspace}; use crate::cli::{ - ColorChoice, GlobalArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, - PipListArgs, PipShowArgs, PipSyncArgs, PipUninstallArgs, RunArgs, VenvArgs, + ColorChoice, GlobalArgs, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, + PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipUninstallArgs, RunArgs, SyncArgs, + VenvArgs, }; use crate::commands::ListFormat; @@ -110,6 +111,48 @@ impl RunSettings { } } +/// The resolved settings to use for a `sync` invocation. +#[allow(clippy::struct_excessive_bools, dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct SyncSettings { + // CLI-only settings. + pub(crate) python: Option, +} + +impl SyncSettings { + /// Resolve the [`SyncSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: SyncArgs, _workspace: Option) -> Self { + let SyncArgs { python } = args; + + Self { + // CLI-only settings. + python, + } + } +} + +/// The resolved settings to use for a `lock` invocation. +#[allow(clippy::struct_excessive_bools, dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct LockSettings { + // CLI-only settings. + pub(crate) python: Option, +} + +impl LockSettings { + /// Resolve the [`LockSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: LockArgs, _workspace: Option) -> Self { + let LockArgs { python } = args; + + Self { + // CLI-only settings. + python, + } + } +} + /// The resolved settings to use for a `pip compile` invocation. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 8e3885b86..1d4cbe4cf 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -314,6 +314,24 @@ dependencies = ["flask==1.0.x"] Ok(()) } +#[test] +fn missing_pip() { + uv_snapshot!(Command::new(get_bin()).arg("install"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: unrecognized subcommand 'install' + + tip: a similar subcommand exists: 'uv pip install' + + Usage: uv [OPTIONS] + + For more information, try '--help'. + "###); +} + #[test] fn no_solution() { let context = TestContext::new("3.12"); diff --git a/crates/uv/tests/pip_sync.rs b/crates/uv/tests/pip_sync.rs index bd56a9a62..fb18eb0d4 100644 --- a/crates/uv/tests/pip_sync.rs +++ b/crates/uv/tests/pip_sync.rs @@ -74,24 +74,6 @@ fn uninstall_command(context: &TestContext) -> Command { command } -#[test] -fn missing_pip() { - uv_snapshot!(Command::new(get_bin()).arg("sync"), @r###" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: unrecognized subcommand 'sync' - - tip: a similar subcommand exists: 'uv pip sync' - - Usage: uv [OPTIONS] - - For more information, try '--help'. - "###); -} - #[test] fn missing_requirements_txt() { let context = TestContext::new("3.12");