Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add validate_package_records function #911

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion .github/workflows/python-bindings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ jobs:
- name: Run tests
run: |
cd py-rattler
pixi run -e test test
pixi run -e test test --color=yes
2 changes: 1 addition & 1 deletion crates/rattler_conda_types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
124 changes: 121 additions & 3 deletions crates/rattler_conda_types/src/repo_data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ 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,
package::{IndexJson, RunExportsJson},
utils::serde::DeserializeFromStrUnchecked,
Channel, NoArchType, PackageName, PackageUrl, Platform, RepoDataRecord, VersionWithSource,
};
use crate::{
utils::serde::sort_map_alphabetically, MatchSpec, Matches, ParseMatchSpecError, ParseStrictness,
baszalmstra marked this conversation as resolved.
Show resolved Hide resolved
};

/// [`RepoData`] is an index of package binaries available on in a subdirectory
/// of a Conda channel.
Expand Down Expand Up @@ -275,6 +277,12 @@ pub fn compute_package_url(
.expect("failed to join base_url and filename")
}

impl AsRef<PackageRecord> for PackageRecord {
fn as_ref(&self) -> &PackageRecord {
self
}
}

impl PackageRecord {
/// A simple helper method that constructs a `PackageRecord` with the bare
/// minimum values.
Expand Down Expand Up @@ -315,6 +323,74 @@ impl PackageRecord {
pub fn sort_topologically<T: AsRef<PackageRecord> + Clone>(records: Vec<T>) -> Vec<T> {
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<T: AsRef<PackageRecord>>(
records: Vec<T>,
) -> 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.
Expand All @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
));
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions crates/rattler_solve/tests/backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<rattler_digest::Sha256>(
"67a63bec3fd3205170eaad532d487595b8aaceb9814d13c6858d7bac3ef24cd4"
Expand Down Expand Up @@ -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::<rattler_digest::Sha256>(
"67a63bec3fd3205170eaad532d487595b8aaceb9814d13c6858d7bac3ef24cd4"
Expand Down Expand Up @@ -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"
);
}
Expand Down
5 changes: 5 additions & 0 deletions py-rattler/rattler/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
EnvironmentCreationError,
ExtractError,
GatewayError,
ValidatePackageRecordsException,
)
except ImportError:
# They are only redefined for documentation purposes
Expand Down Expand Up @@ -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",
Expand All @@ -107,4 +111,5 @@ class GatewayError(Exception): # type: ignore[no-redef]
"EnvironmentCreationError",
"ExtractError",
"GatewayError",
"ValidatePackageRecordsException",
]
31 changes: 31 additions & 0 deletions py-rattler/rattler/repo_data/package_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sorted here is only to make this reproducible. If you have better ideas I'm open for them

... 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:
"""
Expand Down
Loading