Skip to content

Commit

Permalink
Un-cache editable requirements with dynamic metadata (#2029)
Browse files Browse the repository at this point in the history
Closes #1991.
  • Loading branch information
charliermarsh authored Feb 28, 2024
1 parent 8214bfe commit 72a5eba
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 34 deletions.
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions crates/uv-installer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ once-map = { path = "../once-map" }
pep440_rs = { path = "../pep440-rs" }
pep508_rs = { path = "../pep508-rs" }
platform-tags = { path = "../platform-tags" }
pypi-types = { path = "../pypi-types" }
requirements-txt = { path = "../requirements-txt" }
uv-cache = { path = "../uv-cache" }
uv-client = { path = "../uv-client" }
uv-distribution = { path = "../uv-distribution" }
Expand All @@ -29,16 +31,17 @@ uv-git = { path = "../uv-git", features = ["vendored-openssl"] }
uv-interpreter = { path = "../uv-interpreter" }
uv-normalize = { path = "../uv-normalize" }
uv-traits = { path = "../uv-traits" }
pypi-types = { path = "../pypi-types" }
requirements-txt = { path = "../requirements-txt" }

anyhow = { workspace = true }
fs-err = { workspace = true }
futures = { workspace = true }
pyproject-toml = { workspace = true }
rayon = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
56 changes: 56 additions & 0 deletions crates/uv-installer/src/editable.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
use pyproject_toml::Project;
use serde::Deserialize;

use distribution_types::{
CachedDist, InstalledDist, InstalledMetadata, InstalledVersion, LocalEditable, Name,
};
use pypi_types::Metadata21;
use requirements_txt::EditableRequirement;
use uv_cache::ArchiveTimestamp;
use uv_normalize::PackageName;

/// An editable distribution that has been built.
Expand Down Expand Up @@ -63,3 +68,54 @@ impl std::fmt::Display for ResolvedEditable {
write!(f, "{}{}", self.name(), self.installed_version())
}
}

/// Returns `true` if the installed distribution is up-to-date with the [`EditableRequirement`].
pub fn not_modified(editable: &EditableRequirement, installed: &InstalledDist) -> bool {
let Ok(Some(installed_at)) = ArchiveTimestamp::from_path(installed.path().join("METADATA"))
else {
return false;
};
let Ok(Some(modified_at)) = ArchiveTimestamp::from_path(&editable.path) else {
return false;
};
installed_at > modified_at
}

/// Returns `true` if the [`EditableRequirement`] contains dynamic metadata.
pub fn is_dynamic(editable: &EditableRequirement) -> bool {
// If there's no `pyproject.toml`, we assume it's dynamic.
let Ok(contents) = fs_err::read_to_string(editable.path.join("pyproject.toml")) else {
return true;
};
let Ok(pyproject_toml) = toml::from_str::<PyProjectToml>(&contents) else {
return true;
};
// If `[project]` is not present, we assume it's dynamic.
let Some(project) = pyproject_toml.project else {
// ...unless it appears to be a Poetry project.
return pyproject_toml
.tool
.map_or(true, |tool| tool.poetry.is_none());
};
// `[project.dynamic]` must be present and non-empty.
project.dynamic.is_some_and(|dynamic| !dynamic.is_empty())
}

/// A pyproject.toml as specified in PEP 517.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct PyProjectToml {
project: Option<Project>,
tool: Option<Tool>,
}

#[derive(Deserialize, Debug)]
struct Tool {
poetry: Option<ToolPoetry>,
}

#[derive(Deserialize, Debug)]
struct ToolPoetry {
#[allow(dead_code)]
name: Option<String>,
}
4 changes: 2 additions & 2 deletions crates/uv-installer/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
pub use downloader::{Downloader, Reporter as DownloadReporter};
pub use editable::{BuiltEditable, ResolvedEditable};
pub use editable::{is_dynamic, not_modified, BuiltEditable, ResolvedEditable};
pub use installer::{Installer, Reporter as InstallReporter};
pub use plan::{Plan, Planner, Reinstall};
// TODO(zanieb): Just import this properly everywhere else
pub use site_packages::SitePackages;
pub use uninstall::uninstall;
pub use uv_traits::NoBinary;

mod downloader;
mod editable;
mod installer;
Expand Down
17 changes: 7 additions & 10 deletions crates/uv-installer/src/site_packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ use distribution_types::{InstalledDist, InstalledMetadata, InstalledVersion, Nam
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::{Requirement, VerbatimUrl};
use requirements_txt::EditableRequirement;
use uv_cache::ArchiveTimestamp;
use uv_interpreter::Virtualenv;
use uv_normalize::PackageName;

use crate::{is_dynamic, not_modified};

/// An index over the packages installed in an environment.
///
/// Packages are indexed by both name and (for editable installs) URL.
Expand Down Expand Up @@ -275,16 +276,12 @@ impl<'a> SitePackages<'a> {
}
[distribution] => {
// Is the editable out-of-date?
let Ok(Some(installed_at)) =
ArchiveTimestamp::from_path(distribution.path().join("METADATA"))
else {
return Ok(false);
};
let Ok(Some(modified_at)) = ArchiveTimestamp::from_path(&requirement.path)
else {
if !not_modified(requirement, distribution) {
return Ok(false);
};
if modified_at > installed_at {
}

// Does the editable have dynamic metadata?
if is_dynamic(requirement) {
return Ok(false);
}

Expand Down
27 changes: 7 additions & 20 deletions crates/uv/src/commands/pip_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,19 @@ use itertools::Itertools;
use owo_colors::OwoColorize;
use tracing::debug;

use distribution_types::{
IndexLocations, InstalledDist, InstalledMetadata, LocalDist, LocalEditable, Name,
};
use distribution_types::{IndexLocations, InstalledMetadata, LocalDist, LocalEditable, Name};
use install_wheel_rs::linker::LinkMode;
use platform_host::Platform;
use platform_tags::Tags;
use pypi_types::Yanked;
use requirements_txt::EditableRequirement;
use uv_cache::{ArchiveTimestamp, Cache};
use uv_cache::Cache;
use uv_client::{Connectivity, FlatIndex, FlatIndexClient, RegistryClient, RegistryClientBuilder};
use uv_dispatch::BuildDispatch;
use uv_fs::Normalized;
use uv_installer::{
Downloader, NoBinary, Plan, Planner, Reinstall, ResolvedEditable, SitePackages,
is_dynamic, not_modified, Downloader, NoBinary, Plan, Planner, Reinstall, ResolvedEditable,
SitePackages,
};
use uv_interpreter::Virtualenv;
use uv_resolver::InMemoryIndex;
Expand Down Expand Up @@ -393,18 +392,6 @@ async fn resolve_editables(
build_dispatch: &BuildDispatch<'_>,
mut printer: Printer,
) -> Result<ResolvedEditables> {
/// Returns `true` if the installed distribution is up-to-date.
fn not_modified(editable: &EditableRequirement, installed: &InstalledDist) -> bool {
let Ok(Some(installed_at)) = ArchiveTimestamp::from_path(installed.path().join("METADATA"))
else {
return false;
};
let Ok(Some(modified_at)) = ArchiveTimestamp::from_path(&editable.path) else {
return false;
};
installed_at > modified_at
}

// Partition the editables into those that are already installed, and those that must be built.
let mut installed = Vec::with_capacity(editables.len());
let mut uninstalled = Vec::with_capacity(editables.len());
Expand All @@ -415,7 +402,7 @@ async fn resolve_editables(
match existing.as_slice() {
[] => uninstalled.push(editable),
[dist] => {
if not_modified(&editable, dist) {
if not_modified(&editable, dist) && !is_dynamic(&editable) {
installed.push((*dist).clone());
} else {
uninstalled.push(editable);
Expand All @@ -433,10 +420,10 @@ async fn resolve_editables(
let existing = site_packages.get_editables(editable.raw());
match existing.as_slice() {
[] => uninstalled.push(editable),
[dist] if not_modified(&editable, dist) => {
[dist] => {
if packages.contains(dist.name()) {
uninstalled.push(editable);
} else if not_modified(&editable, dist) {
} else if not_modified(&editable, dist) && !is_dynamic(&editable) {
installed.push((*dist).clone());
} else {
uninstalled.push(editable);
Expand Down
90 changes: 90 additions & 0 deletions crates/uv/tests/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1937,3 +1937,93 @@ requires-python = ">=3.8"

Ok(())
}

#[test]
fn invalidate_dynamic() -> Result<()> {
let context = TestContext::new("3.12");

// Create an editable package with dynamic metadata
let editable_dir = assert_fs::TempDir::new()?;
let pyproject_toml = editable_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "example"
version = "0.1.0"
dynamic = ["dependencies"]
requires-python = ">=3.11,<3.13"
[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}
"#,
)?;

let requirements_txt = editable_dir.child("requirements.txt");
requirements_txt.write_str("anyio==4.0.0")?;

let filters = [(r"\(from file://.*\)", "(from [WORKSPACE_DIR])")]
.into_iter()
.chain(INSTA_FILTERS.to_vec())
.collect::<Vec<_>>();

uv_snapshot!(filters, command(&context)
.arg("--editable")
.arg(editable_dir.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Built 1 editable in [TIME]
Resolved 4 packages in [TIME]
Downloaded 3 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==4.0.0
+ example==0.1.0 (from [WORKSPACE_DIR])
+ idna==3.4
+ sniffio==1.3.0
"###
);

// Re-installing should re-install.
uv_snapshot!(filters, command(&context)
.arg("--editable")
.arg(editable_dir.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Built 1 editable in [TIME]
Resolved 4 packages in [TIME]
Installed 1 package in [TIME]
- example==0.1.0 (from [WORKSPACE_DIR])
+ example==0.1.0 (from [WORKSPACE_DIR])
"###
);

// Modify the requirements.
requirements_txt.write_str("anyio==3.7.1")?;

// Re-installing should update the package.
uv_snapshot!(filters, command(&context)
.arg("--editable")
.arg(editable_dir.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Built 1 editable in [TIME]
Resolved 4 packages in [TIME]
Downloaded 1 package in [TIME]
Installed 2 packages in [TIME]
- anyio==4.0.0
+ anyio==3.7.1
- example==0.1.0 (from [WORKSPACE_DIR])
+ example==0.1.0 (from [WORKSPACE_DIR])
"###
);

Ok(())
}

0 comments on commit 72a5eba

Please sign in to comment.