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
83 changes: 83 additions & 0 deletions crates/uv-workspace/src/pyproject_mut.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,41 @@ impl PyProjectTomlMut {
Ok(added)
}

/// Ensure that an optional dependency group exists, creating an empty group if it doesn't.
pub fn ensure_optional_dependency(&mut self, extra: &ExtraName) -> Result<(), Error> {
// Get or create `project.optional-dependencies`.
let optional_dependencies = self
.project()?
.entry("optional-dependencies")
.or_insert(Item::Table(Table::new()))
.as_table_like_mut()
.ok_or(Error::MalformedDependencies)?;

// Check if the extra already exists.
let extra_exists = optional_dependencies
.iter()
.any(|(key, _value)| ExtraName::from_str(key).is_ok_and(|e| e == *extra));

// If the extra doesn't exist, create it.
if !extra_exists {
optional_dependencies.insert(extra.as_ref(), Item::Value(Value::Array(Array::new())));
}

// If `project.optional-dependencies` is an inline table, reformat it.
//
// Reformatting can drop comments between keys, but you can't put comments
// between items in an inline table anyway.
if let Some(optional_dependencies) = self
.project()?
.get_mut("optional-dependencies")
.and_then(Item::as_inline_table_mut)
{
optional_dependencies.fmt();
}

Ok(())
}

/// Adds a dependency to `dependency-groups`.
///
/// Returns `true` if the dependency was added, `false` if it was updated.
Expand Down Expand Up @@ -693,6 +728,54 @@ impl PyProjectTomlMut {
Ok(added)
}

/// Ensure that a dependency group exists, creating an empty group if it doesn't.
pub fn ensure_dependency_group(&mut self, group: &GroupName) -> Result<(), Error> {
// Get or create `dependency-groups`.
let dependency_groups = self
.doc
.entry("dependency-groups")
.or_insert(Item::Table(Table::new()))
.as_table_like_mut()
.ok_or(Error::MalformedDependencies)?;

let was_sorted = dependency_groups
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we doing this check for dependency groups, but not for extras?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure but it's consistent with the other methods. I can look in a separate PR.

.get_values()
.iter()
.filter_map(|(dotted_ks, _)| dotted_ks.first())
.map(|k| k.get())
.is_sorted();

// Check if the group already exists.
let group_exists = dependency_groups
.iter()
.any(|(key, _value)| GroupName::from_str(key).is_ok_and(|g| g == *group));

// If the group doesn't exist, create it.
if !group_exists {
dependency_groups.insert(group.as_ref(), Item::Value(Value::Array(Array::new())));

// To avoid churn in pyproject.toml, we only sort new group keys if the
// existing keys were sorted.
if was_sorted {
dependency_groups.sort_values();
}
}

// If `dependency-groups` is an inline table, reformat it.
//
// Reformatting can drop comments between keys, but you can't put comments
// between items in an inline table anyway.
if let Some(dependency_groups) = self
.doc
.get_mut("dependency-groups")
.and_then(Item::as_inline_table_mut)
{
dependency_groups.fmt();
}

Ok(())
}

/// Set the constraint for a requirement for an existing dependency.
pub fn set_dependency_bound(
&mut self,
Expand Down
16 changes: 16 additions & 0 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,22 @@ pub(crate) async fn add(
&mut toml,
)?;

// If no requirements were added but a dependency group or optional dependency was specified,
// ensure the group/extra exists. This handles the case where `uv add -r requirements.txt
// --group <name>` or `uv add -r requirements.txt --optional <extra>` is called with an empty
// requirements file.
if edits.is_empty() {
match &dependency_type {
DependencyType::Group(group) => {
toml.ensure_dependency_group(group)?;
}
DependencyType::Optional(extra) => {
toml.ensure_optional_dependency(extra)?;
}
_ => {}
}
}

// Validate any indexes that were provided on the command-line to ensure
// they point to existing non-empty directories when using path URLs.
let mut valid_indexes = Vec::with_capacity(indexes.len());
Expand Down
106 changes: 106 additions & 0 deletions crates/uv/tests/it/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4954,6 +4954,112 @@ fn add_virtual_dependency_group() -> Result<()> {
Ok(())
}

#[test]
fn add_empty_requirements_group() -> Result<()> {
// Test that `uv add -r requirements.txt --group <name>` creates an empty group
// when the requirements file is empty
let context = TestContext::new("3.12");

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

let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.write_str("")?;

uv_snapshot!(context.filters(), context.add()
.arg("-r").arg("requirements.txt")
.arg("--group").arg("user"), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
warning: Requirements file `requirements.txt` does not contain any dependencies
Resolved 1 package in [TIME]
Audited in [TIME]
");

let pyproject_toml = context.read("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 = []

[dependency-groups]
user = []
"###
);
});

Ok(())
}

#[test]
fn add_empty_requirements_optional() -> Result<()> {
// Test that `uv add -r requirements.txt --optional <extra>` creates an empty extra
// when the requirements file is empty
let context = TestContext::new("3.12");

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

let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.write_str("")?;

uv_snapshot!(context.filters(), context.add()
.arg("-r").arg("requirements.txt")
.arg("--optional").arg("extra"), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
warning: Requirements file `requirements.txt` does not contain any dependencies
Resolved 1 package in [TIME]
Audited in [TIME]
");

let pyproject_toml = context.read("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 = []

[project.optional-dependencies]
extra = []
"###
);
});

Ok(())
}

#[test]
fn remove_virtual_empty() -> Result<()> {
// testing how `uv remove` reacts to a pyproject with no `[project]` and nothing useful to it
Expand Down
Loading