Skip to content

Commit

Permalink
Add uv sync --no-locals to skip syncing local dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Aug 22, 2024
1 parent 681d605 commit 508fdc6
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 4 deletions.
22 changes: 22 additions & 0 deletions crates/distribution-types/src/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ impl Resolution {
pub fn diagnostics(&self) -> &[ResolutionDiagnostic] {
&self.diagnostics
}

/// Filter the resolution to only include packages that match the given predicate.
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::<BTreeMap<_, _>>();
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)]
Expand Down
10 changes: 10 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,16 @@ pub struct SyncArgs {
#[arg(long, overrides_with("inexact"), hide = true)]
pub exact: bool,

/// Avoid syncing any local packages, including the current project and any workspace members
/// or path dependencies in the lockfile.
///
/// This is useful for priming an environment with remote dependencies, without relying on any
/// local or mutable sources. For example, `uv sync --no-locals` could be used in a Docker image
/// to create a highly cacheable intermediate layer prior to installing local packages, which
/// change frequently.
#[arg(long)]
pub no_locals: bool,

/// Assert that the `uv.lock` will remain unchanged.
///
/// Requires that the lockfile is up-to-date. If the lockfile is missing or
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ pub(crate) async fn add(
&lock,
&extras,
dev,
false,
Modifications::Sufficient,
settings.as_ref().into(),
&state,
Expand Down
2 changes: 2 additions & 0 deletions crates/uv/src/commands/project/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_locals = false;

// Initialize any shared state.
let state = SharedState::default();
Expand All @@ -200,6 +201,7 @@ pub(crate) async fn remove(
&lock,
&extras,
dev,
no_locals,
Modifications::Exact,
settings.as_ref().into(),
&state,
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/project/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ pub(crate) async fn run(
result.lock(),
&extras,
dev,
false,
Modifications::Sufficient,
settings.as_ref().into(),
&state,
Expand Down
18 changes: 18 additions & 0 deletions crates/uv/src/commands/project/sync.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anyhow::{Context, Result};
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};
Expand Down Expand Up @@ -30,6 +31,7 @@ pub(crate) async fn sync(
package: Option<PackageName>,
extras: ExtrasSpecification,
dev: bool,
no_locals: bool,
modifications: Modifications,
python: Option<String>,
python_preference: PythonPreference,
Expand Down Expand Up @@ -102,6 +104,7 @@ pub(crate) async fn sync(
&lock,
&extras,
dev,
no_locals,
modifications,
settings.as_ref().into(),
&state,
Expand All @@ -124,6 +127,7 @@ pub(super) async fn do_sync(
lock: &Lock,
extras: &ExtrasSpecification,
dev: bool,
no_locals: bool,
modifications: Modifications,
settings: InstallerSettingsRef<'_>,
state: &SharedState,
Expand Down Expand Up @@ -187,6 +191,20 @@ pub(super) async fn do_sync(
// Read the lockfile.
let resolution = lock.to_resolution(project, markers, tags, extras, &dev)?;

// If `--no-locals` is set, remove any local dependencies.
let resolution = if no_locals {
let before = resolution.len();
let resolution = resolution.filter(|dist| !dist.is_local());
let after = resolution.len();
debug!(
"Removed {} local dependencies due to `--no-locals`",
after - before
);
resolution
} else {
resolution
};

// Add all authenticated sources to the cache.
for url in index_locations.urls() {
store_credentials_from_url(url);
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,7 @@ async fn run_project(
args.package,
args.extras,
args.dev,
args.no_locals,
args.modifications,
args.python,
globals.python_preference,
Expand Down
7 changes: 5 additions & 2 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ pub(crate) struct SyncSettings {
pub(crate) frozen: bool,
pub(crate) extras: ExtrasSpecification,
pub(crate) dev: bool,
pub(crate) no_locals: bool,
pub(crate) modifications: Modifications,
pub(crate) package: Option<PackageName>,
pub(crate) python: Option<String>,
Expand All @@ -630,15 +631,16 @@ impl SyncSettings {
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: SyncArgs, filesystem: Option<FilesystemOptions>) -> Self {
let SyncArgs {
locked,
frozen,
extra,
all_extras,
no_all_extras,
dev,
no_dev,
inexact,
exact,
no_locals,
locked,
frozen,
installer,
build,
refresh,
Expand Down Expand Up @@ -669,6 +671,7 @@ impl SyncSettings {
extra.unwrap_or_default(),
),
dev: flag(dev, no_dev).unwrap_or(true),
no_locals,
modifications,
package,
python,
Expand Down
159 changes: 159 additions & 0 deletions crates/uv/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,3 +826,162 @@ fn read_metadata_statically_over_the_cache() -> Result<()> {

Ok(())
}

/// Avoid syncing local dependencies when `--no-locals` is provided.
#[test]
fn no_locals() -> 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-locals` should install `anyio`, but not `project`.
uv_snapshot!(context.filters(), context.sync().arg("--no-locals"), @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(())
}

/// Avoid syncing local dependencies for path dependencies when `--no-locals` is provided, but
/// include the path dependency's dependencies.
#[test]
fn no_locals_path_dependency() -> 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", "child"]
[tool.uv.sources]
child = { path = "./child" }
"#,
)?;

// Add a local dependency.
let child = context.temp_dir.child("child");
child.child("pyproject.toml").write_str(
r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
"#,
)?;

// Generate a lockfile.
context.lock().assert().success();

// Running with `--no-locals` should install `anyio` and `iniconfig`, but not `project` or
// `child`.
uv_snapshot!(context.filters(), context.sync().arg("--no-locals"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==3.7.0
+ idna==3.6
+ iniconfig==2.0.0
+ sniffio==1.3.1
"###);

Ok(())
}

/// Avoid syncing local dependencies for workspace dependencies when `--no-locals` is provided, but
/// include the workspace dependency's dependencies.
#[test]
fn no_locals_workspace_dependency() -> 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", "child"]
[tool.uv.workspace]
members = ["child"]
[tool.uv.sources]
child = { workspace = true }
"#,
)?;

// Add a local dependency.
let child = context.temp_dir.child("child");
child.child("pyproject.toml").write_str(
r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig>1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
child
.child("src")
.child("child")
.child("__init__.py")
.touch()?;

// Generate a lockfile.
context.lock().assert().success();

// Running with `--no-locals` should install `anyio` and `iniconfig`, but not `project` or
// `child`.
uv_snapshot!(context.filters(), context.sync().arg("--no-locals"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==3.7.0
+ idna==3.6
+ iniconfig==2.0.0
+ sniffio==1.3.1
"###);

Ok(())
}
34 changes: 32 additions & 2 deletions docs/guides/integration/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -169,3 +169,33 @@ 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-locals`.

`uv sync --no-locals` will install all remote dependencies, but ignore any local dependencies,
including the project itself. Since remote dependencies are typically immutable (whereas local
dependencies change frequently), installing remote dependencies upfront can be a significant time
saver.

```dockerfile title="Dockerfile"
# Install uv
FROM python:3.12-slim-bullseye
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

# Copy the lockfile into the image
ADD uv.lock /app/uv.lock

# Install remote dependencies
WORKDIR /app
RUN uv sync --frozen --no-locals

# Copy the project into the image
ADD . /app
WORKDIR /app

# Sync the remaining dependencies
RUN uv sync --frozen
```

0 comments on commit 508fdc6

Please sign in to comment.