Skip to content

Commit 4527b75

Browse files
committed
Add an empty group with uv add -r
1 parent 61c67be commit 4527b75

File tree

3 files changed

+205
-0
lines changed

3 files changed

+205
-0
lines changed

crates/uv-workspace/src/pyproject_mut.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,41 @@ impl PyProjectTomlMut {
622622
Ok(added)
623623
}
624624

625+
/// Ensure that an optional dependency group exists, creating an empty group if it doesn't.
626+
pub fn ensure_optional_dependency(&mut self, extra: &ExtraName) -> Result<(), Error> {
627+
// Get or create `project.optional-dependencies`.
628+
let optional_dependencies = self
629+
.project()?
630+
.entry("optional-dependencies")
631+
.or_insert(Item::Table(Table::new()))
632+
.as_table_like_mut()
633+
.ok_or(Error::MalformedDependencies)?;
634+
635+
// Check if the extra already exists.
636+
let extra_exists = optional_dependencies.iter().any(|(key, _value)| {
637+
ExtraName::from_str(key).is_ok_and(|e| e == *extra)
638+
});
639+
640+
// If the extra doesn't exist, create it.
641+
if !extra_exists {
642+
optional_dependencies.insert(extra.as_ref(), Item::Value(Value::Array(Array::new())));
643+
}
644+
645+
// If `project.optional-dependencies` is an inline table, reformat it.
646+
//
647+
// Reformatting can drop comments between keys, but you can't put comments
648+
// between items in an inline table anyway.
649+
if let Some(optional_dependencies) = self
650+
.project()?
651+
.get_mut("optional-dependencies")
652+
.and_then(Item::as_inline_table_mut)
653+
{
654+
optional_dependencies.fmt();
655+
}
656+
657+
Ok(())
658+
}
659+
625660
/// Adds a dependency to `dependency-groups`.
626661
///
627662
/// Returns `true` if the dependency was added, `false` if it was updated.
@@ -693,6 +728,54 @@ impl PyProjectTomlMut {
693728
Ok(added)
694729
}
695730

731+
/// Ensure that a dependency group exists, creating an empty group if it doesn't.
732+
pub fn ensure_dependency_group(&mut self, group: &GroupName) -> Result<(), Error> {
733+
// Get or create `dependency-groups`.
734+
let dependency_groups = self
735+
.doc
736+
.entry("dependency-groups")
737+
.or_insert(Item::Table(Table::new()))
738+
.as_table_like_mut()
739+
.ok_or(Error::MalformedDependencies)?;
740+
741+
let was_sorted = dependency_groups
742+
.get_values()
743+
.iter()
744+
.filter_map(|(dotted_ks, _)| dotted_ks.first())
745+
.map(|k| k.get())
746+
.is_sorted();
747+
748+
// Check if the group already exists.
749+
let group_exists = dependency_groups.iter().any(|(key, _value)| {
750+
GroupName::from_str(key).is_ok_and(|g| g == *group)
751+
});
752+
753+
// If the group doesn't exist, create it.
754+
if !group_exists {
755+
dependency_groups.insert(group.as_ref(), Item::Value(Value::Array(Array::new())));
756+
757+
// To avoid churn in pyproject.toml, we only sort new group keys if the
758+
// existing keys were sorted.
759+
if was_sorted {
760+
dependency_groups.sort_values();
761+
}
762+
}
763+
764+
// If `dependency-groups` is an inline table, reformat it.
765+
//
766+
// Reformatting can drop comments between keys, but you can't put comments
767+
// between items in an inline table anyway.
768+
if let Some(dependency_groups) = self
769+
.doc
770+
.get_mut("dependency-groups")
771+
.and_then(Item::as_inline_table_mut)
772+
{
773+
dependency_groups.fmt();
774+
}
775+
776+
Ok(())
777+
}
778+
696779
/// Set the constraint for a requirement for an existing dependency.
697780
pub fn set_dependency_bound(
698781
&mut self,

crates/uv/src/commands/project/add.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,22 @@ pub(crate) async fn add(
645645
&mut toml,
646646
)?;
647647

648+
// If no requirements were added but a dependency group or optional dependency was specified,
649+
// ensure the group/extra exists. This handles the case where `uv add -r requirements.txt
650+
// --group <name>` or `uv add -r requirements.txt --optional <extra>` is called with an empty
651+
// requirements file.
652+
if edits.is_empty() {
653+
match &dependency_type {
654+
DependencyType::Group(group) => {
655+
toml.ensure_dependency_group(group)?;
656+
}
657+
DependencyType::Optional(extra) => {
658+
toml.ensure_optional_dependency(extra)?;
659+
}
660+
_ => {}
661+
}
662+
}
663+
648664
// Validate any indexes that were provided on the command-line to ensure
649665
// they point to existing non-empty directories when using path URLs.
650666
let mut valid_indexes = Vec::with_capacity(indexes.len());

crates/uv/tests/it/edit.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4954,6 +4954,112 @@ fn add_virtual_dependency_group() -> Result<()> {
49544954
Ok(())
49554955
}
49564956

4957+
#[test]
4958+
fn add_empty_requirements_group() -> Result<()> {
4959+
// Test that `uv add -r requirements.txt --group <name>` creates an empty group
4960+
// when the requirements file is empty
4961+
let context = TestContext::new("3.12");
4962+
4963+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
4964+
pyproject_toml.write_str(indoc! {r#"
4965+
[project]
4966+
name = "project"
4967+
version = "0.1.0"
4968+
requires-python = ">=3.12"
4969+
dependencies = []
4970+
"#})?;
4971+
4972+
let requirements_txt = context.temp_dir.child("requirements.txt");
4973+
requirements_txt.write_str("")?;
4974+
4975+
uv_snapshot!(context.filters(), context.add()
4976+
.arg("-r").arg("requirements.txt")
4977+
.arg("--group").arg("user"), @r"
4978+
success: true
4979+
exit_code: 0
4980+
----- stdout -----
4981+
4982+
----- stderr -----
4983+
warning: Requirements file `requirements.txt` does not contain any dependencies
4984+
Resolved 1 package in [TIME]
4985+
Audited in [TIME]
4986+
");
4987+
4988+
let pyproject_toml = context.read("pyproject.toml");
4989+
4990+
insta::with_settings!({
4991+
filters => context.filters(),
4992+
}, {
4993+
assert_snapshot!(
4994+
pyproject_toml, @r###"
4995+
[project]
4996+
name = "project"
4997+
version = "0.1.0"
4998+
requires-python = ">=3.12"
4999+
dependencies = []
5000+
5001+
[dependency-groups]
5002+
user = []
5003+
"###
5004+
);
5005+
});
5006+
5007+
Ok(())
5008+
}
5009+
5010+
#[test]
5011+
fn add_empty_requirements_optional() -> Result<()> {
5012+
// Test that `uv add -r requirements.txt --optional <extra>` creates an empty extra
5013+
// when the requirements file is empty
5014+
let context = TestContext::new("3.12");
5015+
5016+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
5017+
pyproject_toml.write_str(indoc! {r#"
5018+
[project]
5019+
name = "project"
5020+
version = "0.1.0"
5021+
requires-python = ">=3.12"
5022+
dependencies = []
5023+
"#})?;
5024+
5025+
let requirements_txt = context.temp_dir.child("requirements.txt");
5026+
requirements_txt.write_str("")?;
5027+
5028+
uv_snapshot!(context.filters(), context.add()
5029+
.arg("-r").arg("requirements.txt")
5030+
.arg("--optional").arg("extra"), @r"
5031+
success: true
5032+
exit_code: 0
5033+
----- stdout -----
5034+
5035+
----- stderr -----
5036+
warning: Requirements file `requirements.txt` does not contain any dependencies
5037+
Resolved 1 package in [TIME]
5038+
Audited in [TIME]
5039+
");
5040+
5041+
let pyproject_toml = context.read("pyproject.toml");
5042+
5043+
insta::with_settings!({
5044+
filters => context.filters(),
5045+
}, {
5046+
assert_snapshot!(
5047+
pyproject_toml, @r###"
5048+
[project]
5049+
name = "project"
5050+
version = "0.1.0"
5051+
requires-python = ">=3.12"
5052+
dependencies = []
5053+
5054+
[project.optional-dependencies]
5055+
extra = []
5056+
"###
5057+
);
5058+
});
5059+
5060+
Ok(())
5061+
}
5062+
49575063
#[test]
49585064
fn remove_virtual_empty() -> Result<()> {
49595065
// testing how `uv remove` reacts to a pyproject with no `[project]` and nothing useful to it

0 commit comments

Comments
 (0)