diff --git a/crates/distribution-types/src/resolution.rs b/crates/distribution-types/src/resolution.rs index 7d700ace6..8a1f2a4fc 100644 --- a/crates/distribution-types/src/resolution.rs +++ b/crates/distribution-types/src/resolution.rs @@ -70,6 +70,29 @@ impl Resolution { pub fn diagnostics(&self) -> &[ResolutionDiagnostic] { &self.diagnostics } + + /// Filter the resolution to only include packages that match the given predicate. + #[must_use] + pub fn filter(self, predicate: impl Fn(&ResolvedDist) -> bool) -> Self { + let packages = self + .packages + .iter() + .filter(|(_, dist)| predicate(dist)) + .map(|(name, dist)| (name.clone(), dist.clone())) + .collect::>(); + let hashes = self + .hashes + .iter() + .filter(|(name, _)| packages.contains_key(name)) + .map(|(name, hashes)| (name.clone(), hashes.clone())) + .collect(); + let diagnostics = self.diagnostics.clone(); + Self { + packages, + hashes, + diagnostics, + } + } } #[derive(Debug, Clone, Hash)] diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 411b7c24d..d5aa9dcbd 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2272,6 +2272,16 @@ pub struct SyncArgs { #[arg(long, overrides_with("inexact"), hide = true)] pub exact: bool, + /// 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. + #[arg(long)] + pub no_install_project: bool, + /// Assert that the `uv.lock` will remain unchanged. /// /// Requires that the lockfile is up-to-date. If the lockfile is missing or diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c5d419426..875b0878e 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -603,6 +603,7 @@ pub(crate) async fn add( &lock, &extras, dev, + false, Modifications::Sufficient, settings.as_ref().into(), &state, diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 6fcd1463e..1cb0eab7f 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -190,6 +190,7 @@ pub(crate) async fn remove( // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? let extras = ExtrasSpecification::All; let dev = true; + let no_install_project = false; // Initialize any shared state. let state = SharedState::default(); @@ -200,6 +201,7 @@ pub(crate) async fn remove( &lock, &extras, dev, + no_install_project, Modifications::Exact, settings.as_ref().into(), &state, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index d4c8303e4..c7dde93b3 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -417,6 +417,7 @@ pub(crate) async fn run( result.lock(), &extras, dev, + false, Modifications::Sufficient, settings.as_ref().into(), &state, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index f455ae657..3cb276228 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -1,6 +1,8 @@ use anyhow::{Context, Result}; +use distribution_types::Name; use itertools::Itertools; use pep508_rs::MarkerTree; +use tracing::debug; use uv_auth::store_credentials_from_url; use uv_cache::Cache; use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder}; @@ -30,6 +32,7 @@ pub(crate) async fn sync( package: Option, extras: ExtrasSpecification, dev: bool, + no_install_project: bool, modifications: Modifications, python: Option, python_preference: PythonPreference, @@ -102,6 +105,7 @@ pub(crate) async fn sync( &lock, &extras, dev, + no_install_project, modifications, settings.as_ref().into(), &state, @@ -124,6 +128,7 @@ pub(super) async fn do_sync( lock: &Lock, extras: &ExtrasSpecification, dev: bool, + no_install_project: bool, modifications: Modifications, settings: InstallerSettingsRef<'_>, state: &SharedState, @@ -187,6 +192,9 @@ pub(super) async fn do_sync( // Read the lockfile. let resolution = lock.to_resolution(project, markers, tags, extras, &dev)?; + // If `--no-install-project` is set, remove the project itself. + let resolution = apply_no_install_project(no_install_project, resolution, project); + // Add all authenticated sources to the cache. for url in index_locations.urls() { store_credentials_from_url(url); @@ -274,3 +282,20 @@ pub(super) async fn do_sync( Ok(()) } + +fn apply_no_install_project( + no_install_project: bool, + resolution: distribution_types::Resolution, + project: &VirtualProject, +) -> distribution_types::Resolution { + if !no_install_project { + return resolution; + } + + let Some(project_name) = project.project_name() else { + debug!("Ignoring `--no-install-project` for virtual workspace"); + return resolution; + }; + + resolution.filter(|dist| dist.name() != project_name) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 8ebc407a7..bd85e567a 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1102,6 +1102,7 @@ async fn run_project( args.package, args.extras, args.dev, + args.no_install_project, args.modifications, args.python, globals.python_preference, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index a66967f80..bc43ad168 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -617,6 +617,7 @@ pub(crate) struct SyncSettings { pub(crate) frozen: bool, pub(crate) extras: ExtrasSpecification, pub(crate) dev: bool, + pub(crate) no_install_project: bool, pub(crate) modifications: Modifications, pub(crate) package: Option, pub(crate) python: Option, @@ -629,8 +630,6 @@ impl SyncSettings { #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: SyncArgs, filesystem: Option) -> Self { let SyncArgs { - locked, - frozen, extra, all_extras, no_all_extras, @@ -638,6 +637,9 @@ impl SyncSettings { no_dev, inexact, exact, + no_install_project, + locked, + frozen, installer, build, refresh, @@ -668,6 +670,7 @@ impl SyncSettings { extra.unwrap_or_default(), ), dev: flag(dev, no_dev).unwrap_or(true), + no_install_project, modifications, package, python, diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index ef2666831..307d5c817 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -826,3 +826,40 @@ fn read_metadata_statically_over_the_cache() -> Result<()> { Ok(()) } + +/// Avoid syncing the project package when `--no-install-project` is provided. +#[test] +fn no_install_project() -> 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"] + "#, + )?; + + // Generate a lockfile. + context.lock().assert().success(); + + // Running with `--no-install-project` should install `anyio`, but not `project`. + uv_snapshot!(context.filters(), context.sync().arg("--no-install-project"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 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(()) +} diff --git a/docs/guides/integration/docker.md b/docs/guides/integration/docker.md index eefb79912..2d232b151 100644 --- a/docs/guides/integration/docker.md +++ b/docs/guides/integration/docker.md @@ -59,8 +59,8 @@ If you're using uv to manage your project, you can copy it into the image and in ADD . /app WORKDIR /app -# Sync the project into a new environment -RUN uv sync +# Sync the project into a new environment, using the frozen lockfile +RUN uv sync --frozen ``` Once the project is installed, you can either _activate_ the virtual environment: @@ -187,3 +187,32 @@ ENV UV_CACHE_DIR=/opt/uv-cache/ ``` If not mounting the cache, image size can be reduced with `--no-cache` flag. + +### Intermediate layers + +If you're using uv to manage your project, you can improve build times by moving your transitive +dependency installation into its own layer via `uv sync --no-install-project`. + +`uv sync --no-install-project` will install the dependencies of the project but not the project +itself. Since the project changes frequently, but its dependencies are generally static, this can be +a big time saver. + +```dockerfile title="Dockerfile" +# Install uv +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +# Copy the lockfile into the image +ADD uv.lock /app/uv.lock + +# Install dependencies +WORKDIR /app +RUN uv sync --frozen --no-install-project + +# Copy the project into the image +ADD . /app +WORKDIR /app + +# Sync the project +RUN uv sync --frozen +``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0559d822e..2f5a1219f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1170,6 +1170,10 @@ uv sync [OPTIONS]
--no-index

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

+
--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-progress

Hide all progress outputs.

For example, spinners or progress bars.