Add bounds in `uv add --script` (#16954)

## Summary

Closes https://github.com/astral-sh/uv/issues/15544.
This commit is contained in:
Charlie Marsh 2025-12-03 05:37:04 -08:00 committed by GitHub
parent 932d7b8fce
commit e00cc8c35f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 121 additions and 72 deletions

View File

@ -708,17 +708,14 @@ pub(crate) async fn add(
return Ok(ExitStatus::Success); return Ok(ExitStatus::Success);
} }
// If we're modifying a script, and lockfile doesn't exist, don't create it. // If we're modifying a script, and lockfile doesn't exist, avoid creating it. We still need
if let AddTarget::Script(ref script, _) = target { // to perform resolution, since we want to use the resolved versions to populate lower bounds
if !LockTarget::from(script).lock_path().is_file() { // in the script.
writeln!( let dry_run = if let AddTarget::Script(ref script, _) = target {
printer.stderr(), !LockTarget::from(script).lock_path().is_file()
"Updated `{}`", } else {
script.path.user_display().cyan() false
)?; };
return Ok(ExitStatus::Success);
}
}
// Update the `pypackage.toml` in-memory. // Update the `pypackage.toml` in-memory.
let target = target.update(&content)?; let target = target.update(&content)?;
@ -763,6 +760,7 @@ pub(crate) async fn add(
&defaulted_groups, &defaulted_groups,
raw, raw,
bounds, bounds,
dry_run,
constraints, constraints,
&settings, &settings,
&client_builder, &client_builder,
@ -1004,6 +1002,7 @@ async fn lock_and_sync(
groups: &DependencyGroupsWithDefaults, groups: &DependencyGroupsWithDefaults,
raw: bool, raw: bool,
bound_kind: Option<AddBoundsKind>, bound_kind: Option<AddBoundsKind>,
dry_run: bool,
constraints: Vec<NameRequirementSpecification>, constraints: Vec<NameRequirementSpecification>,
settings: &ResolverInstallerSettings, settings: &ResolverInstallerSettings,
client_builder: &BaseClientBuilder<'_>, client_builder: &BaseClientBuilder<'_>,
@ -1017,6 +1016,8 @@ async fn lock_and_sync(
project::lock::LockOperation::new( project::lock::LockOperation::new(
if let LockCheck::Enabled(lock_check) = lock_check { if let LockCheck::Enabled(lock_check) = lock_check {
LockMode::Locked(target.interpreter(), lock_check) LockMode::Locked(target.interpreter(), lock_check)
} else if dry_run {
LockMode::DryRun(target.interpreter())
} else { } else {
LockMode::Write(target.interpreter()) LockMode::Write(target.interpreter())
}, },
@ -1140,6 +1141,8 @@ async fn lock_and_sync(
project::lock::LockOperation::new( project::lock::LockOperation::new(
if let LockCheck::Enabled(lock_check) = lock_check { if let LockCheck::Enabled(lock_check) = lock_check {
LockMode::Locked(target.interpreter(), lock_check) LockMode::Locked(target.interpreter(), lock_check)
} else if dry_run {
LockMode::DryRun(target.interpreter())
} else { } else {
LockMode::Write(target.interpreter()) LockMode::Write(target.interpreter())
}, },

View File

@ -3830,14 +3830,14 @@ fn add_update_git_reference_script() -> Result<()> {
})?; })?;
uv_snapshot!(context.filters(), context.add().arg("--script=script.py").arg("https://github.com/astral-test/uv-public-pypackage.git"), uv_snapshot!(context.filters(), context.add().arg("--script=script.py").arg("https://github.com/astral-test/uv-public-pypackage.git"),
@r###" @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 1 package in [TIME]
"### "
); );
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -3863,14 +3863,14 @@ fn add_update_git_reference_script() -> Result<()> {
}); });
uv_snapshot!(context.filters(), context.add().arg("--script=script.py").arg("uv-public-pypackage").arg("--branch=test-branch"), uv_snapshot!(context.filters(), context.add().arg("--script=script.py").arg("uv-public-pypackage").arg("--branch=test-branch"),
@r###" @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 1 package in [TIME]
"### "
); );
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -6795,14 +6795,14 @@ fn add_script() -> Result<()> {
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().arg("anyio").arg("--script").arg("script.py"), @r###" uv_snapshot!(context.filters(), context.add().arg("anyio").arg("--script").arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 11 packages in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -6810,11 +6810,11 @@ fn add_script() -> Result<()> {
filters => context.filters(), filters => context.filters(),
}, { }, {
assert_snapshot!( assert_snapshot!(
script_content, @r###" script_content, @r#"
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = [ # dependencies = [
# "anyio", # "anyio>=4.3.0",
# "requests<3", # "requests<3",
# "rich", # "rich",
# ] # ]
@ -6826,6 +6826,52 @@ fn add_script() -> Result<()> {
resp = requests.get("https://peps.python.org/api/peps.json") resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json() data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#
);
});
// Adding to a script without a lockfile shouldn't create a lockfile.
assert!(!context.temp_dir.join("script.py.lock").exists());
Ok(())
}
/// Test that `--bounds` is respected when adding to a script without a lockfile.
#[test]
fn add_script_bounds() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
print("Hello, world!")
"#})?;
// Add `anyio` with `--bounds minor` to the script.
uv_snapshot!(context.filters(), context.add().arg("anyio").arg("--bounds").arg("minor").arg("--script").arg("script.py"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: The `bounds` option is in preview and may change in any future release. Pass `--preview-features add-bounds` to disable this warning.
Resolved 3 packages in [TIME]
");
let script_content = context.read("script.py");
// The script should have bounds with minor version constraint (e.g., `>=4.3.0,<4.4.0`).
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "anyio>=4.3.0,<4.4.0",
# ]
# ///
print("Hello, world!")
"### "###
); );
}); });
@ -6854,14 +6900,14 @@ fn add_script_relative_path() -> Result<()> {
print("Hello, world!") print("Hello, world!")
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().arg("./project").arg("--editable").arg("--script").arg("script.py"), @r###" uv_snapshot!(context.filters(), context.add().arg("./project").arg("--editable").arg("--script").arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 1 package in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -7084,14 +7130,14 @@ fn add_script_trailing_comment_lines() -> Result<()> {
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().arg("anyio").arg("--script").arg("script.py"), @r###" uv_snapshot!(context.filters(), context.add().arg("anyio").arg("--script").arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 11 packages in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -7099,11 +7145,11 @@ fn add_script_trailing_comment_lines() -> Result<()> {
filters => context.filters(), filters => context.filters(),
}, { }, {
assert_snapshot!( assert_snapshot!(
script_content, @r##" script_content, @r#"
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = [ # dependencies = [
# "anyio", # "anyio>=4.3.0",
# "requests<3", # "requests<3",
# "rich", # "rich",
# ] # ]
@ -7117,7 +7163,7 @@ fn add_script_trailing_comment_lines() -> Result<()> {
resp = requests.get("https://peps.python.org/api/peps.json") resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json() data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"## "#
); );
}); });
@ -7142,14 +7188,14 @@ fn add_script_without_metadata_table() -> Result<()> {
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r###" uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 9 packages in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -7157,12 +7203,12 @@ fn add_script_without_metadata_table() -> Result<()> {
filters => context.filters(), filters => context.filters(),
}, { }, {
assert_snapshot!( assert_snapshot!(
script_content, @r###" script_content, @r#"
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = [ # dependencies = [
# "requests<3", # "requests<3",
# "rich", # "rich>=13.7.1",
# ] # ]
# /// # ///
import requests import requests
@ -7171,7 +7217,7 @@ fn add_script_without_metadata_table() -> Result<()> {
resp = requests.get("https://peps.python.org/api/peps.json") resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json() data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"### "#
); );
}); });
Ok(()) Ok(())
@ -7193,14 +7239,14 @@ fn add_script_without_metadata_table_with_shebang() -> Result<()> {
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r###" uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 9 packages in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -7208,13 +7254,13 @@ fn add_script_without_metadata_table_with_shebang() -> Result<()> {
filters => context.filters(), filters => context.filters(),
}, { }, {
assert_snapshot!( assert_snapshot!(
script_content, @r###" script_content, @r#"
#!/usr/bin/env python3 #!/usr/bin/env python3
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = [ # dependencies = [
# "requests<3", # "requests<3",
# "rich", # "rich>=13.7.1",
# ] # ]
# /// # ///
import requests import requests
@ -7223,7 +7269,7 @@ fn add_script_without_metadata_table_with_shebang() -> Result<()> {
resp = requests.get("https://peps.python.org/api/peps.json") resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json() data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"### "#
); );
}); });
Ok(()) Ok(())
@ -7249,14 +7295,14 @@ fn add_script_with_metadata_table_and_shebang() -> Result<()> {
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r###" uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 9 packages in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -7264,13 +7310,13 @@ fn add_script_with_metadata_table_and_shebang() -> Result<()> {
filters => context.filters(), filters => context.filters(),
}, { }, {
assert_snapshot!( assert_snapshot!(
script_content, @r###" script_content, @r#"
#!/usr/bin/env python3 #!/usr/bin/env python3
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = [ # dependencies = [
# "requests<3", # "requests<3",
# "rich", # "rich>=13.7.1",
# ] # ]
# /// # ///
import requests import requests
@ -7279,7 +7325,7 @@ fn add_script_with_metadata_table_and_shebang() -> Result<()> {
resp = requests.get("https://peps.python.org/api/peps.json") resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json() data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"### "#
); );
}); });
Ok(()) Ok(())
@ -7301,14 +7347,14 @@ fn add_script_without_metadata_table_with_docstring() -> Result<()> {
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r###" uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 9 packages in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");
@ -7316,12 +7362,12 @@ fn add_script_without_metadata_table_with_docstring() -> Result<()> {
filters => context.filters(), filters => context.filters(),
}, { }, {
assert_snapshot!( assert_snapshot!(
script_content, @r###" script_content, @r#"
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = [ # dependencies = [
# "requests<3", # "requests<3",
# "rich", # "rich>=13.7.1",
# ] # ]
# /// # ///
"""This is a script.""" """This is a script."""
@ -7331,7 +7377,7 @@ fn add_script_without_metadata_table_with_docstring() -> Result<()> {
resp = requests.get("https://peps.python.org/api/peps.json") resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json() data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"### "#
); );
}); });
Ok(()) Ok(())
@ -7357,14 +7403,14 @@ fn add_extensionless_script() -> Result<()> {
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?; "#})?;
uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script"), @r###" uv_snapshot!(context.filters(), context.add().args(["rich", "requests<3"]).arg("--script").arg("script"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script` Resolved 9 packages in [TIME]
"###); ");
let script_content = context.read("script"); let script_content = context.read("script");
@ -7372,13 +7418,13 @@ fn add_extensionless_script() -> Result<()> {
filters => context.filters(), filters => context.filters(),
}, { }, {
assert_snapshot!( assert_snapshot!(
script_content, @r###" script_content, @r#"
#!/usr/bin/env python3 #!/usr/bin/env python3
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = [ # dependencies = [
# "requests<3", # "requests<3",
# "rich", # "rich>=13.7.1",
# ] # ]
# /// # ///
import requests import requests
@ -7387,7 +7433,7 @@ fn add_extensionless_script() -> Result<()> {
resp = requests.get("https://peps.python.org/api/peps.json") resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json() data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10]) pprint([(k, v["title"]) for k, v in data.items()][:10])
"### "#
); );
}); });
Ok(()) Ok(())
@ -8317,14 +8363,14 @@ fn add_git_to_script() -> Result<()> {
.arg("uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage") .arg("uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage")
.arg("--tag=0.0.1") .arg("--tag=0.0.1")
.arg("--script") .arg("--script")
.arg("script.py"), @r###" .arg("script.py"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Updated `script.py` Resolved 4 packages in [TIME]
"###); ");
let script_content = context.read("script.py"); let script_content = context.read("script.py");