diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index c78d90cb0..8f4d53692 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -46,4 +46,4 @@ jobs: - name: Run tests run: | cd py-rattler - pixi run -e test test + pixi run -e test test --color=yes diff --git a/crates/rattler_conda_types/src/lib.rs b/crates/rattler_conda_types/src/lib.rs index 1d7f09e48..d79622b17 100644 --- a/crates/rattler_conda_types/src/lib.rs +++ b/crates/rattler_conda_types/src/lib.rs @@ -50,7 +50,7 @@ pub use repo_data::{ compute_package_url, patches::{PackageRecordPatch, PatchInstructions, RepoDataPatch}, sharded::{Shard, ShardedRepodata, ShardedSubdirInfo}, - ChannelInfo, ConvertSubdirError, PackageRecord, RepoData, + ChannelInfo, ConvertSubdirError, PackageRecord, RepoData, ValidatePackageRecordsError, }; pub use repo_data_record::RepoDataRecord; pub use run_export::RunExportKind; diff --git a/crates/rattler_conda_types/src/repo_data/mod.rs b/crates/rattler_conda_types/src/repo_data/mod.rs index bc3f00a82..0664c9399 100644 --- a/crates/rattler_conda_types/src/repo_data/mod.rs +++ b/crates/rattler_conda_types/src/repo_data/mod.rs @@ -19,7 +19,6 @@ use serde_with::{serde_as, skip_serializing_none, OneOrMany}; use thiserror::Error; use url::Url; -use crate::utils::serde::sort_map_alphabetically; use crate::utils::url::add_trailing_slash; use crate::{ build_spec::BuildNumber, @@ -27,6 +26,9 @@ use crate::{ utils::serde::DeserializeFromStrUnchecked, Channel, NoArchType, PackageName, PackageUrl, Platform, RepoDataRecord, VersionWithSource, }; +use crate::{ + utils::serde::sort_map_alphabetically, MatchSpec, Matches, ParseMatchSpecError, ParseStrictness, +}; /// [`RepoData`] is an index of package binaries available on in a subdirectory /// of a Conda channel. @@ -275,6 +277,12 @@ pub fn compute_package_url( .expect("failed to join base_url and filename") } +impl AsRef for PackageRecord { + fn as_ref(&self) -> &PackageRecord { + self + } +} + impl PackageRecord { /// A simple helper method that constructs a `PackageRecord` with the bare /// minimum values. @@ -315,6 +323,74 @@ impl PackageRecord { pub fn sort_topologically + Clone>(records: Vec) -> Vec { topological_sort::sort_topologically(records) } + + /// Validate that the given package records are valid w.r.t. 'depends' and 'constrains'. + /// This function will return Ok(()) if all records form a valid environment, i.e., all dependencies + /// of each package are satisfied by the other packages in the list. + /// If there is a dependency that is not satisfied, this function will return an error. + pub fn validate>( + records: Vec, + ) -> Result<(), ValidatePackageRecordsError> { + for package in records.iter() { + let package = package.as_ref(); + // First we check if all dependencies are in the environment. + for dep in package.depends.iter() { + // We ignore virtual packages, e.g. `__unix`. + if dep.starts_with("__") { + continue; + } + let dep_spec = MatchSpec::from_str(dep, ParseStrictness::Lenient)?; + if !records.iter().any(|p| dep_spec.matches(p.as_ref())) { + return Err(ValidatePackageRecordsError::DependencyNotInEnvironment { + package: package.to_owned(), + dependency: dep.to_string(), + }); + } + } + + // Then we check if all constraints are satisfied. + for constraint in package.constrains.iter() { + let constraint_spec = MatchSpec::from_str(constraint, ParseStrictness::Lenient)?; + let matching_package = records + .iter() + .find(|record| Some(record.as_ref().name.clone()) == constraint_spec.name); + if matching_package.is_some_and(|p| !constraint_spec.matches(p.as_ref())) { + return Err(ValidatePackageRecordsError::PackageConstraintNotSatisfied { + package: package.to_owned(), + constraint: constraint.to_owned(), + violating_package: matching_package.unwrap().as_ref().to_owned(), + }); + } + } + } + Ok(()) + } +} + +/// An error when validating package records. +#[derive(Debug, Error)] +pub enum ValidatePackageRecordsError { + /// A package is not present in the environment. + #[error("package '{package}' has dependency '{dependency}', which is not in the environment")] + DependencyNotInEnvironment { + /// The package containing the unmet dependency. + package: PackageRecord, + /// The dependency that is not in the environment. + dependency: String, + }, + /// A package constraint is not met in the environment. + #[error("package '{package}' has constraint '{constraint}', which is not satisfied by '{violating_package}' in the environment")] + PackageConstraintNotSatisfied { + /// The package containing the unmet constraint. + package: PackageRecord, + /// The constraint that is violated. + constraint: String, + /// The corresponding package that violates the constraint. + violating_package: PackageRecord, + }, + /// Failed to parse a matchspec. + #[error(transparent)] + ParseMatchSpec(#[from] ParseMatchSpecError), } /// An error that can occur when parsing a platform from a string. @@ -331,7 +407,7 @@ pub enum ConvertSubdirError { /// Platform key is empty #[error("platform key is empty in index.json")] PlatformEmpty, - /// Arc key is empty + /// Arch key is empty #[error("arch key is empty in index.json")] ArchEmpty, } @@ -437,7 +513,7 @@ mod test { use crate::{ repo_data::{compute_package_url, determine_subdir}, - Channel, ChannelConfig, RepoData, + Channel, ChannelConfig, PackageRecord, RepoData, }; // isl-0.12.2-1.tar.bz2 @@ -554,4 +630,46 @@ mod test { let data_path = test_data_path.join(path); RepoData::from_path(data_path).unwrap() } + + #[test] + fn test_validate() { + // load test data + let test_data_path = dunce::canonicalize( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data"), + ) + .unwrap(); + let data_path = test_data_path.join("channels/dummy/linux-64/repodata.json"); + let repodata = RepoData::from_path(&data_path).unwrap(); + + let package_depends_only_virtual_package = repodata + .packages + .get("baz-1.0-unix_py36h1af98f8_2.tar.bz2") + .unwrap(); + let package_depends = repodata.packages.get("foobar-2.0-bla_1.tar.bz2").unwrap(); + let package_constrains = repodata + .packages + .get("foo-3.0.2-py36h1af98f8_3.conda") + .unwrap(); + let package_bors_1 = repodata.packages.get("bors-1.2.1-bla_1.tar.bz2").unwrap(); + let package_bors_2 = repodata.packages.get("bors-2.1-bla_1.tar.bz2").unwrap(); + + assert!(PackageRecord::validate(vec![package_depends_only_virtual_package]).is_ok()); + for packages in [vec![package_depends], vec![package_depends, package_bors_2]] { + let result = PackageRecord::validate(packages); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains( + "package 'foobar=2.0=bla_1' has dependency 'bors <2.0', which is not in the environment" + )); + } + + assert!(PackageRecord::validate(vec![package_depends, package_bors_1]).is_ok()); + assert!(PackageRecord::validate(vec![package_constrains]).is_ok()); + assert!(PackageRecord::validate(vec![package_constrains, package_bors_1]).is_ok()); + + let result = PackageRecord::validate(vec![package_constrains, package_bors_2]); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains( + "package 'foo=3.0.2=py36h1af98f8_3' has constraint 'bors <2.0', which is not satisfied by 'bors=2.1=bla_1' in the environment" + )); + } } diff --git a/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__base_url_packages.snap b/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__base_url_packages.snap index 4069166f7..d129494da 100644 --- a/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__base_url_packages.snap +++ b/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__base_url_packages.snap @@ -1,6 +1,6 @@ --- source: crates/rattler_conda_types/src/repo_data/mod.rs -assertion_line: 538 +assertion_line: 594 expression: file_urls --- - channels/dummy/linux-64/issue_717-2.1-bla_1.tar.bz2 @@ -9,6 +9,7 @@ expression: file_urls - channels/dummy/linux-64/foo-3.0.2-py36h1af98f8_1.conda - channels/dummy/linux-64/foo-3.0.2-py36h1af98f8_1.tar.bz2 - channels/dummy/linux-64/foo-4.0.2-py36h1af98f8_2.tar.bz2 +- channels/dummy/linux-64/foo-3.0.2-py36h1af98f8_3.conda - channels/dummy/linux-64/bors-1.2.1-bla_1.tar.bz2 - channels/dummy/linux-64/baz-1.0-unix_py36h1af98f8_2.tar.bz2 - channels/dummy/linux-64/bors-2.1-bla_1.tar.bz2 @@ -17,5 +18,5 @@ expression: file_urls - channels/dummy/linux-64/bors-1.1-bla_1.tar.bz2 - channels/dummy/linux-64/baz-2.0-unix_py36h1af98f8_2.tar.bz2 - channels/dummy/linux-64/foobar-2.1-bla_1.tar.bz2 -- channels/dummy/linux-64/cuda-version-12.5-hd4f0392_3.conda - channels/dummy/linux-64/bors-2.0-bla_1.tar.bz2 +- channels/dummy/linux-64/cuda-version-12.5-hd4f0392_3.conda diff --git a/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages-2.snap b/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages-2.snap index 1b79f058e..c832f9539 100644 --- a/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages-2.snap +++ b/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages-2.snap @@ -184,6 +184,23 @@ expression: json "timestamp": 1715610974, "version": "3.0.2" }, + "foo-3.0.2-py36h1af98f8_3.conda": { + "build": "py36h1af98f8_3", + "build_number": 3, + "constrains": [ + "bors <2.0" + ], + "depends": [], + "license": "MIT", + "license_family": "MIT", + "md5": "fb731d9290f0bcbf3a054665f33ec94f", + "name": "foo", + "sha256": "67a63bec3fd3205170eaad532d487595b8aaceb9814d13c6858d7bac3ef24cd4", + "size": 414494, + "subdir": "linux-64", + "timestamp": 1715610974, + "version": "3.0.2" + }, "foo-4.0.2-py36h1af98f8_2.tar.bz2": { "build": "py36h1af98f8_2", "build_number": 1, diff --git a/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages.snap b/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages.snap index 725724284..570ea57e1 100644 --- a/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages.snap +++ b/crates/rattler_conda_types/src/repo_data/snapshots/rattler_conda_types__repo_data__test__serialize_packages.snap @@ -167,6 +167,21 @@ packages: subdir: linux-64 timestamp: 1715610974 version: 3.0.2 + foo-3.0.2-py36h1af98f8_3.conda: + build: py36h1af98f8_3 + build_number: 3 + constrains: + - bors <2.0 + depends: [] + license: MIT + license_family: MIT + md5: fb731d9290f0bcbf3a054665f33ec94f + name: foo + sha256: 67a63bec3fd3205170eaad532d487595b8aaceb9814d13c6858d7bac3ef24cd4 + size: 414494 + subdir: linux-64 + timestamp: 1715610974 + version: 3.0.2 foo-4.0.2-py36h1af98f8_2.tar.bz2: build: py36h1af98f8_2 build_number: 1 diff --git a/crates/rattler_solve/tests/backends.rs b/crates/rattler_solve/tests/backends.rs index 3a903824f..fc588273f 100644 --- a/crates/rattler_solve/tests/backends.rs +++ b/crates/rattler_solve/tests/backends.rs @@ -346,17 +346,17 @@ macro_rules! solver_backend_tests { assert_eq!(1, pkgs.len()); let info = &pkgs[0]; - assert_eq!("foo-3.0.2-py36h1af98f8_2.conda", info.file_name); + assert_eq!("foo-3.0.2-py36h1af98f8_3.conda", info.file_name); assert_eq!( - "https://conda.anaconda.org/conda-forge/linux-64/foo-3.0.2-py36h1af98f8_2.conda", + "https://conda.anaconda.org/conda-forge/linux-64/foo-3.0.2-py36h1af98f8_3.conda", info.url.to_string() ); assert_eq!("https://conda.anaconda.org/conda-forge/", info.channel); assert_eq!("foo", info.package_record.name.as_normalized()); assert_eq!("linux-64", info.package_record.subdir); assert_eq!("3.0.2", info.package_record.version.to_string()); - assert_eq!("py36h1af98f8_2", info.package_record.build); - assert_eq!(2, info.package_record.build_number); + assert_eq!("py36h1af98f8_3", info.package_record.build); + assert_eq!(3, info.package_record.build_number); assert_eq!( rattler_digest::parse_digest_from_hex::( "67a63bec3fd3205170eaad532d487595b8aaceb9814d13c6858d7bac3ef24cd4" @@ -665,17 +665,17 @@ mod libsolv_c { assert_eq!(1, pkgs.len()); let info = &pkgs[0]; - assert_eq!("foo-3.0.2-py36h1af98f8_2.conda", info.file_name); + assert_eq!("foo-3.0.2-py36h1af98f8_3.conda", info.file_name); assert_eq!( - "https://conda.anaconda.org/conda-forge/linux-64/foo-3.0.2-py36h1af98f8_2.conda", + "https://conda.anaconda.org/conda-forge/linux-64/foo-3.0.2-py36h1af98f8_3.conda", info.url.to_string() ); assert_eq!("https://conda.anaconda.org/conda-forge/", info.channel); assert_eq!("foo", info.package_record.name.as_normalized()); assert_eq!("linux-64", info.package_record.subdir); assert_eq!("3.0.2", info.package_record.version.to_string()); - assert_eq!("py36h1af98f8_2", info.package_record.build); - assert_eq!(2, info.package_record.build_number); + assert_eq!("py36h1af98f8_3", info.package_record.build); + assert_eq!(3, info.package_record.build_number); assert_eq!( rattler_digest::parse_digest_from_hex::( "67a63bec3fd3205170eaad532d487595b8aaceb9814d13c6858d7bac3ef24cd4" @@ -781,7 +781,7 @@ mod resolvo { Version::from_str("3.0.2").unwrap() ); assert_eq!( - result[0].package_record.build_number, 2, + result[0].package_record.build_number, 3, "expected the highest build number" ); } diff --git a/py-rattler/rattler/exceptions.py b/py-rattler/rattler/exceptions.py index 954789ca4..68d85f640 100644 --- a/py-rattler/rattler/exceptions.py +++ b/py-rattler/rattler/exceptions.py @@ -20,6 +20,7 @@ EnvironmentCreationError, ExtractError, GatewayError, + ValidatePackageRecordsException, ) except ImportError: # They are only redefined for documentation purposes @@ -85,6 +86,9 @@ class ExtractError(Exception): # type: ignore[no-redef] class GatewayError(Exception): # type: ignore[no-redef] """An error that can occur when querying the repodata gateway.""" + class ValidatePackageRecordsException(Exception): # type: ignore[no-redef] + """An error when validating package records.""" + __all__ = [ "ActivationError", @@ -107,4 +111,5 @@ class GatewayError(Exception): # type: ignore[no-redef] "EnvironmentCreationError", "ExtractError", "GatewayError", + "ValidatePackageRecordsException", ] diff --git a/py-rattler/rattler/repo_data/package_record.py b/py-rattler/rattler/repo_data/package_record.py index edf4846f7..df308595d 100644 --- a/py-rattler/rattler/repo_data/package_record.py +++ b/py-rattler/rattler/repo_data/package_record.py @@ -119,6 +119,37 @@ def to_graph(records: List[PackageRecord]) -> nx.DiGraph: # type: ignore[type-a return graph + @staticmethod + def validate(records: List[PackageRecord]) -> None: + """ + Validate that the given package records are valid w.r.t. 'depends' and 'constrains'. + + This function will return nothing if all records form a valid environment, i.e., all dependencies + of each package are satisfied by the other packages in the list. + If there is a dependency that is not satisfied, this function will raise an exception. + + Examples + -------- + ```python + >>> from os import listdir + >>> from os.path import isfile, join + >>> from rattler import PrefixRecord + >>> from rattler.exceptions import ValidatePackageRecordsException + >>> records = [ + ... PrefixRecord.from_path(join("../test-data/conda-meta/", f)) + ... for f in sorted(listdir("../test-data/conda-meta")) + ... if isfile(join("../test-data/conda-meta", f)) + ... ] + >>> try: + ... PackageRecord.validate(records) + ... except ValidatePackageRecordsException as e: + ... print(e) + package 'libsqlite=3.40.0=hcfcfb64_0' has dependency 'ucrt >=10.0.20348.0', which is not in the environment + >>> + ``` + """ + return PyRecord.validate(records) + @classmethod def _from_py_record(cls, py_record: PyRecord) -> PackageRecord: """ diff --git a/py-rattler/src/error.rs b/py-rattler/src/error.rs index 744998c57..73a4a09bc 100644 --- a/py-rattler/src/error.rs +++ b/py-rattler/src/error.rs @@ -4,8 +4,8 @@ use pyo3::{create_exception, exceptions::PyException, PyErr}; use rattler::install::TransactionError; use rattler_conda_types::{ ConvertSubdirError, InvalidPackageNameError, ParseArchError, ParseChannelError, - ParseMatchSpecError, ParsePlatformError, ParseVersionError, VersionBumpError, - VersionExtendError, + ParseMatchSpecError, ParsePlatformError, ParseVersionError, ValidatePackageRecordsError, + VersionBumpError, VersionExtendError, }; use rattler_lock::{ConversionError, ParseCondaLockError}; use rattler_package_streaming::ExtractError; @@ -70,6 +70,8 @@ pub enum PyRattlerError { GatewayError(#[from] GatewayError), #[error(transparent)] InstallerError(#[from] rattler::install::InstallerError), + #[error(transparent)] + ValidatePackageRecordsError(#[from] ValidatePackageRecordsError), } fn pretty_print_error(mut err: &dyn Error) -> String { @@ -154,6 +156,9 @@ impl From for PyErr { PyRattlerError::InstallerError(err) => { InstallerException::new_err(pretty_print_error(&err)) } + PyRattlerError::ValidatePackageRecordsError(err) => { + ValidatePackageRecordsException::new_err(pretty_print_error(&err)) + } } } } @@ -184,3 +189,4 @@ create_exception!(exceptions, ExtractException, PyException); create_exception!(exceptions, ActivationScriptFormatException, PyException); create_exception!(exceptions, GatewayException, PyException); create_exception!(exceptions, InstallerException, PyException); +create_exception!(exceptions, ValidatePackageRecordsException, PyException); diff --git a/py-rattler/src/lib.rs b/py-rattler/src/lib.rs index 3cfe48f3b..94d01b778 100644 --- a/py-rattler/src/lib.rs +++ b/py-rattler/src/lib.rs @@ -34,7 +34,7 @@ use error::{ InvalidChannelException, InvalidMatchSpecException, InvalidPackageNameException, InvalidUrlException, InvalidVersionException, IoException, LinkException, ParseArchException, ParsePlatformException, PyRattlerError, SolverException, TransactionException, - VersionBumpException, + ValidatePackageRecordsException, VersionBumpException, }; use generic_virtual_package::PyGenericVirtualPackage; use index::py_index; @@ -223,5 +223,11 @@ fn rattler(py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add("GatewayError", py.get_type::()) .unwrap(); + m.add( + "ValidatePackageRecordsException", + py.get_type::(), + ) + .unwrap(); + Ok(()) } diff --git a/py-rattler/src/record.rs b/py-rattler/src/record.rs index ab3b260fc..336d2fdb0 100644 --- a/py-rattler/src/record.rs +++ b/py-rattler/src/record.rs @@ -424,6 +424,19 @@ impl PyRecord { .map_err(PyRattlerError::from)?) } + /// Validate that the given package records are valid w.r.t. 'depends' and 'constrains'. + /// This function will return nothing if all records form a valid environment, i.e., all dependencies + /// of each package are satisfied by the other packages in the list. + /// If there is a dependency that is not satisfied, this function will raise an exception. + #[staticmethod] + fn validate(records: Vec<&PyAny>) -> PyResult<()> { + let records = records + .into_iter() + .map(PyRecord::try_from) + .collect::>>()?; + Ok(PackageRecord::validate(records).map_err(PyRattlerError::from)?) + } + /// Sorts the records topologically. /// /// This function is deterministic, meaning that it will return the same result diff --git a/test-data/channels/dummy/linux-64/repodata.json b/test-data/channels/dummy/linux-64/repodata.json index 378ecec96..edb62f8b3 100644 --- a/test-data/channels/dummy/linux-64/repodata.json +++ b/test-data/channels/dummy/linux-64/repodata.json @@ -62,6 +62,21 @@ "timestamp": 1715610974000, "version": "3.0.2" }, + "foo-3.0.2-py36h1af98f8_3.conda": { + "build": "py36h1af98f8_3", + "build_number": 3, + "depends": [], + "constrains": ["bors <2.0"], + "license": "MIT", + "license_family": "MIT", + "md5": "fb731d9290f0bcbf3a054665f33ec94f", + "name": "foo", + "sha256": "67a63bec3fd3205170eaad532d487595b8aaceb9814d13c6858d7bac3ef24cd4", + "size": 414494, + "subdir": "linux-64", + "timestamp": 1715610974000, + "version": "3.0.2" + }, "foo-4.0.2-py36h1af98f8_2.tar.bz2": { "build": "py36h1af98f8_2", "build_number": 1,