diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index b3e81906e..98c881add 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3435,6 +3435,14 @@ pub struct SyncArgs { #[arg(long)] pub no_install_workspace: bool, + /// Do not install local path dependencies + /// + /// Skips the current project, workspace members, and any other local (path or editable) + /// packages. Only remote/indexed dependencies are installed. Useful in Docker builds to cache + /// heavy third-party dependencies first and layer local packages separately. + #[arg(long)] + pub no_install_local: bool, + /// Do not install the given package(s). /// /// By default, all of the project's dependencies are installed into the environment. The @@ -3503,6 +3511,7 @@ pub struct SyncArgs { conflicts_with = "package", conflicts_with = "no_install_project", conflicts_with = "no_install_workspace", + conflicts_with = "no_install_local", conflicts_with = "extra", conflicts_with = "all_extras", conflicts_with = "no_extra", @@ -3847,6 +3856,14 @@ pub struct AddArgs { /// allows optimal layer caching. #[arg(long, conflicts_with = "frozen", conflicts_with = "no_sync")] pub no_install_workspace: bool, + + /// Do not install local path dependencies + /// + /// Skips the current project, workspace members, and any other local (path or editable) + /// packages. Only remote/indexed dependencies are installed. Useful in Docker builds to cache + /// heavy third-party dependencies first and layer local packages separately. + #[arg(long, conflicts_with = "frozen", conflicts_with = "no_sync")] + pub no_install_local: bool, } #[derive(Args)] @@ -4239,6 +4256,14 @@ pub struct ExportArgs { #[arg(long, alias = "no-install-workspace")] pub no_emit_workspace: bool, + /// Do not include local path dependencies in the exported requirements. + /// + /// Omits the current project, workspace members, and any other local (path or editable) + /// packages from the export. Only remote/indexed dependencies are written. Useful for Docker + /// and CI flows that want to export and cache third-party dependencies first. + #[arg(long, alias = "no-install-local")] + pub no_emit_local: bool, + /// Do not emit the given package(s). /// /// By default, all of the project's dependencies are included in the exported requirements diff --git a/crates/uv-configuration/src/install_options.rs b/crates/uv-configuration/src/install_options.rs index 7b69562e9..e21f9d4f7 100644 --- a/crates/uv-configuration/src/install_options.rs +++ b/crates/uv-configuration/src/install_options.rs @@ -4,12 +4,23 @@ use tracing::debug; use uv_normalize::PackageName; +/// Minimal view of a package used to apply install filters. +#[derive(Debug, Clone, Copy)] +pub struct InstallTarget<'a> { + /// The package name. + pub name: &'a PackageName, + /// Whether the package refers to a local source (path, directory, editable, etc.). + pub is_local: bool, +} + #[derive(Debug, Clone, Default)] pub struct InstallOptions { /// Omit the project itself from the resolution. pub no_install_project: bool, /// Omit all workspace members (including the project itself) from the resolution. pub no_install_workspace: bool, + /// Omit all local packages from the resolution. + pub no_install_local: bool, /// Omit the specified packages from the resolution. pub no_install_package: Vec, } @@ -18,11 +29,13 @@ impl InstallOptions { pub fn new( no_install_project: bool, no_install_workspace: bool, + no_install_local: bool, no_install_package: Vec, ) -> Self { Self { no_install_project, no_install_workspace, + no_install_local, no_install_package, } } @@ -30,15 +43,18 @@ impl InstallOptions { /// Returns `true` if a package passes the install filters. pub fn include_package( &self, - package: &PackageName, + target: InstallTarget<'_>, project_name: Option<&PackageName>, members: &BTreeSet, ) -> bool { + let package_name = target.name; // If `--no-install-project` is set, remove the project itself. if self.no_install_project { if let Some(project_name) = project_name { - if package == project_name { - debug!("Omitting `{package}` from resolution due to `--no-install-project`"); + if package_name == project_name { + debug!( + "Omitting `{package_name}` from resolution due to `--no-install-project`" + ); return false; } } @@ -51,24 +67,32 @@ impl InstallOptions { // is set.) if !self.no_install_project { if let Some(project_name) = project_name { - if package == project_name { + if package_name == project_name { debug!( - "Omitting `{package}` from resolution due to `--no-install-workspace`" + "Omitting `{package_name}` from resolution due to `--no-install-workspace`" ); return false; } } } - if members.contains(package) { - debug!("Omitting `{package}` from resolution due to `--no-install-workspace`"); + if members.contains(package_name) { + debug!("Omitting `{package_name}` from resolution due to `--no-install-workspace`"); + return false; + } + } + + // If `--no-install-local` is set, remove local packages. + if self.no_install_local { + if target.is_local { + debug!("Omitting `{package_name}` from resolution due to `--no-install-local`"); return false; } } // If `--no-install-package` is provided, remove the requested packages. - if self.no_install_package.contains(package) { - debug!("Omitting `{package}` from resolution due to `--no-install-package`"); + if self.no_install_package.contains(package_name) { + debug!("Omitting `{package_name}` from resolution due to `--no-install-package`"); return false; } diff --git a/crates/uv-resolver/src/lock/export/mod.rs b/crates/uv-resolver/src/lock/export/mod.rs index 5b69329a7..326d46706 100644 --- a/crates/uv-resolver/src/lock/export/mod.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -303,7 +303,7 @@ impl<'lock> ExportableRequirements<'lock> { }) .filter(|(_index, package)| { install_options.include_package( - &package.id.name, + package.as_install_target(), target.project_name(), target.lock().members(), ) diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index e20921531..7488436e3 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -563,7 +563,7 @@ pub trait Installable<'lock> { install_options: &InstallOptions, ) -> Result { if install_options.include_package( - package.name(), + package.as_install_target(), self.project_name(), self.lock().members(), ) { diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index b3bd25447..fb61ce4ec 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -19,7 +19,7 @@ use tracing::debug; use url::Url; use uv_cache_key::RepositoryUrl; -use uv_configuration::{BuildOptions, Constraints}; +use uv_configuration::{BuildOptions, Constraints, InstallTarget}; use uv_distribution::{DistributionDatabase, FlatRequiresDist}; use uv_distribution_filename::{ BuildTag, DistExtension, ExtensionError, SourceDistExtension, WheelFilename, @@ -3123,6 +3123,14 @@ impl Package { pub fn resolved_dependency_groups(&self) -> &BTreeMap> { &self.dependency_groups } + + /// Returns an [`InstallTarget`] view for filtering decisions. + pub fn as_install_target(&self) -> InstallTarget<'_> { + InstallTarget { + name: self.name(), + is_local: self.id.source.is_local(), + } + } } /// Attempts to construct a `VerbatimUrl` from the given normalized `Path`. @@ -3688,6 +3696,14 @@ impl Source { } } } + + /// Check if a package is local by examining its source. + pub(crate) fn is_local(&self) -> bool { + matches!( + self, + Self::Path(_) | Self::Directory(_) | Self::Editable(_) | Self::Virtual(_) + ) + } } #[derive(Clone, Debug, serde::Deserialize)] diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c8257bb18..c6b9a7f94 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -71,6 +71,7 @@ pub(crate) async fn add( no_sync: bool, no_install_project: bool, no_install_workspace: bool, + no_install_local: bool, requirements: Vec, constraints: Vec, marker: Option, @@ -738,6 +739,7 @@ pub(crate) async fn add( locked, no_install_project, no_install_workspace, + no_install_local, &defaulted_extras, &defaulted_groups, raw, @@ -968,6 +970,7 @@ async fn lock_and_sync( locked: bool, no_install_project: bool, no_install_workspace: bool, + no_install_local: bool, extras: &ExtrasSpecificationWithDefaults, groups: &DependencyGroupsWithDefaults, raw: bool, @@ -1154,7 +1157,12 @@ async fn lock_and_sync( extras, groups, EditableMode::Editable, - InstallOptions::new(no_install_project, no_install_workspace, vec![]), + InstallOptions::new( + no_install_project, + no_install_workspace, + no_install_local, + vec![], + ), Modifications::Sufficient, None, settings.into(), diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 6b5b36d5e..ae2fc0e92 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1978,6 +1978,7 @@ async fn run_project( args.no_sync, args.no_install_project, args.no_install_workspace, + args.no_install_local, requirements, constraints, args.marker, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index ffcd62742..7f3ec45d3 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1221,6 +1221,7 @@ impl SyncSettings { exact, no_install_project, no_install_workspace, + no_install_local, no_install_package, locked, frozen, @@ -1286,6 +1287,7 @@ impl SyncSettings { install_options: InstallOptions::new( no_install_project, no_install_workspace, + no_install_local, no_install_package, ), modifications: if flag(exact, inexact, "inexact").unwrap_or(true) { @@ -1377,6 +1379,7 @@ pub(crate) struct AddSettings { pub(crate) workspace: Option, pub(crate) no_install_project: bool, pub(crate) no_install_workspace: bool, + pub(crate) no_install_local: bool, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, pub(crate) indexes: Vec, @@ -1418,6 +1421,7 @@ impl AddSettings { no_workspace, no_install_project, no_install_workspace, + no_install_local, } = args; let dependency_type = if let Some(extra) = optional { @@ -1521,6 +1525,7 @@ impl AddSettings { workspace: flag(workspace, no_workspace, "workspace"), no_install_project, no_install_workspace, + no_install_local, editable: flag(editable, no_editable, "editable"), extras: extra.unwrap_or_default(), refresh: Refresh::from(refresh), @@ -1820,6 +1825,7 @@ impl ExportSettings { no_emit_project, no_emit_workspace, no_emit_package, + no_emit_local, locked, frozen, resolver, @@ -1862,6 +1868,7 @@ impl ExportSettings { install_options: InstallOptions::new( no_emit_project, no_emit_workspace, + no_emit_local, no_emit_package, ), output_file, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 5e89c0697..8b07a8dff 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -5197,6 +5197,90 @@ fn no_install_workspace() -> Result<()> { Ok(()) } +/// Avoid syncing local packages when `--no-install-local` is provided. +#[test] +fn no_install_local() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0", "local", "local-editable", "workspace-member"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + local = { path = "./local" } + local-editable = { path = "./local-editable", editable = true } + workspace-member = { workspace = true } + + [tool.uv.workspace] + members = ["workspace-member"] + "#, + )?; + + // Add a local package, local editable package, and then a workspace member + // as a dependency. + let local = context.temp_dir.child("local"); + local.create_dir_all()?; + let local_pyproject = local.child("pyproject.toml"); + local_pyproject.write_str( + r#" + [project] + name = "local" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + let local_editable = context.temp_dir.child("local-editable"); + local_editable.create_dir_all()?; + let local_editable_pyproject = local_editable.child("pyproject.toml"); + local_editable_pyproject.write_str( + r#" + [project] + name = "local-editable" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + let workspace_member = context.temp_dir.child("workspace-member"); + workspace_member.create_dir_all()?; + let member_pyproject = workspace_member.child("pyproject.toml"); + member_pyproject.write_str( + r#" + [project] + name = "workspace-member" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + context.lock().assert().success(); + uv_snapshot!(context.filters(), context.sync().arg("--no-install-local"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 7 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + sniffio==1.3.1 + "); + + Ok(()) +} + /// Avoid syncing the target package when `--no-install-package` is provided. #[test] fn no_install_package() -> Result<()> { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b24860531..a4c3d33be 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -540,6 +540,8 @@ uv add [OPTIONS] >

May also be set with the UV_NO_CACHE environment variable.

--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

May also be set with the UV_NO_CONFIG environment variable.

--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

+
--no-install-local

Do not install local path dependencies

+

Skips the current project, workspace members, and any other local (path or editable) packages. Only remote/indexed dependencies are installed. Useful in Docker builds to cache heavy third-party dependencies first and layer local packages separately.

--no-install-project

Do not install the current project.

By default, the current project is installed into the environment with all of its dependencies. The --no-install-project option allows the project to be excluded, but all of its dependencies are still installed. This is particularly useful in situations like building Docker images where installing the project separately from its dependencies allows optimal layer caching.

--no-install-workspace

Do not install any workspace members, including the current project.

@@ -1127,6 +1129,8 @@ uv sync [OPTIONS]

This option always takes precedence over default groups, --all-groups, and --group.

May be provided multiple times.

--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

+
--no-install-local

Do not install local path dependencies

+

Skips the current project, workspace members, and any other local (path or editable) packages. Only remote/indexed dependencies are installed. Useful in Docker builds to cache heavy third-party dependencies first and layer local packages separately.

--no-install-package no-install-package

Do not install the given package(s).

By default, all of the project's dependencies are installed into the environment. The --no-install-package option allows exclusion of specific packages. Note this can result in a broken environment, and should be used with caution.

--no-install-project

Do not install the current project.

@@ -1544,7 +1548,9 @@ uv export [OPTIONS]
--no-dev

Disable the development dependency group.

This option is an alias of --no-group dev. See --no-default-groups to disable all default groups instead.

May also be set with the UV_NO_DEV environment variable.

--no-editable

Export any editable dependencies, including the project and any workspace members, as non-editable

-

May also be set with the UV_NO_EDITABLE environment variable.

--no-emit-package, --no-install-package no-emit-package

Do not emit the given package(s).

+

May also be set with the UV_NO_EDITABLE environment variable.

--no-emit-local, --no-install-local

Do not include local path dependencies in the exported requirements.

+

Omits the current project, workspace members, and any other local (path or editable) packages from the export. Only remote/indexed dependencies are written. Useful for Docker and CI flows that want to export and cache third-party dependencies first.

+
--no-emit-package, --no-install-package no-emit-package

Do not emit the given package(s).

By default, all of the project's dependencies are included in the exported requirements file. The --no-emit-package option allows exclusion of specific packages.

--no-emit-project, --no-install-project

Do not emit the current project.

By default, the current project is included in the exported requirements file with all of its dependencies. The --no-emit-project option allows the project to be excluded, but all of its dependencies to remain included.