Skip to content

Commit

Permalink
feat: Add PackageRecord::validate function (#911)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelzw authored Nov 4, 2024
1 parent 7617946 commit 2042758
Show file tree
Hide file tree
Showing 13 changed files with 246 additions and 19 deletions.
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,
};

/// [`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"))
... 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

0 comments on commit 2042758

Please sign in to comment.