Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 125 additions & 15 deletions crates/uv-workspace/src/pyproject_mut.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ use toml_edit::{
};

use uv_cache_key::CanonicalUrl;
use uv_distribution_types::Index;
use uv_fs::PortablePath;
use uv_distribution_types::{Index, IndexFormat, IndexUrl};
use uv_fs::{PortablePath, is_same_file_allow_missing};
use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::{Version, VersionParseError, VersionSpecifier, VersionSpecifiers};
use uv_pep508::{MarkerTree, Requirement, VersionOrUrl};
use uv_redacted::DisplaySafeUrl;

use crate::pyproject::{DependencyType, Source};

Expand All @@ -29,6 +28,21 @@ pub struct PyProjectTomlMut {
target: DependencyTarget,
}

fn index_locations_equal(existing: &str, incoming: &IndexUrl, root_dir: &Path) -> bool {
let Ok(existing) = IndexUrl::parse(existing, Some(root_dir)) else {
return false;
};

if let (IndexUrl::Path(existing), IndexUrl::Path(incoming)) = (&existing, incoming)
&& let (Ok(existing), Ok(incoming)) = (existing.to_file_path(), incoming.to_file_path())
&& let Some(equal) = is_same_file_allow_missing(&existing, &incoming)
{
return equal;
}

CanonicalUrl::new(existing.url()) == CanonicalUrl::new(incoming.url())
}

#[derive(Error, Debug)]
pub enum Error {
#[error("Failed to parse `pyproject.toml`")]
Expand Down Expand Up @@ -428,7 +442,7 @@ impl PyProjectTomlMut {
}

/// Add an [`Index`] to `tool.uv.index`.
pub fn add_index(&mut self, index: &Index) -> Result<(), Error> {
pub fn add_index(&mut self, index: &Index, root_dir: &Path) -> Result<(), Error> {
let size = self.doc.len();
let existing = self
.doc
Expand Down Expand Up @@ -472,10 +486,7 @@ impl PyProjectTomlMut {
if table
.get("url")
.and_then(|item| item.as_str())
.and_then(|url| DisplaySafeUrl::parse(url).ok())
.is_some_and(|url| {
CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url())
})
.is_some_and(|url| index_locations_equal(url, &index.url, root_dir))
{
return true;
}
Expand Down Expand Up @@ -504,12 +515,16 @@ impl PyProjectTomlMut {
table.insert("name", Value::String(formatted).into());
}

let existing_url = table.get("url").and_then(|item| item.as_str());

// Update the stored URL independently of whether the index location changed.
let url_needs_update =
existing_url.is_none_or(|url| url != index.url.without_credentials().as_str());
let index_location_changed =
existing_url.is_none_or(|url| !index_locations_equal(url, &index.url, root_dir));

// If necessary, update the URL.
if table
.get("url")
.and_then(|item| item.as_str())
.is_none_or(|url| url != index.url.without_credentials().as_str())
{
if url_needs_update {
let mut formatted = Formatted::new(index.url.without_credentials().to_string());
if let Some(value) = table.get("url").and_then(Item::as_value) {
if let Some(prefix) = value.decor().prefix() {
Expand Down Expand Up @@ -542,6 +557,34 @@ impl PyProjectTomlMut {
}
}

// If the index location changed, sync the format to match the incoming index.
if index_location_changed {
match index.format {
IndexFormat::Flat => {
if table
.get("format")
.and_then(Item::as_str)
.is_none_or(|format| format != "flat")
{
let mut formatted = Formatted::new("flat".to_string());
if let Some(value) = table.get("format").and_then(Item::as_value) {
if let Some(prefix) = value.decor().prefix() {
formatted.decor_mut().set_prefix(prefix.clone());
}
if let Some(suffix) = value.decor().suffix() {
formatted.decor_mut().set_suffix(suffix.clone());
}
}
table.insert("format", Value::String(formatted).into());
}
}
IndexFormat::Simple => {
// Remove the format key if it exists (Simple is the default).
table.remove("format");
}
}
}

// Remove any replaced tables.
existing.retain(|table| {
// If the index has the same name, skip it.
Expand All @@ -567,8 +610,7 @@ impl PyProjectTomlMut {
if table
.get("url")
.and_then(|item| item.as_str())
.and_then(|url| DisplaySafeUrl::parse(url).ok())
.is_some_and(|url| CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url()))
.is_some_and(|url| index_locations_equal(url, &index.url, root_dir))
{
return false;
}
Expand Down Expand Up @@ -1829,8 +1871,10 @@ mod test {
};
use anyhow::Result;
use insta::assert_snapshot;
use std::path::Path;
use std::str::FromStr;
use toml_edit::DocumentMut;
use uv_distribution_types::Index;
use uv_normalize::PackageName;
use uv_pep440::Version;
use uv_pep508::Requirement;
Expand Down Expand Up @@ -2283,4 +2327,70 @@ dependencies = [
"#
);
}

#[test]
fn add_index_syncs_format_on_url_update() {
let toml = r#"
[[tool.uv.index]]
name = "index"
url = "https://example.com/flat/"
format = "flat"
"#;

let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml).unwrap();

// The URL spelling changes, but the canonical URL does not, so format should be preserved.
let equivalent_index = Index::from_str("index=https://example.com/flat").unwrap();
doc.add_index(&equivalent_index, Path::new(".")).unwrap();

assert_snapshot!(doc.to_string(), @r#"

[[tool.uv.index]]
name = "index"
url = "https://example.com/flat"
format = "flat"
"#);

let new_index = Index::from_str("index=https://pypi.org/simple").unwrap();
doc.add_index(&new_index, Path::new(".")).unwrap();

assert_snapshot!(doc.to_string(), @r#"

[[tool.uv.index]]
name = "index"
url = "https://pypi.org/simple"
"#);
}

#[cfg(windows)]
#[test]
fn add_index_preserves_format_when_windows_path_unchanged() -> Result<()> {
let toml = r#"
[[tool.uv.index]]
name = "index"
url = 'C:\links'
format = "flat"
"#;

let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml)?;

let new_index = Index::from_str(r"index=C:\links")?;
doc.add_index(&new_index, &std::env::current_dir()?)?;

let expected_url = new_index.url.without_credentials();
let index = doc.doc["tool"]["uv"]["index"]
.as_array_of_tables()
.and_then(|indexes| indexes.get(0))
.expect("index table");
assert_eq!(
index.get("url").and_then(|item| item.as_str()),
Some(expected_url.as_str())
);
assert_eq!(
index.get("format").and_then(|item| item.as_str()),
Some("flat")
);

Ok(())
}
}
8 changes: 6 additions & 2 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use uv_distribution_types::{
Identifier, Index, IndexLocations, IndexName, IndexUrl, NameRequirementSpecification,
Requirement, RequirementSource, UnresolvedRequirement,
};
use uv_fs::{LockedFile, LockedFileError, Simplified};
use uv_fs::{CWD, LockedFile, LockedFileError, Simplified};
use uv_git::store_credentials;
use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, DefaultGroups, ExtraName, PackageName};
use uv_pep508::{MarkerTree, VersionOrUrl};
Expand Down Expand Up @@ -688,11 +688,15 @@ pub(crate) async fn add(

// Add any indexes that were provided on the command-line, in priority order.
if !raw {
let root_dir = match &target {
AddTarget::Script(_, _) => CWD.as_path(),
AddTarget::Project(project, _) => project.root(),
};
let locations = IndexLocations::new(indexes, Vec::new(), false);
let mut indexes = locations.defined_indexes().collect::<Vec<_>>();
indexes.reverse();
for index in indexes {
toml.add_index(index)?;
toml.add_index(index, root_dir)?;
}
}

Expand Down
106 changes: 96 additions & 10 deletions crates/uv/tests/project/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11255,37 +11255,123 @@ fn add_index_without_trailing_slash() -> Result<()> {
fn add_index_with_existing_relative_path_index() -> Result<()> {
let context = uv_test::test_context!("3.12");

let pyproject_toml = context.temp_dir.child("pyproject.toml");
let project = context.temp_dir.child("project");
project.create_dir_all()?;
let pyproject_toml = project.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []

[[tool.uv.index]]
name = "local"
url = "./links-alias"
format = "flat"
"#})?;

// Create test-index/ subdirectory and copy our "offline" tqdm wheel there
let packages = context.temp_dir.child("test-index");
// Create a non-empty flat index.
let packages = project.child("test-index");
packages.create_dir_all()?;
packages.child("placeholder").touch()?;
uv_fs::create_symlink(packages.path(), project.child("links-alias").path())?;

let index = format!("local={}", packages.path().display());
uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--frozen").arg("--project").arg(project.path()).arg("--index").arg(index), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
");

let pyproject_toml = fs_err::read_to_string(project.join("pyproject.toml"))?;

insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(pyproject_toml, @r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"iniconfig",
]

[[tool.uv.index]]
name = "local"
url = "file://[TEMP_DIR]/project/test-index"
format = "flat"

[tool.uv.sources]
iniconfig = { index = "local" }
"#);
});

Ok(())
}

/// Add an index with an existing relative path to a script outside the working directory.
#[test]
fn add_index_with_existing_relative_path_in_script() -> Result<()> {
let context = uv_test::test_context!("3.12");

let scripts = context.temp_dir.child("scripts");
scripts.create_dir_all()?;
let script = scripts.child("main.py");
script.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.12"
# dependencies = []
#
# [[tool.uv.index]]
# name = "local"
# url = "./links"
# format = "flat"
# ///
"#})?;

let packages = context.temp_dir.child("links");
packages.create_dir_all()?;
let wheel_src = context
.workspace_root
.join("test/links/ok-1.0.0-py3-none-any.whl");
let wheel_dst = packages.child("ok-1.0.0-py3-none-any.whl");
fs_err::copy(&wheel_src, &wheel_dst)?;
fs_err::copy(&wheel_src, packages.child("ok-1.0.0-py3-none-any.whl"))?;

uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--index").arg("./test-index"), @"
uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--frozen").arg("--script").arg(script.path()).arg("--index").arg("local=./links"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
warning: `--frozen` is a no-op for Python scripts with inline metadata, which always run in isolation
");

let script = fs_err::read_to_string(script.path())?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(script, @r#"
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "iniconfig",
# ]
#
# [[tool.uv.index]]
# name = "local"
# url = "file://[TEMP_DIR]/links"
# format = "flat"
#
# [tool.uv.sources]
# iniconfig = { index = "local" }
# ///
"#);
});

Ok(())
}

Expand Down
Loading