diff --git a/crates/uv/tests/pip/pip_install_scenarios.rs b/crates/uv/tests/pip/pip_install_scenarios.rs index ff613abf6c5..16658e2d7cc 100644 --- a/crates/uv/tests/pip/pip_install_scenarios.rs +++ b/crates/uv/tests/pip/pip_install_scenarios.rs @@ -3409,6 +3409,43 @@ fn python_version_does_not_exist() { context.assert_not_installed("a"); } +/// A single package with two stable versions. +/// +/// ```text +/// single-package +/// ├── environment +/// │ └── python3.12 +/// ├── root +/// │ └── requires a +/// │ ├── satisfied by a-1.0.0 +/// │ └── satisfied by a-2.0.0 +/// └── a +/// ├── a-1.0.0 +/// └── a-2.0.0 +/// ``` +#[test] +fn single_package() { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("simple/single-package.toml"); + + uv_snapshot!(context.filters(), command(&context, &server) + .arg("a") + , @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + a==2.0.0 + "); + + // The latest version of 'a' should be selected. + context.assert_installed("a", "2.0.0"); +} + /// Both wheels and source distributions are available, and the user has disabled binaries. /// /// ```text diff --git a/crates/uv/tests/project/check.rs b/crates/uv/tests/project/check.rs index bb22bf9d464..1295c0d6e1f 100644 --- a/crates/uv/tests/project/check.rs +++ b/crates/uv/tests/project/check.rs @@ -1,11 +1,11 @@ use anyhow::Result; -#[cfg(feature = "test-pypi")] use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; use indoc::indoc; use insta::assert_snapshot; use uv_static::EnvVars; +use uv_test::packse::PackseServer; use uv_test::{diff_snapshot, uv_snapshot}; #[test] @@ -41,6 +41,7 @@ fn check_project() -> Result<()> { #[test] fn check_no_sync_creates_lock_without_sync() -> Result<()> { + let server = PackseServer::new("simple/single-package.toml"); let context = uv_test::test_context!("3.12"); context @@ -51,7 +52,7 @@ fn check_no_sync_creates_lock_without_sync() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig==2.0.0"] + dependencies = ["a==1.0.0"] "#})?; context.temp_dir.child("main.py").write_str(indoc! {r" x: int = 1 @@ -62,8 +63,8 @@ fn check_no_sync_creates_lock_without_sync() -> Result<()> { context .check() .arg("--no-sync") - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") + .arg("--index") + .arg(server.index_url()) .arg("--ty-version") .arg("0.0.17"), @" @@ -89,12 +90,12 @@ fn check_no_sync_creates_lock_without_sync() -> Result<()> { exclude-newer = "2026-02-15T00:00:00Z" [[package]] - name = "iniconfig" - version = "2.0.0" - source = { registry = "https://pypi.org/simple" } - sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + name = "a" + version = "1.0.0" + source = { registry = "http://[LOCALHOST]/simple/" } + sdist = { url = "http://[LOCALHOST]/files/a-1.0.0.tar.gz", hash = "sha256:3d2b4c28a4e112f3a1cef1db4dc5efa33fcbbcc38bc11ccc80321097db86c097", upload-time = "2024-03-24T00:00:00Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + { url = "http://[LOCALHOST]/files/a-1.0.0-py3-none-any.whl", hash = "sha256:f936eedc194aa91ca01a4c6c9981136ca6c75ce6df47e3951b12522881dce809", upload-time = "2024-03-24T00:00:00Z" }, ] [[package]] @@ -102,14 +103,14 @@ fn check_no_sync_creates_lock_without_sync() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "iniconfig" }, + { name = "a" }, ] [package.metadata] - requires-dist = [{ name = "iniconfig", specifier = "==2.0.0" }] + requires-dist = [{ name = "a", specifier = "==1.0.0" }] "#); }); - assert!(!context.site_packages().join("iniconfig").exists()); + assert!(!context.site_packages().join("a").exists()); Ok(()) } @@ -145,8 +146,6 @@ fn check_no_sync_uses_compatible_lock_interpreter() -> Result<()> { .arg("--no-sync") .arg("--python") .arg("3.11") - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") .arg("--ty-version") .arg("0.0.17"), @" @@ -172,7 +171,8 @@ fn check_no_sync_uses_compatible_lock_interpreter() -> Result<()> { #[test] fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { - let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("simple/single-package.toml"); + let context = uv_test::test_context!("3.12").with_exclude_newer("2026-02-15T00:00:00Z"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -180,12 +180,12 @@ fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["anyio==3.7.0"] + dependencies = ["a==1.0.0"] "#})?; context .lock() - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") + .arg("--index") + .arg(server.index_url()) .assert() .success(); let stale_lock = context.read("uv.lock"); @@ -195,7 +195,7 @@ fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig==2.0.0"] + dependencies = ["a==2.0.0"] "#})?; context.temp_dir.child("main.py").write_str(indoc! {r" x: int = 1 @@ -206,8 +206,8 @@ fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { context .check() .arg("--no-sync") - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") + .arg("--index") + .arg(server.index_url()) .arg("--ty-version") .arg("0.0.17"), @" @@ -229,7 +229,7 @@ fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { assert_snapshot!(diff, @r#" --- old +++ new - @@ -1,48 +1,26 @@ + @@ -1,26 +1,26 @@ version = 1 revision = 3 requires-python = ">=3.12" @@ -238,29 +238,15 @@ fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { exclude-newer = "2026-02-15T00:00:00Z" [[package]] - -name = "anyio" - -version = "3.7.0" - +name = "iniconfig" + name = "a" + -version = "1.0.0" +version = "2.0.0" - source = { registry = "https://pypi.org/simple" } - -dependencies = [ - - { name = "idna" }, - - { name = "sniffio" }, - -] - -sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737, upload-time = "2023-05-27T11:12:46.688Z" } - +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + source = { registry = "http://[LOCALHOST]/simple/" } + -sdist = { url = "http://[LOCALHOST]/files/a-1.0.0.tar.gz", hash = "sha256:3d2b4c28a4e112f3a1cef1db4dc5efa33fcbbcc38bc11ccc80321097db86c097", upload-time = "2024-03-24T00:00:00Z" } + +sdist = { url = "http://[LOCALHOST]/files/a-2.0.0.tar.gz", hash = "sha256:80ec95a66cff82a78a3333e3f5702e4254cf80533f21762933252eec58c9869a", upload-time = "2024-03-24T00:00:00Z" } wheels = [ - - { url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0", size = 80873, upload-time = "2023-05-27T11:12:44.474Z" }, - -] - - - -[[package]] - -name = "idna" - -version = "3.11" - -source = { registry = "https://pypi.org/simple" } - -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } - -wheels = [ - - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, - + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + - { url = "http://[LOCALHOST]/files/a-1.0.0-py3-none-any.whl", hash = "sha256:f936eedc194aa91ca01a4c6c9981136ca6c75ce6df47e3951b12522881dce809", upload-time = "2024-03-24T00:00:00Z" }, + + { url = "http://[LOCALHOST]/files/a-2.0.0-py3-none-any.whl", hash = "sha256:833374310e0a15880f3be9e6d082f527c9ac70129b2054d733da9b754315361f", upload-time = "2024-03-24T00:00:00Z" }, ] [[package]] @@ -268,32 +254,23 @@ fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - - { name = "anyio" }, - + { name = "iniconfig" }, + { name = "a" }, ] [package.metadata] - -requires-dist = [{ name = "anyio", specifier = "==3.7.0" }] - - - -[[package]] - -name = "sniffio" - -version = "1.3.1" - -source = { registry = "https://pypi.org/simple" } - -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } - -wheels = [ - - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, - -] - +requires-dist = [{ name = "iniconfig", specifier = "==2.0.0" }] + -requires-dist = [{ name = "a", specifier = "==1.0.0" }] + +requires-dist = [{ name = "a", specifier = "==2.0.0" }] "#); }); - assert!(!context.site_packages().join("iniconfig").exists()); + assert!(!context.site_packages().join("a").exists()); Ok(()) } #[test] fn check_no_sync_locked_rejects_stale_lock_without_update() -> Result<()> { - let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("simple/single-package.toml"); + let context = uv_test::test_context!("3.12").with_exclude_newer("2026-02-15T00:00:00Z"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -301,12 +278,12 @@ fn check_no_sync_locked_rejects_stale_lock_without_update() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["anyio==3.7.0"] + dependencies = ["a==1.0.0"] "#})?; context .lock() - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") + .arg("--index") + .arg(server.index_url()) .assert() .success(); let stale_lock = context.read("uv.lock"); @@ -316,7 +293,7 @@ fn check_no_sync_locked_rejects_stale_lock_without_update() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig==2.0.0"] + dependencies = ["a==2.0.0"] "#})?; uv_snapshot!( @@ -325,8 +302,8 @@ fn check_no_sync_locked_rejects_stale_lock_without_update() -> Result<()> { .check() .arg("--no-sync") .arg("--locked") - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z"), + .arg("--index") + .arg(server.index_url()), @" success: false exit_code: 2 @@ -339,7 +316,7 @@ fn check_no_sync_locked_rejects_stale_lock_without_update() -> Result<()> { ); assert_eq!(stale_lock, context.read("uv.lock")); - assert!(!context.site_packages().join("iniconfig").exists()); + assert!(!context.site_packages().join("a").exists()); Ok(()) } @@ -380,7 +357,8 @@ fn check_no_sync_locked_requires_existing_lock() -> Result<()> { #[test] fn check_no_sync_frozen_uses_existing_lock_without_update() -> Result<()> { - let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("simple/single-package.toml"); + let context = uv_test::test_context!("3.12").with_exclude_newer("2026-02-15T00:00:00Z"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -388,12 +366,12 @@ fn check_no_sync_frozen_uses_existing_lock_without_update() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["anyio==3.7.0"] + dependencies = ["a==1.0.0"] "#})?; context .lock() - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") + .arg("--index") + .arg(server.index_url()) .assert() .success(); let stale_lock = context.read("uv.lock"); @@ -403,7 +381,7 @@ fn check_no_sync_frozen_uses_existing_lock_without_update() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig==2.0.0"] + dependencies = ["a==2.0.0"] "#})?; context.temp_dir.child("main.py").write_str(indoc! {r" x: int = 1 @@ -415,8 +393,8 @@ fn check_no_sync_frozen_uses_existing_lock_without_update() -> Result<()> { .check() .arg("--no-sync") .arg("--frozen") - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") + .arg("--index") + .arg(server.index_url()) .arg("--ty-version") .arg("0.0.17"), @" @@ -431,7 +409,7 @@ fn check_no_sync_frozen_uses_existing_lock_without_update() -> Result<()> { ); assert_eq!(stale_lock, context.read("uv.lock")); - assert!(!context.site_packages().join("iniconfig").exists()); + assert!(!context.site_packages().join("a").exists()); Ok(()) } @@ -472,6 +450,7 @@ fn check_no_sync_frozen_requires_existing_lock() -> Result<()> { #[test] fn check_no_sync_isolated_does_not_write_lock_or_sync() -> Result<()> { + let server = PackseServer::new("simple/single-package.toml"); let context = uv_test::test_context!("3.12"); context @@ -482,7 +461,7 @@ fn check_no_sync_isolated_does_not_write_lock_or_sync() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig==2.0.0"] + dependencies = ["a==1.0.0"] "#})?; context.temp_dir.child("main.py").write_str(indoc! {r" x: int = 1 @@ -494,8 +473,8 @@ fn check_no_sync_isolated_does_not_write_lock_or_sync() -> Result<()> { .check() .arg("--no-sync") .arg("--isolated") - .arg("--exclude-newer") - .arg("2026-02-15T00:00:00Z") + .arg("--index") + .arg(server.index_url()) .arg("--ty-version") .arg("0.0.17"), @" @@ -510,7 +489,7 @@ fn check_no_sync_isolated_does_not_write_lock_or_sync() -> Result<()> { ); assert!(!context.temp_dir.child("uv.lock").exists()); - assert!(!context.site_packages().join("iniconfig").exists()); + assert!(!context.site_packages().join("a").exists()); Ok(()) } @@ -947,8 +926,8 @@ fn check_type_error() -> Result<()> { } #[test] -#[cfg(feature = "test-pypi")] fn check_with_declared_dependency() -> Result<()> { + let server = PackseServer::new("extras/extra-does-not-exist-backtrack.toml"); let context = uv_test::test_context!("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -957,19 +936,25 @@ fn check_with_declared_dependency() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig"] + dependencies = ["a==3.0.0"] [project.optional-dependencies] - test = ["typing-extensions"] + test = ["b==1.0.0"] "#})?; let main_py = context.temp_dir.child("main.py"); main_py.write_str(indoc! {r" - import iniconfig + import a "})?; // ty should resolve the import via the synced virtual environment. - uv_snapshot!(context.filters(), context.check(), @" + uv_snapshot!( + context.filters(), + context + .check() + .arg("--index") + .arg(server.index_url()), + @" success: true exit_code: 0 ----- stdout ----- @@ -978,27 +963,23 @@ fn check_with_declared_dependency() -> Result<()> { ----- stderr ----- warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. Installed 1 package in [TIME] - "); + " + ); context .assert_command( - "from importlib.metadata import distribution; assert distribution('iniconfig').read_text('INSTALLER') == 'uv'", + "from importlib.metadata import distribution; assert distribution('a').read_text('INSTALLER') == 'uv'", ) .success(); - assert!( - !context - .site_packages() - .join("typing_extensions.py") - .exists() - ); + assert!(!context.site_packages().join("b").exists()); Ok(()) } #[test] -#[cfg(feature = "test-pypi")] fn check_isolated() -> Result<()> { - let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/extra-does-not-exist-backtrack.toml"); + let context = uv_test::test_context!("3.12").with_exclude_newer("2026-02-15T00:00:00Z"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -1006,18 +987,25 @@ fn check_isolated() -> Result<()> { name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig"] + dependencies = ["a==3.0.0"] "#})?; let main_py = context.temp_dir.child("main.py"); main_py.write_str(indoc! {r" - import iniconfig + import a "})?; let environment_sentinel = context.venv.child("sentinel"); environment_sentinel.write_str("present")?; - uv_snapshot!(context.filters(), context.check().arg("--isolated"), @" + uv_snapshot!( + context.filters(), + context + .check() + .arg("--isolated") + .arg("--index") + .arg(server.index_url()), + @" success: true exit_code: 0 ----- stdout ----- @@ -1026,36 +1014,43 @@ fn check_isolated() -> Result<()> { ----- stderr ----- warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. Installed 1 package in [TIME] - "); + " + ); assert!(!context.temp_dir.child("uv.lock").exists()); - assert!(!context.site_packages().join("iniconfig").exists()); + assert!(!context.site_packages().join("a").exists()); assert!(environment_sentinel.exists()); // An existing lockfile should not be updated. - context.lock().assert().success(); + context + .lock() + .arg("--index") + .arg(server.index_url()) + .assert() + .success(); let existing_lock = context.read("uv.lock"); pyproject_toml.write_str(indoc! {r#" [project] name = "project" version = "0.1.0" requires-python = ">=3.12" - dependencies = ["iniconfig", "typing-extensions"] + dependencies = ["a==3.0.0", "b==1.0.0"] "#})?; main_py.write_str(indoc! {r" - import iniconfig - import typing_extensions + import a + import b "})?; - context.check().arg("--isolated").assert().success(); + context + .check() + .arg("--isolated") + .arg("--index") + .arg(server.index_url()) + .assert() + .success(); assert_eq!(existing_lock, context.read("uv.lock")); - assert!(!context.site_packages().join("iniconfig").exists()); - assert!( - !context - .site_packages() - .join("typing_extensions.py") - .exists() - ); + assert!(!context.site_packages().join("a").exists()); + assert!(!context.site_packages().join("b").exists()); assert!(environment_sentinel.exists()); Ok(()) diff --git a/test/scenarios/simple/single-package.toml b/test/scenarios/simple/single-package.toml new file mode 100644 index 00000000000..e5e3b554e60 --- /dev/null +++ b/test/scenarios/simple/single-package.toml @@ -0,0 +1,16 @@ +name = "single-package" +description = "A single package with two stable versions." + +[root] +requires = ["a"] + +[expected] +satisfiable = true +explanation = "The latest version of 'a' should be selected." + +[expected.packages] +a = "2.0.0" + +[packages.a.versions."1.0.0"] + +[packages.a.versions."2.0.0"]