From e6a6c20ad98ed7a1d3c74412c39298ef0007a287 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 23 Dec 2024 11:37:45 -0500 Subject: [PATCH 1/2] Add script locking --- bar.py | 18 + bar.py.lock | 106 ++++++ crates/uv-cli/src/lib.rs | 7 + crates/uv-configuration/src/dev.rs | 4 +- crates/uv-resolver/src/lock/mod.rs | 54 ++- crates/uv-resolver/src/lock/target.rs | 101 +++++- crates/uv-scripts/src/lib.rs | 8 + crates/uv/src/commands/project/add.rs | 9 +- crates/uv/src/commands/project/environment.rs | 137 ++++++- crates/uv/src/commands/project/export.rs | 2 +- crates/uv/src/commands/project/init.rs | 8 +- crates/uv/src/commands/project/lock.rs | 291 +++++++-------- crates/uv/src/commands/project/mod.rs | 5 +- crates/uv/src/commands/project/remove.rs | 2 +- crates/uv/src/commands/project/run.rs | 316 +++++++++++------ crates/uv/src/commands/project/sync.rs | 10 +- crates/uv/src/commands/project/target.rs | 335 ++++++++++++++++++ crates/uv/src/commands/project/tree.rs | 2 +- crates/uv/src/lib.rs | 14 + crates/uv/src/settings.rs | 3 + 20 files changed, 1093 insertions(+), 339 deletions(-) create mode 100644 bar.py create mode 100644 bar.py.lock create mode 100644 crates/uv/src/commands/project/target.rs diff --git a/bar.py b/bar.py new file mode 100644 index 000000000000..3df7257d8667 --- /dev/null +++ b/bar.py @@ -0,0 +1,18 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "anyio", +# "requests", +# "iniconfig", +# ] +# /// + +import iniconfig + + +def main() -> None: + print("Hello from bar.py!") + + +if __name__ == "__main__": + main() diff --git a/bar.py.lock b/bar.py.lock new file mode 100644 index 000000000000..14623c60d922 --- /dev/null +++ b/bar.py.lock @@ -0,0 +1,106 @@ +version = 1 +requires-python = ">=3.13" + +[manifest] +requirements = [ + { name = "anyio" }, + { name = "iniconfig" }, + { name = "requests" }, +] + +[[package]] +name = "anyio" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, +] + +[[package]] +name = "certifi" +version = "2024.12.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 731c9074236c..b9654fb98b36 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3089,6 +3089,13 @@ pub struct LockArgs { #[arg(long, conflicts_with = "check_exists", conflicts_with = "check")] pub dry_run: bool, + /// Remove the dependency from the specified Python script, rather than from a project. + /// + /// If provided, uv will remove the dependency from the script's inline metadata + /// table, in adherence with PEP 723. + #[arg(long)] + pub script: Option, + #[command(flatten)] pub resolver: ResolverArgs, diff --git a/crates/uv-configuration/src/dev.rs b/crates/uv-configuration/src/dev.rs index 80ae4f0640e4..cce967ba1f89 100644 --- a/crates/uv-configuration/src/dev.rs +++ b/crates/uv-configuration/src/dev.rs @@ -316,7 +316,7 @@ impl From for DevGroupsSpecification { /// The manifest of `dependency-groups` to include, taking into account the user-provided /// [`DevGroupsSpecification`] and the project-specific default groups. -#[derive(Debug, Clone)] +#[derive(Debug, Default, Clone)] pub struct DevGroupsManifest { /// The specification for the development dependencies. pub(crate) spec: DevGroupsSpecification, @@ -347,7 +347,7 @@ impl DevGroupsManifest { } /// Returns `true` if the group was enabled by default. - pub fn default(&self, group: &GroupName) -> bool { + pub fn is_default(&self, group: &GroupName) -> bool { if self.spec.contains(group) { // If the group was explicitly requested, then it wasn't enabled by default. false diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 91aefe78d4f4..84bfe81a9cd9 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -49,7 +49,7 @@ use uv_pypi_types::{ }; use uv_types::{BuildContext, HashStrategy}; use uv_workspace::dependency_groups::DependencyGroupError; -use uv_workspace::Workspace; +use uv_workspace::WorkspaceMember; mod map; mod requirements_txt; @@ -879,7 +879,8 @@ impl Lock { /// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root. pub async fn satisfies( &self, - workspace: &Workspace, + root: &Path, + packages: &BTreeMap, members: &[PackageName], requirements: &[Requirement], constraints: &[Requirement], @@ -906,7 +907,7 @@ impl Lock { // Validate that the member sources have not changed. { // E.g., that they've switched from virtual to non-virtual or vice versa. - for (name, member) in workspace.packages() { + for (name, member) in packages { let expected = !member.pyproject_toml().is_package(); let actual = self .find_by_name(name) @@ -919,7 +920,7 @@ impl Lock { } // E.g., that the version has changed. - for (name, member) in workspace.packages() { + for (name, member) in packages { let Some(expected) = member .pyproject_toml() .project @@ -948,14 +949,14 @@ impl Lock { let expected: BTreeSet<_> = requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = self .manifest .requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { return Ok(SatisfiesResult::MismatchedConstraints(expected, actual)); @@ -967,14 +968,14 @@ impl Lock { let expected: BTreeSet<_> = constraints .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = self .manifest .constraints .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { return Ok(SatisfiesResult::MismatchedConstraints(expected, actual)); @@ -986,14 +987,14 @@ impl Lock { let expected: BTreeSet<_> = overrides .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = self .manifest .overrides .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { return Ok(SatisfiesResult::MismatchedOverrides(expected, actual)); @@ -1034,7 +1035,7 @@ impl Lock { IndexUrl::Pypi(_) | IndexUrl::Url(_) => None, IndexUrl::Path(url) => { let path = url.to_file_path().ok()?; - let path = relative_to(&path, workspace.install_path()) + let path = relative_to(&path, root) .or_else(|_| std::path::absolute(path)) .ok()?; Some(path) @@ -1044,7 +1045,7 @@ impl Lock { }); // Add the workspace packages to the queue. - for root_name in workspace.packages().keys() { + for root_name in packages.keys() { let root = self .find_by_name(root_name) .expect("found too many packages matching root"); @@ -1093,7 +1094,7 @@ impl Lock { // Get the metadata for the distribution. let dist = package.to_dist( - workspace.install_path(), + root, // When validating, it's okay to use wheels that don't match the current platform. TagPolicy::Preferred(tags), // When validating, it's okay to use (e.g.) a source distribution with `--no-build`. @@ -1156,14 +1157,14 @@ impl Lock { let expected: BTreeSet<_> = metadata .requires_dist .into_iter() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = package .metadata .requires_dist .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { @@ -1187,7 +1188,7 @@ impl Lock { group, requirements .into_iter() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1203,7 +1204,7 @@ impl Lock { requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1368,23 +1369,23 @@ impl ResolverManifest { } /// Convert the manifest to a relative form using the given workspace. - pub fn relative_to(self, workspace: &Workspace) -> Result { + pub fn relative_to(self, root: &Path) -> Result { Ok(Self { members: self.members, requirements: self .requirements .into_iter() - .map(|requirement| requirement.relative_to(workspace.install_path())) + .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, constraints: self .constraints .into_iter() - .map(|requirement| requirement.relative_to(workspace.install_path())) + .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, overrides: self .overrides .into_iter() - .map(|requirement| requirement.relative_to(workspace.install_path())) + .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, dependency_metadata: self.dependency_metadata, }) @@ -3764,10 +3765,7 @@ fn normalize_url(mut url: Url) -> UrlString { /// 2. Ensures that the lock and install paths are appropriately framed with respect to the /// current [`Workspace`]. /// 3. Removes the `origin` field, which is only used in `requirements.txt`. -fn normalize_requirement( - requirement: Requirement, - workspace: &Workspace, -) -> Result { +fn normalize_requirement(requirement: Requirement, root: &Path) -> Result { match requirement.source { RequirementSource::Git { mut repository, @@ -3809,8 +3807,7 @@ fn normalize_requirement( ext, url: _, } => { - let install_path = - uv_fs::normalize_path_buf(workspace.install_path().join(&install_path)); + let install_path = uv_fs::normalize_path_buf(root.join(&install_path)); let url = VerbatimUrl::from_absolute_path(&install_path) .map_err(LockErrorKind::RequirementVerbatimUrl)?; @@ -3833,8 +3830,7 @@ fn normalize_requirement( r#virtual, url: _, } => { - let install_path = - uv_fs::normalize_path_buf(workspace.install_path().join(&install_path)); + let install_path = uv_fs::normalize_path_buf(root.join(&install_path)); let url = VerbatimUrl::from_absolute_path(&install_path) .map_err(LockErrorKind::RequirementVerbatimUrl)?; diff --git a/crates/uv-resolver/src/lock/target.rs b/crates/uv-resolver/src/lock/target.rs index dcbae78e6df9..ca25281375f1 100644 --- a/crates/uv-resolver/src/lock/target.rs +++ b/crates/uv-resolver/src/lock/target.rs @@ -3,6 +3,7 @@ use petgraph::Graph; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use std::collections::hash_map::Entry; use std::collections::{BTreeMap, VecDeque}; +use std::path::Path; use uv_configuration::{BuildOptions, DevGroupsManifest, ExtrasSpecification, InstallOptions}; use uv_distribution_types::{Edge, Node, Resolution, ResolvedDist}; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; @@ -34,15 +35,22 @@ pub enum InstallTarget<'env> { workspace: &'env Workspace, lock: &'env Lock, }, + /// A PEP 723 script. + Script { + path: &'env Path, + dependencies: Option<&'env [uv_pep508::Requirement]>, + lock: &'env Lock, + }, } impl<'env> InstallTarget<'env> { /// Return the [`Workspace`] of the target. - pub fn workspace(&self) -> &'env Workspace { + pub fn install_path(&self) -> &'env Path { match self { - Self::Project { workspace, .. } => workspace, - Self::Workspace { workspace, .. } => workspace, - Self::NonProjectWorkspace { workspace, .. } => workspace, + Self::Project { workspace, .. } => workspace.install_path(), + Self::Workspace { workspace, .. } => workspace.install_path(), + Self::NonProjectWorkspace { workspace, .. } => workspace.install_path(), + Self::Script { path, .. } => path.parent().unwrap(), } } @@ -52,6 +60,7 @@ impl<'env> InstallTarget<'env> { Self::Project { lock, .. } => lock, Self::Workspace { lock, .. } => lock, Self::NonProjectWorkspace { lock, .. } => lock, + Self::Script { lock, .. } => lock, } } @@ -59,7 +68,9 @@ impl<'env> InstallTarget<'env> { pub fn packages(&self) -> impl Iterator { match self { Self::Project { name, .. } => Either::Right(Either::Left(std::iter::once(*name))), - Self::NonProjectWorkspace { lock, .. } => Either::Left(lock.members().iter()), + Self::NonProjectWorkspace { lock, .. } => { + Either::Left(Either::Left(lock.members().iter())) + } Self::Workspace { lock, .. } => { // Identify the workspace members. // @@ -70,9 +81,24 @@ impl<'env> InstallTarget<'env> { lock.root().into_iter().map(|package| &package.id.name), )) } else { - Either::Left(lock.members().iter()) + Either::Left(Either::Left(lock.members().iter())) } } + Self::Script { .. } => Either::Left(Either::Right(std::iter::empty())), + } + } + + /// Return the [`InstallTarget`] requirements. + /// + /// Returns dependencies that apply to the workspace root, but not any of its members. As such, + /// only returns a non-empty iterator for scripts, which include packages directly (unlike + /// workspaces, in which each member has its own dependencies). + pub fn requirements(&self) -> Option<&[uv_pep508::Requirement]> { + match self { + Self::Project { .. } => None, + Self::Workspace { .. } => None, + Self::NonProjectWorkspace { .. } => None, + Self::Script { dependencies, .. } => dependencies.as_deref(), } } @@ -135,6 +161,7 @@ impl<'env> InstallTarget<'env> { Ok(map) } + Self::Script { .. } => Ok(BTreeMap::default()), } } @@ -144,6 +171,7 @@ impl<'env> InstallTarget<'env> { Self::Project { name, .. } => Some(name), Self::Workspace { .. } => None, Self::NonProjectWorkspace { .. } => None, + Self::Script { .. } => None, } } @@ -273,6 +301,63 @@ impl<'env> InstallTarget<'env> { } } + // Add any dependencies that are exclusive to the workspace root (e.g., dependencies in + // scripts). + for dependency in self.requirements().into_iter().flatten() { + if !dependency.marker.evaluate(marker_env, &[]) { + continue; + } + + let root_name = &dependency.name; + let dist = self + .lock() + .find_by_markers(root_name, marker_env) + .map_err(|_| LockErrorKind::MultipleRootPackages { + name: root_name.clone(), + })? + .ok_or_else(|| LockErrorKind::MissingRootPackage { + name: root_name.clone(), + })?; + + // Add the package to the graph. + let index = match inverse.entry(&dist.id) { + Entry::Vacant(entry) => { + let index = petgraph.add_node(self.package_to_node( + dist, + tags, + build_options, + install_options, + )?); + entry.insert(index); + index + } + Entry::Occupied(entry) => { + // Critically, if the package is already in the graph, then it's a workspace + // member. If it was omitted due to, e.g., `--only-dev`, but is itself + // referenced as a development dependency, then we need to re-enable it. + let index = *entry.get(); + let node = &mut petgraph[index]; + if !dev.prod() { + *node = self.package_to_node(dist, tags, build_options, install_options)?; + } + index + } + }; + + // Add the edge. + petgraph.add_edge(root, index, Edge::Prod(dependency.marker)); + + // Push its dependencies on the queue. + if seen.insert((&dist.id, None)) { + queue.push_back((dist, None)); + } + for extra in &dependency.extras { + if seen.insert((&dist.id, Some(extra))) { + queue.push_back((dist, Some(extra))); + } + } + } + // Add any dependency groups that are exclusive to the workspace root (e.g., dev // dependencies in (legacy) non-project workspace roots). let groups = self @@ -419,7 +504,7 @@ impl<'env> InstallTarget<'env> { build_options: &BuildOptions, ) -> Result { let dist = package.to_dist( - self.workspace().install_path(), + self.install_path(), TagPolicy::Required(tags), build_options, )?; @@ -436,7 +521,7 @@ impl<'env> InstallTarget<'env> { /// Create a non-installable [`Node`] from a [`Package`]. fn non_installable_node(&self, package: &Package, tags: &Tags) -> Result { let dist = package.to_dist( - self.workspace().install_path(), + self.install_path(), TagPolicy::Preferred(tags), &BuildOptions::default(), )?; diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 8b46007e0be8..f457e1e66c4c 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -54,6 +54,14 @@ impl Pep723Item { Self::Remote(_) => None, } } + + /// Return the PEP 723 script, if any. + pub fn as_script(&self) -> Option<&Pep723Script> { + match self { + Self::Script(script) => Some(script), + _ => None, + } + } } /// A PEP 723 script, including its [`Pep723Metadata`]. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c0bb80796880..e3b2611518bc 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -45,8 +45,9 @@ use crate::commands::pip::loggers::{ }; use crate::commands::pip::operations::Modifications; use crate::commands::project::lock::LockMode; +use crate::commands::project::target::LockTarget; use crate::commands::project::{ - init_script_python_requirement, lock, validate_script_requires_python, ProjectError, + init_script_python_requirement, validate_script_requires_python, ProjectError, ProjectInterpreter, ScriptPython, }; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; @@ -633,7 +634,7 @@ pub(crate) async fn add( let project_root = project.root().to_path_buf(); let workspace_root = project.workspace().install_path().clone(); let existing_pyproject_toml = project.pyproject_toml().as_ref().to_vec(); - let existing_uv_lock = lock::read_bytes(project.workspace()).await?; + let existing_uv_lock = LockTarget::from(project.workspace()).read_bytes().await?; // Update the `pypackage.toml` in-memory. let project = project @@ -737,7 +738,7 @@ async fn lock_and_sync( let mut lock = project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.into(), bounds, &state, @@ -856,7 +857,7 @@ async fn lock_and_sync( // the addition of the minimum version specifiers. lock = project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.into(), bounds, &state, diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index e4dd28ec2c01..70fe04ecc473 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -9,10 +9,13 @@ use crate::settings::ResolverInstallerSettings; use uv_cache::{Cache, CacheBucket}; use uv_cache_key::{cache_digest, hash_digest}; use uv_client::Connectivity; -use uv_configuration::{Concurrency, PreviewMode, TrustedHost}; +use uv_configuration::{ + Concurrency, DevGroupsManifest, ExtrasSpecification, InstallOptions, PreviewMode, TrustedHost, +}; use uv_dispatch::SharedState; use uv_distribution_types::{Name, Resolution}; use uv_python::{Interpreter, PythonEnvironment}; +use uv_resolver::InstallTarget; /// A [`PythonEnvironment`] stored in the cache. #[derive(Debug)] @@ -140,6 +143,138 @@ impl CachedEnvironment { Ok(Self(PythonEnvironment::from_root(root, cache)?)) } + /// Get or create an [`CachedEnvironment`] based on a given set of requirements and a base + /// interpreter. + pub(crate) async fn from_lock( + target: InstallTarget<'_>, + extras: &ExtrasSpecification, + dev: &DevGroupsManifest, + install_options: InstallOptions, + settings: &ResolverInstallerSettings, + interpreter: Interpreter, + state: &SharedState, + install: Box, + installer_metadata: bool, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + allow_insecure_host: &[TrustedHost], + cache: &Cache, + printer: Printer, + preview: PreviewMode, + ) -> Result { + let ResolverInstallerSettings { + index_locations: _, + index_strategy: _, + keyring_provider: _, + resolution: _, + prerelease: _, + fork_strategy: _, + dependency_metadata: _, + config_setting: _, + no_build_isolation: _, + no_build_isolation_package: _, + exclude_newer: _, + link_mode: _, + compile_bytecode: _, + sources: _, + upgrade: _, + reinstall: _, + build_options, + } = settings; + + // When caching, always use the base interpreter, rather than that of the virtual + // environment. + let interpreter = if let Some(interpreter) = interpreter.to_base_interpreter(cache)? { + debug!( + "Caching via base interpreter: `{}`", + interpreter.sys_executable().display() + ); + interpreter + } else { + debug!( + "Caching via interpreter: `{}`", + interpreter.sys_executable().display() + ); + interpreter + }; + + // Determine the tags, markers, and interpreter to use for resolution. + let tags = interpreter.tags()?; + let marker_env = interpreter.resolver_marker_environment(); + + // Read the lockfile. + let resolution = target.to_resolution( + &marker_env, + tags, + extras, + dev, + build_options, + &install_options, + )?; + + // Hash the resolution by hashing the generated lockfile. + // TODO(charlie): If the resolution contains any mutable metadata (like a path or URL + // dependency), skip this step. + let resolution_hash = { + let mut distributions = resolution.distributions().collect::>(); + distributions.sort_unstable_by_key(|dist| dist.name()); + hash_digest(&distributions) + }; + + // Hash the interpreter based on its path. + // TODO(charlie): Come up with a robust hash for the interpreter. + let interpreter_hash = cache_digest(&interpreter.sys_executable()); + + // Search in the content-addressed cache. + let cache_entry = cache.entry(CacheBucket::Environments, interpreter_hash, resolution_hash); + + if cache.refresh().is_none() { + if let Ok(root) = fs_err::read_link(cache_entry.path()) { + if let Ok(environment) = PythonEnvironment::from_root(root, cache) { + return Ok(Self(environment)); + } + } + } + + // Create the environment in the cache, then relocate it to its content-addressed location. + let temp_dir = cache.venv_dir()?; + let venv = uv_virtualenv::create_venv( + temp_dir.path(), + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + true, + false, + )?; + + sync_environment( + venv, + &resolution, + settings.as_ref().into(), + state, + install, + installer_metadata, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + printer, + preview, + ) + .await?; + + // Now that the environment is complete, sync it to its content-addressed location. + let id = cache + .persist(temp_dir.into_path(), cache_entry.path()) + .await?; + let root = cache.archive(&id); + + Ok(Self(PythonEnvironment::from_root(root, cache)?)) + } + /// Convert the [`CachedEnvironment`] into an [`Interpreter`]. pub(crate) fn into_interpreter(self) -> Interpreter { self.0.into_interpreter() diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 4368af06cf84..0fbb5d53328a 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -135,7 +135,7 @@ pub(crate) async fn export( // Lock the project. let lock = match do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref(), LowerBound::Warn, &state, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 8974dcc5214e..49198f25ce85 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -37,7 +37,7 @@ use crate::printer::Printer; #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)] pub(crate) async fn init( project_dir: &Path, - explicit_path: Option, + script: Option, name: Option, package: bool, init_kind: InitKind, @@ -64,7 +64,7 @@ pub(crate) async fn init( } match init_kind { InitKind::Script => { - let Some(path) = explicit_path.as_deref() else { + let Some(path) = script.as_deref() else { anyhow::bail!("Script initialization requires a file path") }; @@ -96,7 +96,7 @@ pub(crate) async fn init( } InitKind::Project(project_kind) => { // Default to the current directory if a path was not provided. - let path = match explicit_path { + let path = match script { None => project_dir.to_path_buf(), Some(ref path) => std::path::absolute(path)?, }; @@ -156,7 +156,7 @@ pub(crate) async fn init( } } - match explicit_path { + match script { // Initialized a project in the current directory. None => { writeln!(printer.stderr(), "Initialized project `{}`", name.cyan())?; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index cf36c8e6349e..46bfc31e857f 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1,6 +1,6 @@ #![allow(clippy::single_match_else)] -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; use std::path::Path; @@ -8,11 +8,18 @@ use owo_colors::OwoColorize; use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; +use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; +use crate::commands::project::target::LockTarget; +use crate::commands::project::{ProjectError, ProjectInterpreter, ScriptPython}; +use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; +use crate::commands::{diagnostics, pip, ExitStatus}; +use crate::printer::Printer; +use crate::settings::{ResolverSettings, ResolverSettingsRef}; use uv_cache::Cache; -use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder}; +use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, ExtrasSpecification, LowerBound, PreviewMode, Reinstall, - SourceStrategy, TrustedHost, Upgrade, + Concurrency, Constraints, ExtrasSpecification, LowerBound, PreviewMode, Reinstall, TrustedHost, + Upgrade, }; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::DistributionDatabase; @@ -23,27 +30,22 @@ use uv_distribution_types::{ use uv_git::ResolvedRepositoryReference; use uv_normalize::PackageName; use uv_pep440::Version; -use uv_pep508::RequirementOrigin; -use uv_pypi_types::{Requirement, SupportedEnvironments, VerbatimParsedUrl}; -use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; +use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments}; +use uv_python::{ + EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, +}; use uv_requirements::upgrade::{read_lock_requirements, LockedRequirements}; use uv_requirements::ExtrasResolver; use uv_resolver::{ - FlatIndex, InMemoryIndex, Lock, LockVersion, Options, OptionsBuilder, PythonRequirement, - RequiresPython, ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, - VERSION, + FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython, + ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, }; +use uv_scripts::{Pep723Item, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::{DiscoveryOptions, Workspace}; - -use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; -use crate::commands::project::{find_requires_python, ProjectError, ProjectInterpreter}; -use crate::commands::reporters::ResolverReporter; -use crate::commands::{diagnostics, pip, ExitStatus}; -use crate::printer::Printer; -use crate::settings::{ResolverSettings, ResolverSettingsRef}; +use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceMember}; /// The result of running a lock operation. #[derive(Debug, Clone)] @@ -80,6 +82,7 @@ pub(crate) async fn lock( python: Option, install_mirrors: PythonInstallMirrors, settings: ResolverSettings, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, connectivity: Connectivity, @@ -91,31 +94,73 @@ pub(crate) async fn lock( printer: Printer, preview: PreviewMode, ) -> anyhow::Result { + // Initialize any output reporters. + let download_reporter = PythonDownloadReporter::single(printer); + // Find the project requirements. - let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?; + let workspace; + let target = if let Some(script) = script.as_ref() { + LockTarget::Script(script) + } else { + workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?; + LockTarget::Workspace(&workspace) + }; // Determine the lock mode. let interpreter; let mode = if frozen { LockMode::Frozen } else { - // Find an interpreter for the project - interpreter = ProjectInterpreter::discover( - &workspace, - project_dir, - python.as_deref().map(PythonRequest::parse), - python_preference, - python_downloads, - connectivity, - native_tls, - allow_insecure_host, - install_mirrors, - no_config, - cache, - printer, - ) - .await? - .into_interpreter(); + interpreter = match target { + LockTarget::Workspace(workspace) => ProjectInterpreter::discover( + workspace, + project_dir, + python.as_deref().map(PythonRequest::parse), + python_preference, + python_downloads, + connectivity, + native_tls, + allow_insecure_host, + install_mirrors, + no_config, + cache, + printer, + ) + .await? + .into_interpreter(), + LockTarget::Script(script) => { + let ScriptPython { + source, + python_request, + requires_python, + } = ScriptPython::from_request( + python.as_deref().map(PythonRequest::parse), + None, + &Pep723Item::Script(script.clone()), + no_config, + ) + .await?; + + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls) + .allow_insecure_host(allow_insecure_host.to_vec()); + + PythonInstallation::find_or_download( + python_request.as_ref(), + EnvironmentPreference::Any, + python_preference, + python_downloads, + &client_builder, + cache, + Some(&download_reporter), + install_mirrors.python_install_mirror.as_deref(), + install_mirrors.pypy_install_mirror.as_deref(), + ) + .await? + .into_interpreter() + } + }; if locked { LockMode::Locked(&interpreter) @@ -132,7 +177,7 @@ pub(crate) async fn lock( // Perform the lock operation. match do_safe_lock( mode, - &workspace, + target, settings.as_ref(), LowerBound::Warn, &state, @@ -192,7 +237,7 @@ pub(super) enum LockMode<'env> { #[allow(clippy::fn_params_excessive_bools)] pub(super) async fn do_safe_lock( mode: LockMode<'_>, - workspace: &Workspace, + target: LockTarget<'_>, settings: ResolverSettingsRef<'_>, bounds: LowerBound, state: &SharedState, @@ -208,20 +253,22 @@ pub(super) async fn do_safe_lock( match mode { LockMode::Frozen => { // Read the existing lockfile, but don't attempt to lock the project. - let existing = read(workspace) + let existing = target + .read() .await? .ok_or_else(|| ProjectError::MissingLockfile)?; Ok(LockResult::Unchanged(existing)) } LockMode::Locked(interpreter) => { // Read the existing lockfile. - let existing = read(workspace) + let existing = target + .read() .await? .ok_or_else(|| ProjectError::MissingLockfile)?; // Perform the lock operation, but don't write the lockfile to disk. let result = do_lock( - workspace, + target, interpreter, Some(existing), settings, @@ -247,7 +294,7 @@ pub(super) async fn do_safe_lock( } LockMode::Write(interpreter) | LockMode::DryRun(interpreter) => { // Read the existing lockfile. - let existing = match read(workspace).await { + let existing = match target.read().await { Ok(Some(existing)) => Some(existing), Ok(None) => None, Err(ProjectError::Lock(err)) => { @@ -261,7 +308,7 @@ pub(super) async fn do_safe_lock( // Perform the lock operation. let result = do_lock( - workspace, + target, interpreter, existing, settings, @@ -281,7 +328,7 @@ pub(super) async fn do_safe_lock( // If the lockfile changed, write it to disk. if !matches!(mode, LockMode::DryRun(_)) { if let LockResult::Changed(_, lock) = &result { - commit(lock, workspace).await?; + target.commit(lock).await?; } } @@ -292,7 +339,7 @@ pub(super) async fn do_safe_lock( /// Lock the project requirements into a lockfile. async fn do_lock( - workspace: &Workspace, + target: LockTarget<'_>, interpreter: &Interpreter, existing_lock: Option, settings: ResolverSettingsRef<'_>, @@ -329,34 +376,24 @@ async fn do_lock( } = settings; // Collect the requirements, etc. - let requirements = workspace.non_project_requirements()?; - let overrides = workspace.overrides(); - let constraints = workspace.constraints(); + let members = target.members(); + let packages = target.packages(); + let requirements = target.non_project_requirements()?; + let overrides = target.overrides(); + let constraints = target.constraints(); let source_trees = vec![]; // If necessary, lower the overrides and constraints. - let requirements = lower(requirements, workspace, index_locations, sources)?; - let overrides = lower(overrides, workspace, index_locations, sources)?; - let constraints = lower(constraints, workspace, index_locations, sources)?; - - // Collect the list of members. - let members = { - let mut members = workspace.packages().keys().cloned().collect::>(); - members.sort(); - - // If this is a non-virtual project with a single member, we can omit it from the lockfile. - // If any members are added or removed, it will inherently mismatch. If the member is - // renamed, it will also mismatch. - if members.len() == 1 && !workspace.is_non_project() { - members.clear(); - } + let requirements = target.lower(requirements, index_locations, sources)?; + let overrides = target.lower(overrides, index_locations, sources)?; + let constraints = target.lower(constraints, index_locations, sources)?; - members - }; + // Collect the conflicts. + let conflicts = target.conflicts(); // Collect the list of supported environments. let environments = { - let environments = workspace.environments(); + let environments = target.environments(); // Ensure that the environments are disjoint. if let Some(environments) = &environments { @@ -392,7 +429,7 @@ async fn do_lock( // Determine the supported Python range. If no range is defined, and warn and default to the // current minor version. - let requires_python = find_requires_python(workspace); + let requires_python = target.requires_python(); let requires_python = if let Some(requires_python) = requires_python { if requires_python.is_unbounded() { @@ -520,11 +557,13 @@ async fn do_lock( let existing_lock = if let Some(existing_lock) = existing_lock { match ValidatedLock::validate( existing_lock, - workspace, + target.install_path(), + packages, &members, &requirements, &constraints, &overrides, + &conflicts, environments, dependency_metadata, interpreter, @@ -576,7 +615,7 @@ async fn do_lock( // If an existing lockfile exists, build up a set of preferences. let LockedRequirements { preferences, git } = versions_lock - .map(|lock| read_lock_requirements(lock, workspace.install_path(), upgrade)) + .map(|lock| read_lock_requirements(lock, target.install_path(), upgrade)) .transpose()? .unwrap_or_default(); @@ -625,11 +664,11 @@ async fn do_lock( let resolution = pip::operations::resolve( ExtrasResolver::new(&hasher, state.index(), database) .with_reporter(ResolverReporter::from(printer)) - .resolve(workspace.members_requirements()) + .resolve(target.members_requirements()) .await .map_err(|err| ProjectError::Operation(err.into()))? .into_iter() - .chain(workspace.group_requirements()) + .chain(target.group_requirements()) .chain(requirements.iter().cloned()) .map(UnresolvedRequirementSpecification::from) .collect(), @@ -646,7 +685,7 @@ async fn do_lock( source_trees, // The root is always null in workspaces, it "depends on" the projects None, - Some(workspace.packages().keys().cloned().collect()), + Some(packages.keys().cloned().collect()), &extras, preferences, EmptyInstalledPackages, @@ -656,7 +695,7 @@ async fn do_lock( None, resolver_env, python_requirement, - workspace.conflicts(), + conflicts, &client, &flat_index, state.index(), @@ -681,12 +720,12 @@ async fn do_lock( overrides, dependency_metadata.values().cloned(), ) - .relative_to(workspace)?; + .relative_to(target.install_path())?; let previous = existing_lock.map(ValidatedLock::into_lock); - let lock = Lock::from_resolution(&resolution, workspace.install_path())? + let lock = Lock::from_resolution(&resolution, target.install_path())? .with_manifest(manifest) - .with_conflicts(workspace.conflicts()) + .with_conflicts(target.conflicts()) .with_supported_environments( environments .cloned() @@ -717,11 +756,13 @@ impl ValidatedLock { /// Validate a [`Lock`] against the workspace requirements. async fn validate( lock: Lock, - workspace: &Workspace, + install_path: &Path, + packages: &BTreeMap, members: &[PackageName], requirements: &[Requirement], constraints: &[Requirement], overrides: &[Requirement], + conflicts: &Conflicts, environments: Option<&SupportedEnvironments>, dependency_metadata: &DependencyMetadata, interpreter: &Interpreter, @@ -840,10 +881,10 @@ impl ValidatedLock { } // If the conflicting group config has changed, we have to perform a clean resolution. - if &workspace.conflicts() != lock.conflicts() { + if conflicts != lock.conflicts() { debug!( "Ignoring existing lockfile due to change in conflicting groups: `{:?}` vs. `{:?}`", - workspace.conflicts(), + conflicts, lock.conflicts(), ); return Ok(Self::Versions(lock)); @@ -865,7 +906,8 @@ impl ValidatedLock { // Determine whether the lockfile satisfies the workspace requirements. match lock .satisfies( - workspace, + install_path, + packages, members, requirements, constraints, @@ -987,62 +1029,6 @@ impl ValidatedLock { } } -/// Write the lockfile to disk. -async fn commit(lock: &Lock, workspace: &Workspace) -> Result<(), ProjectError> { - let encoded = lock.to_toml()?; - fs_err::tokio::write(workspace.install_path().join("uv.lock"), encoded).await?; - Ok(()) -} - -/// Read the lockfile from the workspace. -/// -/// Returns `Ok(None)` if the lockfile does not exist. -pub(crate) async fn read(workspace: &Workspace) -> Result, ProjectError> { - match fs_err::tokio::read_to_string(&workspace.install_path().join("uv.lock")).await { - Ok(encoded) => { - match toml::from_str::(&encoded) { - Ok(lock) => { - // If the lockfile uses an unsupported version, raise an error. - if lock.version() != VERSION { - return Err(ProjectError::UnsupportedLockVersion( - VERSION, - lock.version(), - )); - } - Ok(Some(lock)) - } - Err(err) => { - // If we failed to parse the lockfile, determine whether it's a supported - // version. - if let Ok(lock) = toml::from_str::(&encoded) { - if lock.version() != VERSION { - return Err(ProjectError::UnparsableLockVersion( - VERSION, - lock.version(), - err, - )); - } - } - Err(ProjectError::UvLockParse(err)) - } - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.into()), - } -} - -/// Read the lockfile from the workspace as bytes. -/// -/// Returns `Ok(None)` if the lockfile does not exist. -pub(crate) async fn read_bytes(workspace: &Workspace) -> Result>, ProjectError> { - match fs_err::tokio::read(&workspace.install_path().join("uv.lock")).await { - Ok(encoded) => Ok(Some(encoded)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.into()), - } -} - /// Reports on the versions that were upgraded in the new lockfile. /// /// Returns `true` if any upgrades were reported. @@ -1137,36 +1123,3 @@ fn report_upgrades( Ok(updated) } - -/// Lower a set of requirements, relative to the workspace root. -fn lower( - requirements: Vec>, - workspace: &Workspace, - locations: &IndexLocations, - sources: SourceStrategy, -) -> Result, uv_distribution::MetadataError> { - let name = workspace - .pyproject_toml() - .project - .as_ref() - .map(|project| project.name.clone()); - - // We model these as `build-requires`, since, like build requirements, it doesn't define extras - // or dependency groups. - let metadata = uv_distribution::BuildRequires::from_workspace( - uv_pypi_types::BuildRequires { - name, - requires_dist: requirements, - }, - workspace, - locations, - sources, - LowerBound::Warn, - )?; - - Ok(metadata - .requires_dist - .into_iter() - .map(|requirement| requirement.with_origin(RequirementOrigin::Workspace)) - .collect::>()) -} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index d77cee35e797..f84a7abe4269 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -57,6 +57,7 @@ pub(crate) mod lock; pub(crate) mod remove; pub(crate) mod run; pub(crate) mod sync; +mod target; pub(crate) mod tree; #[derive(thiserror::Error, Debug)] @@ -265,7 +266,7 @@ impl std::fmt::Display for ConflictError { self.conflicts .iter() .map(|conflict| match conflict { - ConflictPackage::Group(ref group) if self.dev.default(group) => + ConflictPackage::Group(ref group) if self.dev.is_default(group) => format!("`{group}` (enabled by default)"), ConflictPackage::Group(ref group) => format!("`{group}`"), ConflictPackage::Extra(..) => unreachable!(), @@ -284,7 +285,7 @@ impl std::fmt::Display for ConflictError { .map(|(i, conflict)| { let conflict = match conflict { ConflictPackage::Extra(ref extra) => format!("extra `{extra}`"), - ConflictPackage::Group(ref group) if self.dev.default(group) => { + ConflictPackage::Group(ref group) if self.dev.is_default(group) => { format!("group `{group}` (enabled by default)") } ConflictPackage::Group(ref group) => format!("group `{group}`"), diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index effa0752e205..cdec2b7c1e47 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -221,7 +221,7 @@ pub(crate) async fn remove( // Lock and sync the environment, if necessary. let lock = match project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref().into(), LowerBound::Allow, &state, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index cb51e7c8264f..0bdec693290c 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -17,8 +17,8 @@ use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{ - Concurrency, DevGroupsSpecification, EditableMode, ExtrasSpecification, GroupsSpecification, - InstallOptions, LowerBound, PreviewMode, SourceStrategy, TrustedHost, + Concurrency, DevGroupsManifest, DevGroupsSpecification, EditableMode, ExtrasSpecification, + GroupsSpecification, InstallOptions, LowerBound, PreviewMode, SourceStrategy, TrustedHost, }; use uv_dispatch::SharedState; use uv_distribution::LoweredRequirement; @@ -44,6 +44,7 @@ use crate::commands::pip::loggers::{ use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; use crate::commands::project::lock::LockMode; +use crate::commands::project::target::LockTarget; use crate::commands::project::{ default_dependency_groups, validate_requires_python, validate_script_requires_python, DependencyGroupsTarget, EnvironmentSpecification, ProjectError, ScriptPython, WorkspacePython, @@ -227,109 +228,65 @@ pub(crate) async fn run( } } - // Determine the working directory for the script. - let script_dir = match &script { - Pep723Item::Script(script) => std::path::absolute(&script.path)? - .parent() - .expect("script path has no parent") - .to_owned(), - Pep723Item::Stdin(..) | Pep723Item::Remote(..) => std::env::current_dir()?, - }; - let script = script.into_metadata(); - - // Install the script requirements, if necessary. Otherwise, use an isolated environment. - if let Some(dependencies) = script.dependencies { - // Collect any `tool.uv.index` from the script. - let empty = Vec::default(); - let script_indexes = match settings.sources { - SourceStrategy::Enabled => script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.top_level.index.as_deref()) - .unwrap_or(&empty), - SourceStrategy::Disabled => &empty, - }; + // If a lockfile already exists, lock the script. + if let Some(target) = script + .as_script() + .map(LockTarget::from) + .filter(LockTarget::exists) + { + debug!( + "Found existing lockfile for script at: {}", + target.lock_path().display() + ); - // Collect any `tool.uv.sources` from the script. - let empty = BTreeMap::default(); - let script_sources = match settings.sources { - SourceStrategy::Enabled => script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.sources.as_ref()) - .unwrap_or(&empty), - SourceStrategy::Disabled => &empty, + // Determine the lock mode. + let mode = if frozen { + LockMode::Frozen + } else if locked { + LockMode::Locked(&interpreter) + } else { + LockMode::Write(&interpreter) }; - let requirements = dependencies - .into_iter() - .flat_map(|requirement| { - LoweredRequirement::from_non_workspace_requirement( - requirement, - script_dir.as_ref(), - script_sources, - script_indexes, - &settings.index_locations, - LowerBound::Allow, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::>()?; - let constraints = script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.constraint_dependencies.as_ref()) - .into_iter() - .flatten() - .cloned() - .flat_map(|requirement| { - LoweredRequirement::from_non_workspace_requirement( - requirement, - script_dir.as_ref(), - script_sources, - script_indexes, - &settings.index_locations, - LowerBound::Allow, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::, _>>()?; - let overrides = script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.override_dependencies.as_ref()) - .into_iter() - .flatten() - .cloned() - .flat_map(|requirement| { - LoweredRequirement::from_non_workspace_requirement( - requirement, - script_dir.as_ref(), - script_sources, - script_indexes, - &settings.index_locations, - LowerBound::Allow, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::, _>>()?; - - let spec = - RequirementsSpecification::from_overrides(requirements, constraints, overrides); - let result = CachedEnvironment::get_or_create( - EnvironmentSpecification::from(spec), - interpreter, - &settings, + // Generate a lockfile. + let lock = project::lock::do_safe_lock( + mode, + target, + settings.as_ref().into(), + LowerBound::Allow, &state, if show_resolution { Box::new(DefaultResolveLogger) } else { Box::new(SummaryResolveLogger) }, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + printer, + preview, + ) + .await? + .into_lock(); + + let dependencies = script.metadata().dependencies.as_deref(); + + let target = InstallTarget::Script { + path: target.install_path(), + dependencies, + lock: &lock, + }; + + let result = CachedEnvironment::from_lock( + target, + &ExtrasSpecification::default(), + &DevGroupsManifest::default(), + InstallOptions::default(), + &settings, + interpreter, + &state, if show_resolution { Box::new(DefaultInstallLogger) } else { @@ -358,19 +315,153 @@ pub(crate) async fn run( Some(environment.into_interpreter()) } else { - // Create a virtual environment. - temp_dir = cache.venv_dir()?; - let environment = uv_virtualenv::create_venv( - temp_dir.path(), - interpreter, - uv_virtualenv::Prompt::None, - false, - false, - false, - false, - )?; + // Determine the working directory for the script. + let script_dir = match &script { + Pep723Item::Script(script) => std::path::absolute(&script.path)? + .parent() + .expect("script path has no parent") + .to_owned(), + Pep723Item::Stdin(..) | Pep723Item::Remote(..) => std::env::current_dir()?, + }; - Some(environment.into_interpreter()) + let script = script.into_metadata(); + + // Install the script requirements, if necessary. Otherwise, use an isolated environment. + if let Some(dependencies) = script.dependencies { + // Collect any `tool.uv.index` from the script. + let empty = Vec::default(); + let script_indexes = match settings.sources { + SourceStrategy::Enabled => script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.top_level.index.as_deref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + // Collect any `tool.uv.sources` from the script. + let empty = BTreeMap::default(); + let script_sources = match settings.sources { + SourceStrategy::Enabled => script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + let requirements = dependencies + .into_iter() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + LowerBound::Allow, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::>()?; + let constraints = script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.constraint_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + LowerBound::Allow, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::, _>>()?; + let overrides = script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.override_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + LowerBound::Allow, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::, _>>()?; + + let spec = + RequirementsSpecification::from_overrides(requirements, constraints, overrides); + + let result = CachedEnvironment::get_or_create( + EnvironmentSpecification::from(spec), + interpreter, + &settings, + &state, + if show_resolution { + Box::new(DefaultResolveLogger) + } else { + Box::new(SummaryResolveLogger) + }, + if show_resolution { + Box::new(DefaultInstallLogger) + } else { + Box::new(SummaryInstallLogger) + }, + installer_metadata, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + printer, + preview, + ) + .await; + + let environment = match result { + Ok(resolution) => resolution, + Err(ProjectError::Operation(err)) => { + return diagnostics::OperationDiagnostic::with_context("script") + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } + Err(err) => return Err(err.into()), + }; + + Some(environment.into_interpreter()) + } else { + // Create a virtual environment. + temp_dir = cache.venv_dir()?; + let environment = uv_virtualenv::create_venv( + temp_dir.path(), + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + false, + false, + )?; + + Some(environment.into_interpreter()) + } } } else { None @@ -611,7 +702,8 @@ pub(crate) async fn run( // If we're not syncing, we should still attempt to respect the locked preferences // in any `--with` requirements. if !isolated && !requirements.is_empty() { - lock = project::lock::read(project.workspace()) + lock = LockTarget::from(project.workspace()) + .read() .await .ok() .flatten() @@ -649,7 +741,7 @@ pub(crate) async fn run( let result = match project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref().into(), LowerBound::Allow, &state, @@ -722,15 +814,13 @@ pub(crate) async fn run( } }; - let install_options = InstallOptions::default(); - match project::sync::do_sync( target, &venv, &extras, &dev.with_defaults(defaults), editable, - install_options, + InstallOptions::default(), Modifications::Sufficient, settings.as_ref().into(), if show_resolution { diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index f1e385dc7b26..6a1b85997086 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -34,6 +34,7 @@ use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, use crate::commands::pip::operations; use crate::commands::pip::operations::Modifications; use crate::commands::project::lock::{do_safe_lock, LockMode}; +use crate::commands::project::target::LockTarget; use crate::commands::project::{ default_dependency_groups, detect_conflicts, DependencyGroupsTarget, ProjectError, }; @@ -146,7 +147,7 @@ pub(crate) async fn sync( let lock = match do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref().into(), LowerBound::Warn, &state, @@ -367,7 +368,7 @@ pub(super) async fn do_sync( } // Populate credentials from the workspace. - store_credentials_from_workspace(target.workspace()); + // store_credentials_from_workspace(target.into()); // Initialize the registry client. let client = RegistryClientBuilder::new(cache.clone()) @@ -525,8 +526,9 @@ fn apply_editable_mode(resolution: Resolution, editable: EditableMode) -> Resolu /// /// These credentials can come from any of `tool.uv.sources`, `tool.uv.dev-dependencies`, /// `project.dependencies`, and `project.optional-dependencies`. -fn store_credentials_from_workspace(workspace: &Workspace) { - for member in workspace.packages().values() { +fn store_credentials_from_workspace(target: LockTarget<'_>) { + // TODO(charlie): I think this misses dependencies in the non-project root. + for member in target.packages().values() { // Iterate over the `tool.uv.sources`. for source in member .pyproject_toml() diff --git a/crates/uv/src/commands/project/target.rs b/crates/uv/src/commands/project/target.rs new file mode 100644 index 000000000000..d97862a1ec1c --- /dev/null +++ b/crates/uv/src/commands/project/target.rs @@ -0,0 +1,335 @@ +use itertools::{Either, Itertools}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use uv_configuration::{LowerBound, SourceStrategy}; +use uv_distribution::LoweredRequirement; +use uv_distribution_types::IndexLocations; +use uv_normalize::PackageName; +use uv_pep508::RequirementOrigin; +use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments, VerbatimParsedUrl}; +use uv_resolver::{Lock, LockVersion, RequiresPython, VERSION}; +use uv_scripts::Pep723Script; +use uv_workspace::dependency_groups::DependencyGroupError; +use uv_workspace::{Workspace, WorkspaceMember}; + +use crate::commands::project::{find_requires_python, ProjectError}; + +#[derive(Debug, Copy, Clone)] +pub(crate) enum LockTarget<'lock> { + Workspace(&'lock Workspace), + Script(&'lock Pep723Script), +} + +impl<'lock> From<&'lock Workspace> for LockTarget<'lock> { + fn from(workspace: &'lock Workspace) -> Self { + LockTarget::Workspace(workspace) + } +} + +impl<'lock> From<&'lock Pep723Script> for LockTarget<'lock> { + fn from(script: &'lock Pep723Script) -> Self { + LockTarget::Script(script) + } +} + +impl<'lock> LockTarget<'lock> { + /// Return the path to the lockfile. + pub(crate) fn lock_path(&self) -> PathBuf { + match self { + // `uv.lock` + LockTarget::Workspace(workspace) => workspace.install_path().join("uv.lock"), + // `script.py.lock` + LockTarget::Script(script) => { + let mut file_name = match script.path.file_name() { + Some(f) => f.to_os_string(), + None => panic!("Script path has no file name"), + }; + file_name.push(".lock"); + script.path.with_file_name(file_name) + } + } + } + + /// Read the lockfile from the workspace. + /// + /// Returns `Ok(None)` if the lockfile does not exist. + pub(crate) async fn read(&self) -> Result, ProjectError> { + match fs_err::tokio::read_to_string(self.lock_path()).await { + Ok(encoded) => { + match toml::from_str::(&encoded) { + Ok(lock) => { + // If the lockfile uses an unsupported version, raise an error. + if lock.version() != VERSION { + return Err(ProjectError::UnsupportedLockVersion( + VERSION, + lock.version(), + )); + } + Ok(Some(lock)) + } + Err(err) => { + // If we failed to parse the lockfile, determine whether it's a supported + // version. + if let Ok(lock) = toml::from_str::(&encoded) { + if lock.version() != VERSION { + return Err(ProjectError::UnparsableLockVersion( + VERSION, + lock.version(), + err, + )); + } + } + Err(ProjectError::UvLockParse(err)) + } + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } + + /// Read the lockfile from the workspace as bytes. + pub(crate) async fn read_bytes(&self) -> Result>, ProjectError> { + match fs_err::tokio::read(self.lock_path()).await { + Ok(encoded) => Ok(Some(encoded)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } + + /// Write the lockfile to disk. + pub(crate) async fn commit(&self, lock: &Lock) -> Result<(), ProjectError> { + let encoded = lock.to_toml()?; + fs_err::tokio::write(self.lock_path(), encoded).await?; + Ok(()) + } + + /// Returns `true` if the lockfile exists. + pub(crate) fn exists(&self) -> bool { + self.lock_path().exists() + } + + pub(crate) fn non_project_requirements( + &self, + ) -> Result>, DependencyGroupError> { + match self { + LockTarget::Workspace(workspace) => workspace.non_project_requirements(), + LockTarget::Script(script) => { + Ok(script.metadata.dependencies.clone().unwrap_or_default()) + } + } + } + + pub(crate) fn overrides(&self) -> Vec> { + match self { + LockTarget::Workspace(workspace) => workspace.overrides(), + LockTarget::Script(script) => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.override_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .collect(), + } + } + + pub(crate) fn constraints(&self) -> Vec> { + match self { + LockTarget::Workspace(workspace) => workspace.constraints(), + LockTarget::Script(script) => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.constraint_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .collect(), + } + } + + pub(crate) fn lower( + &self, + requirements: Vec>, + index_locations: &IndexLocations, + sources: SourceStrategy, + ) -> Result, uv_distribution::MetadataError> { + match self { + LockTarget::Workspace(workspace) => { + lower(requirements, workspace, index_locations, sources) + } + LockTarget::Script(script) => { + // Collect any `tool.uv.index` from the script. + let empty = Vec::default(); + let indexes = match sources { + SourceStrategy::Enabled => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.top_level.index.as_deref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + // Collect any `tool.uv.sources` from the script. + let empty = BTreeMap::default(); + let sources = match sources { + SourceStrategy::Enabled => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + Ok(requirements + .into_iter() + .flat_map(|requirement| { + let requirement_name = requirement.name.clone(); + LoweredRequirement::from_non_workspace_requirement( + requirement, + script.path.parent().unwrap(), + sources, + indexes, + index_locations, + LowerBound::Allow, + ) + .map(move |requirement| match requirement { + Ok(requirement) => Ok(requirement.into_inner()), + Err(err) => Err(uv_distribution::MetadataError::LoweringError( + requirement_name.clone(), + Box::new(err), + )), + }) + }) + .collect::>()?) + } + } + } + + pub(crate) fn members(&self) -> Vec { + match self { + LockTarget::Workspace(workspace) => { + let mut members = workspace.packages().keys().cloned().collect::>(); + members.sort(); + + // If this is a non-virtual project with a single member, we can omit it from the lockfile. + // If any members are added or removed, it will inherently mismatch. If the member is + // renamed, it will also mismatch. + if members.len() == 1 && !workspace.is_non_project() { + members.clear(); + } + + members + } + LockTarget::Script(script) => Vec::new(), + } + } + + pub(crate) fn packages(&self) -> &BTreeMap { + match self { + LockTarget::Workspace(workspace) => workspace.packages(), + LockTarget::Script(_) => { + static EMPTY: BTreeMap = BTreeMap::new(); + &EMPTY + } + } + } + + pub(crate) fn environments(&self) -> Option<&SupportedEnvironments> { + match self { + LockTarget::Workspace(workspace) => workspace.environments(), + LockTarget::Script(script) => { + // TODO(charlie): Add support for environments in scripts. + None + } + } + } + + pub(crate) fn conflicts(&self) -> Conflicts { + match self { + LockTarget::Workspace(workspace) => workspace.conflicts(), + LockTarget::Script(_) => Conflicts::empty(), + } + } + + pub(crate) fn requires_python(&self) -> Option { + match self { + LockTarget::Workspace(workspace) => find_requires_python(workspace), + LockTarget::Script(script) => script + .metadata + .requires_python + .as_ref() + .map(RequiresPython::from_specifiers), + } + } + + pub(crate) fn members_requirements(&self) -> impl Iterator + '_ { + match self { + LockTarget::Workspace(workspace) => Either::Left(workspace.members_requirements()), + LockTarget::Script(script) => Either::Right( + script + .metadata + .dependencies + .iter() + .flatten() + .cloned() + .map(Requirement::from), + ), + } + } + + pub(crate) fn group_requirements(&self) -> impl Iterator + '_ { + match self { + LockTarget::Workspace(workspace) => Either::Left(workspace.group_requirements()), + LockTarget::Script(_) => Either::Right(std::iter::empty()), + } + } + + pub(crate) fn install_path(&self) -> &Path { + match self { + LockTarget::Workspace(workspace) => workspace.install_path(), + LockTarget::Script(script) => script.path.parent().unwrap(), + } + } +} + +/// Lower a set of requirements, relative to the workspace root. +fn lower( + requirements: Vec>, + workspace: &Workspace, + locations: &IndexLocations, + sources: SourceStrategy, +) -> Result, uv_distribution::MetadataError> { + let name = workspace + .pyproject_toml() + .project + .as_ref() + .map(|project| project.name.clone()); + + // We model these as `build-requires`, since, like build requirements, it doesn't define extras + // or dependency groups. + let metadata = uv_distribution::BuildRequires::from_workspace( + uv_pypi_types::BuildRequires { + name, + requires_dist: requirements, + }, + workspace, + locations, + sources, + LowerBound::Warn, + )?; + + Ok(metadata + .requires_dist + .into_iter() + .map(|requirement| requirement.with_origin(RequirementOrigin::Workspace)) + .collect::>()) +} diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index b62ef2b54801..d38d22891a47 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -111,7 +111,7 @@ pub(crate) async fn tree( // Update the lockfile, if necessary. let lock = match do_safe_lock( mode, - &workspace, + (&workspace).into(), settings.as_ref(), LowerBound::Allow, &state, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f34ef7a03a15..15af02cececf 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -183,6 +183,12 @@ async fn run(mut cli: Cli) -> Result { script: Some(script), .. }) = &**command + { + Pep723Script::read(&script).await?.map(Pep723Item::Script) + } else if let ProjectCommand::Lock(uv_cli::LockArgs { + script: Some(script), + .. + }) = &**command { Pep723Script::read(&script).await?.map(Pep723Item::Script) } else { @@ -1483,6 +1489,13 @@ async fn run_project( .combine(Refresh::from(args.settings.upgrade.clone())), ); + // Unwrap the script. + let script = script.map(|script| match script { + Pep723Item::Script(script) => script, + Pep723Item::Stdin(_) => unreachable!("`uv lock` does not support stdin"), + Pep723Item::Remote(_) => unreachable!("`uv lock` does not support remote files"), + }); + commands::lock( project_dir, args.locked, @@ -1491,6 +1504,7 @@ async fn run_project( args.python, args.install_mirrors, args.settings, + script, globals.python_preference, globals.python_downloads, globals.connectivity, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 2e677eeabf4a..761deae0c847 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1008,6 +1008,7 @@ pub(crate) struct LockSettings { pub(crate) locked: bool, pub(crate) frozen: bool, pub(crate) dry_run: bool, + pub(crate) script: Option, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, @@ -1022,6 +1023,7 @@ impl LockSettings { check, check_exists, dry_run, + script, resolver, build, refresh, @@ -1037,6 +1039,7 @@ impl LockSettings { locked: check, frozen: check_exists, dry_run, + script, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), From d8775bc143a3aef3c5c162d698191621c006667a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 23 Dec 2024 13:40:28 -0500 Subject: [PATCH 2/2] Reverts --- bar.py | 18 ------- bar.py.lock | 106 --------------------------------------- crates/uv-cli/src/lib.rs | 6 +-- docs/reference/cli.md | 4 ++ 4 files changed, 7 insertions(+), 127 deletions(-) delete mode 100644 bar.py delete mode 100644 bar.py.lock diff --git a/bar.py b/bar.py deleted file mode 100644 index 3df7257d8667..000000000000 --- a/bar.py +++ /dev/null @@ -1,18 +0,0 @@ -# /// script -# requires-python = ">=3.13" -# dependencies = [ -# "anyio", -# "requests", -# "iniconfig", -# ] -# /// - -import iniconfig - - -def main() -> None: - print("Hello from bar.py!") - - -if __name__ == "__main__": - main() diff --git a/bar.py.lock b/bar.py.lock deleted file mode 100644 index 14623c60d922..000000000000 --- a/bar.py.lock +++ /dev/null @@ -1,106 +0,0 @@ -version = 1 -requires-python = ">=3.13" - -[manifest] -requirements = [ - { name = "anyio" }, - { name = "iniconfig" }, - { name = "requests" }, -] - -[[package]] -name = "anyio" -version = "4.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, -] - -[[package]] -name = "certifi" -version = "2024.12.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, - { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, - { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, - { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, - { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, - { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, - { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, - { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, - { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, - { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[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 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, -] - -[[package]] -name = "requests" -version = "2.32.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, -] - -[[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 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "urllib3" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, -] diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index b9654fb98b36..ddaac9a9758b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3089,10 +3089,10 @@ pub struct LockArgs { #[arg(long, conflicts_with = "check_exists", conflicts_with = "check")] pub dry_run: bool, - /// Remove the dependency from the specified Python script, rather than from a project. + /// Lock the specified Python script, rather than the current project. /// - /// If provided, uv will remove the dependency from the script's inline metadata - /// table, in adherence with PEP 723. + /// If provided, uv will lock the script based on its inline metadata table, in adherence + /// with PEP 723. #[arg(long)] pub script: Option, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cf8110eec7f8..5090d483a9f4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2123,6 +2123,10 @@ uv lock [OPTIONS]
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
    --script script

    Lock the specified Python script, rather than the current project.

    + +

    If provided, uv will lock the script based on its inline metadata table, in adherence with PEP 723.

    +
    --upgrade, -U

    Allow package upgrades, ignoring pinned versions in any existing output file. Implies --refresh

    --upgrade-package, -P upgrade-package

    Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies --refresh-package