diff --git a/Cargo.lock b/Cargo.lock index f75eba1dd..aa2188816 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1576,6 +1576,7 @@ version = "0.17.0-dev0" dependencies = [ "kernels-data", "pyo3", + "serde_json", ] [[package]] diff --git a/kernels-data/bindings/python/Cargo.toml b/kernels-data/bindings/python/Cargo.toml index ce37ed10c..9bb2feae2 100644 --- a/kernels-data/bindings/python/Cargo.toml +++ b/kernels-data/bindings/python/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.26", features = ["abi3", "abi3-py38"] } +serde_json = "1" [dependencies.kernels-data] path = "../.." diff --git a/kernels-data/bindings/python/kernels_data.pyi b/kernels-data/bindings/python/kernels_data.pyi index 0e3ea2cbd..53c29b7c5 100644 --- a/kernels-data/bindings/python/kernels_data.pyi +++ b/kernels-data/bindings/python/kernels_data.pyi @@ -1,7 +1,9 @@ """Type stubs for kernels_data module.""" import os +from collections.abc import Iterator from enum import Enum +from pathlib import Path from typing import Optional, final __all__ = [ @@ -14,9 +16,14 @@ __all__ = [ "GitStatus", "KernelBuilderVersion", "KernelDependency", + "KernelLock", + "KernelPaths", + "KernelLocks", "KernelName", "KernelVersion", "Metadata", + "NixKernelLock", + "NixKernelLocks", "Digest", "DigestViolation", "DigestValidationError", @@ -326,6 +333,270 @@ class KernelDependency: def __eq__(self, value: object, /) -> bool: ... def __hash__(self) -> int: ... +@final +class KernelLock: + """A locked kernel revision.""" + + def __new__(cls, commit: str) -> "KernelLock": + """Construct a lock from a commit. + + Raises: + ValueError: If `commit` is not a full git object id. + """ + ... + + @property + def commit(self) -> str: + """Locked commit of the kernel, as lowercase hexadecimal digits.""" + ... + + @staticmethod + def from_json(s: str) -> "KernelLock": + """Parse a `KernelLock` from a JSON string. + + Raises: + ValueError: If the JSON cannot be parsed. + """ + ... + + def to_json(self) -> str: + """Serialize the lock to a pretty-printed JSON string. + + Raises: + ValueError: If the lock cannot be serialized. + """ + ... + + def __repr__(self) -> str: ... + def __eq__(self, value: object, /) -> bool: ... + def __hash__(self) -> int: ... + +@final +class KernelLocks: + """Multiple kernel locks keyed by the dependency they resolve. + + Behaves as a read-only mapping from `KernelDependency` to `KernelLock`. + """ + + def __new__(cls, locks: dict[KernelDependency, KernelLock]) -> "KernelLocks": ... + def __len__(self) -> int: ... + def __getitem__(self, dependency: KernelDependency, /) -> KernelLock: + """Get the lock for `dependency`. + + Raises: + KeyError: If the dependency is not locked. + """ + ... + + def __contains__(self, dependency: object, /) -> bool: ... + def __iter__(self) -> Iterator[KernelDependency]: ... + def get( + self, dependency: KernelDependency, default: Optional[KernelLock] = None + ) -> Optional[KernelLock]: + """Get the lock for `dependency`, or `default` if it is not locked.""" + ... + + def keys(self) -> list[KernelDependency]: + """Get the locked dependencies.""" + ... + + def values(self) -> list[KernelLock]: + """Get the kernel locks.""" + ... + + def items(self) -> list[tuple[KernelDependency, KernelLock]]: + """Get the (dependency, lock) pairs.""" + ... + + @staticmethod + def from_json(s: str) -> "KernelLocks": + """Parse a `KernelLocks` collection from a JSON string. + + Raises: + ValueError: If the JSON cannot be parsed. + """ + ... + + def to_json(self) -> str: + """Serialize the locks collection to a pretty-printed JSON string. + + Raises: + ValueError: If the locks cannot be serialized. + """ + ... + + def __repr__(self) -> str: ... + def __eq__(self, value: object, /) -> bool: ... + def __hash__(self) -> int: ... + +@final +class NixKernelLock: + """A locked kernel revision with the SRI hash of the Nix output path.""" + + def __new__(cls, commit: str, hash: str) -> "NixKernelLock": + """Construct a lock from a commit and a SRI hash. + + Raises: + ValueError: If `commit` is not a full git object id. + """ + ... + + @property + def commit(self) -> str: + """Locked commit of the kernel, as lowercase hexadecimal digits.""" + ... + + @property + def hash(self) -> str: + """SRI hash of the repository snapshot, as used by fixed-output derivations.""" + ... + + @staticmethod + def from_json(s: str) -> "NixKernelLock": + """Parse a `NixKernelLock` from a JSON string. + + Raises: + ValueError: If the JSON cannot be parsed. + """ + ... + + def to_json(self) -> str: + """Serialize the lock to a pretty-printed JSON string. + + Raises: + ValueError: If the lock cannot be serialized. + """ + ... + + def __repr__(self) -> str: ... + def __eq__(self, value: object, /) -> bool: ... + def __hash__(self) -> int: ... + +@final +class NixKernelLocks: + """Multiple (Nix) kernel locks keyed by the dependency they resolve. + + This data structure is used to store lock files to be consumed + by nix-builder. + + Behaves as a read-only mapping from `KernelDependency` to `NixKernelLock`. + """ + + def __new__( + cls, locks: dict[KernelDependency, NixKernelLock] + ) -> "NixKernelLocks": ... + def __len__(self) -> int: ... + def __getitem__(self, dependency: KernelDependency, /) -> NixKernelLock: + """Get the lock for `dependency`. + + Raises: + KeyError: If the dependency is not locked. + """ + ... + + def __contains__(self, dependency: object, /) -> bool: ... + def __iter__(self) -> Iterator[KernelDependency]: ... + def get( + self, dependency: KernelDependency, default: Optional[NixKernelLock] = None + ) -> Optional[NixKernelLock]: + """Get the lock for `dependency`, or `default` if it is not locked.""" + ... + + def keys(self) -> list[KernelDependency]: + """Get the locked dependencies.""" + ... + + def values(self) -> list[NixKernelLock]: + """Get the kernel locks.""" + ... + + def items(self) -> list[tuple[KernelDependency, NixKernelLock]]: + """Get the (dependency, lock) pairs.""" + ... + + @staticmethod + def from_json(s: str) -> "NixKernelLocks": + """Parse a `NixKernelLocks` collection from a JSON string. + + Raises: + ValueError: If the JSON cannot be parsed. + """ + ... + + def to_json(self) -> str: + """Serialize the locks collection to a pretty-printed JSON string. + + Raises: + ValueError: If the locks cannot be serialized. + """ + ... + + def __repr__(self) -> str: ... + def __eq__(self, value: object, /) -> bool: ... + def __hash__(self) -> int: ... + +@final +class KernelPaths: + """A collection of kernel paths keyed by the dependency they resolve. + + Behaves as a read-only mapping from `KernelDependency` to `pathlib.Path`. + """ + + def __new__( + cls, paths: dict[KernelDependency, os.PathLike[str] | str] + ) -> "KernelPaths": ... + def __len__(self) -> int: ... + def __getitem__(self, dependency: KernelDependency, /) -> Path: + """Get the path for `dependency`. + + Raises: + KeyError: If the dependency has no path. + """ + ... + + def __contains__(self, dependency: object, /) -> bool: ... + def __iter__(self) -> Iterator[KernelDependency]: ... + def get( + self, + dependency: KernelDependency, + default: Optional[os.PathLike[str] | str] = None, + ) -> Optional[Path]: + """Get the path for `dependency`, or `default` if it has no path.""" + ... + + def keys(self) -> list[KernelDependency]: + """Get the dependencies.""" + ... + + def values(self) -> list[Path]: + """Get the kernel paths.""" + ... + + def items(self) -> list[tuple[KernelDependency, Path]]: + """Get the (dependency, path) pairs.""" + ... + + @staticmethod + def from_json(s: str) -> "KernelPaths": + """Parse a `KernelPaths` collection from a JSON string. + + Raises: + ValueError: If the JSON cannot be parsed. + """ + ... + + def to_json(self) -> str: + """Serialize the paths collection to a pretty-printed JSON string. + + Raises: + ValueError: If the paths cannot be serialized. + """ + ... + + def __repr__(self) -> str: ... + def __eq__(self, value: object, /) -> bool: ... + def __hash__(self) -> int: ... + @final class General: """General kernel configuration common to all backends.""" diff --git a/kernels-data/bindings/python/src/lib.rs b/kernels-data/bindings/python/src/lib.rs index bce6e81fb..5ea8ce836 100644 --- a/kernels-data/bindings/python/src/lib.rs +++ b/kernels-data/bindings/python/src/lib.rs @@ -14,8 +14,10 @@ use pyo3::exceptions::{PyException, PyOSError, PyRuntimeError, PyValueError}; use pyo3::prelude::*; mod config; +mod lock; use config::{PyBuild, PyGeneral}; +use lock::{PyKernelLock, PyKernelLocks, PyKernelPaths, PyNixKernelLock, PyNixKernelLocks}; /// A dotted numeric version (e.g. `12.8.0`). Trailing zeros are stripped /// during normalization. @@ -323,7 +325,7 @@ impl PyProvenance { /// A kernel version: either a numeric version or a git revision string. #[pyclass(name = "KernelVersion", frozen, eq, hash)] -#[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] enum PyKernelVersion { Version { version: usize }, Revision { revision: String }, @@ -338,6 +340,15 @@ impl From for PyKernelVersion { } } +impl From for KernelVersion { + fn from(v: PyKernelVersion) -> Self { + match v { + PyKernelVersion::Version { version } => Self::Version(version), + PyKernelVersion::Revision { revision } => Self::Revision(revision), + } + } +} + #[pymethods] impl PyKernelVersion { fn __repr__(&self) -> String { @@ -352,7 +363,7 @@ impl PyKernelVersion { /// A dependency on another kernel. #[pyclass(name = "KernelDependency", frozen, eq, hash)] -#[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct PyKernelDependency { repo_id: String, version: PyKernelVersion, @@ -367,6 +378,15 @@ impl From for PyKernelDependency { } } +impl From for KernelDependency { + fn from(d: PyKernelDependency) -> Self { + Self { + repo_id: d.repo_id, + version: d.version.into(), + } + } +} + #[pymethods] impl PyKernelDependency { #[new] @@ -759,6 +779,11 @@ fn kernels_data_py(m: &PyBound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/kernels-data/bindings/python/src/lock.rs b/kernels-data/bindings/python/src/lock.rs new file mode 100644 index 000000000..c61b2e003 --- /dev/null +++ b/kernels-data/bindings/python/src/lock.rs @@ -0,0 +1,525 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::str::FromStr; + +use kernels_data::git::Oid; +use kernels_data::lock::{KernelLock, KernelLocks, KernelPaths, NixKernelLock, NixKernelLocks}; +use pyo3::Bound as PyBound; +use pyo3::exceptions::{PyKeyError, PyValueError}; +use pyo3::prelude::*; + +use crate::PyKernelDependency; + +/// Parse a git object id, mapping a parse failure to a Python `ValueError`. +fn parse_oid(s: &str) -> PyResult { + Oid::from_str(s).map_err(|err| PyValueError::new_err(err.to_string())) +} + +/// A locked kernel revision. +#[pyclass(name = "KernelLock", frozen, eq, hash)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct PyKernelLock { + commit: Oid, +} + +impl From for PyKernelLock { + fn from(lock: KernelLock) -> Self { + Self { + commit: lock.commit, + } + } +} + +impl From for KernelLock { + fn from(lock: PyKernelLock) -> Self { + Self { + commit: lock.commit, + } + } +} + +#[pymethods] +impl PyKernelLock { + #[new] + fn new(commit: &str) -> PyResult { + Ok(Self { + commit: parse_oid(commit)?, + }) + } + + #[getter] + fn commit(&self) -> &str { + self.commit.as_str() + } + + /// Parse a `KernelLock` from a JSON string. + #[staticmethod] + #[pyo3(name = "from_json")] + fn py_from_json(s: &str) -> PyResult { + let lock: KernelLock = serde_json::from_str(s) + .map_err(|err| PyValueError::new_err(format!("Cannot parse KernelLock: {err:#}")))?; + Ok(lock.into()) + } + + /// Serialize the lock to a pretty-printed JSON string. + fn to_json(&self) -> PyResult { + let lock: KernelLock = self.clone().into(); + serde_json::to_string_pretty(&lock) + .map_err(|err| PyValueError::new_err(format!("Cannot serialize KernelLock: {err:#}"))) + } + + fn __repr__(&self) -> String { + format!("KernelLock(commit={:?})", self.commit.as_str()) + } +} + +/// Multiple kernel locks keyed by the dependency they resolve. +#[pyclass(name = "KernelLocks", frozen, eq, hash)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct PyKernelLocks { + locks: BTreeMap, +} + +/// Iterator over the dependencies of a [`PyKernelLocks`] or [`PyKernelPaths`]. +#[pyclass(name = "KernelDependencyIterator")] +struct PyKernelDependencyIterator { + dependencies: std::vec::IntoIter, +} + +#[pymethods] +impl PyKernelDependencyIterator { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(&mut self) -> Option { + self.dependencies.next() + } +} + +impl From for PyKernelLocks { + fn from(locks: KernelLocks) -> Self { + Self { + locks: locks + .locks + .into_iter() + .map(|(dep, lock)| (dep.into(), lock.into())) + .collect(), + } + } +} + +impl From for KernelLocks { + fn from(locks: PyKernelLocks) -> Self { + Self { + locks: locks + .locks + .into_iter() + .map(|(dep, lock)| (dep.into(), lock.into())) + .collect(), + } + } +} + +#[pymethods] +impl PyKernelLocks { + #[new] + fn new(locks: BTreeMap) -> Self { + Self { locks } + } + + fn __len__(&self) -> usize { + self.locks.len() + } + + fn __getitem__(&self, dependency: &PyKernelDependency) -> PyResult { + self.locks + .get(dependency) + .cloned() + .ok_or_else(|| PyKeyError::new_err(dependency.__repr__())) + } + + fn __contains__(&self, dependency: &PyBound<'_, PyAny>) -> bool { + dependency + .extract::() + .is_ok_and(|dependency| self.locks.contains_key(&dependency)) + } + + fn __iter__(&self) -> PyKernelDependencyIterator { + PyKernelDependencyIterator { + dependencies: self.keys().into_iter(), + } + } + + /// Get the lock for `dependency`, or `default` if it is not locked. + #[pyo3(signature = (dependency, default = None))] + fn get( + &self, + dependency: &PyBound<'_, PyAny>, + default: Option, + ) -> Option { + dependency + .extract::() + .ok() + .and_then(|dependency| self.locks.get(&dependency).cloned()) + .or(default) + } + + /// Get the locked dependencies. + fn keys(&self) -> Vec { + self.locks.keys().cloned().collect() + } + + /// Get the kernel locks. + fn values(&self) -> Vec { + self.locks.values().cloned().collect() + } + + /// Get the (dependency, lock) pairs. + fn items(&self) -> Vec<(PyKernelDependency, PyKernelLock)> { + self.locks + .iter() + .map(|(dependency, lock)| (dependency.clone(), lock.clone())) + .collect() + } + + /// Parse a `KernelLocks` collection from a JSON string. + #[staticmethod] + #[pyo3(name = "from_json")] + fn py_from_json(s: &str) -> PyResult { + let locks: KernelLocks = serde_json::from_str(s) + .map_err(|err| PyValueError::new_err(format!("Cannot parse KernelLocks: {err:#}")))?; + Ok(locks.into()) + } + + /// Serialize the locks collection to a pretty-printed JSON string. + fn to_json(&self) -> PyResult { + let locks: KernelLocks = self.clone().into(); + serde_json::to_string_pretty(&locks) + .map_err(|err| PyValueError::new_err(format!("Cannot serialize KernelLocks: {err:#}"))) + } + + fn __repr__(&self) -> String { + let locks = self + .locks + .iter() + .map(|(dependency, lock)| format!("{}: {}", dependency.__repr__(), lock.__repr__())) + .collect::>() + .join(", "); + format!("KernelLocks({{{locks}}})") + } +} + +/// A locked kernel revision with the SRI hash of the Nix output path. +#[pyclass(name = "NixKernelLock", frozen, eq, hash)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct PyNixKernelLock { + commit: Oid, + hash: String, +} + +impl From for PyNixKernelLock { + fn from(lock: NixKernelLock) -> Self { + Self { + commit: lock.commit, + hash: lock.hash, + } + } +} + +impl From for NixKernelLock { + fn from(lock: PyNixKernelLock) -> Self { + Self { + commit: lock.commit, + hash: lock.hash, + } + } +} + +#[pymethods] +impl PyNixKernelLock { + #[new] + fn new(commit: &str, hash: String) -> PyResult { + Ok(Self { + commit: parse_oid(commit)?, + hash, + }) + } + + #[getter] + fn commit(&self) -> &str { + self.commit.as_str() + } + + #[getter] + fn hash(&self) -> &str { + &self.hash + } + + /// Parse a `NixKernelLock` from a JSON string. + #[staticmethod] + #[pyo3(name = "from_json")] + fn py_from_json(s: &str) -> PyResult { + let lock: NixKernelLock = serde_json::from_str(s) + .map_err(|err| PyValueError::new_err(format!("Cannot parse NixKernelLock: {err:#}")))?; + Ok(lock.into()) + } + + /// Serialize the lock to a pretty-printed JSON string. + fn to_json(&self) -> PyResult { + let lock: NixKernelLock = self.clone().into(); + serde_json::to_string_pretty(&lock).map_err(|err| { + PyValueError::new_err(format!("Cannot serialize NixKernelLock: {err:#}")) + }) + } + + fn __repr__(&self) -> String { + format!( + "NixKernelLock(commit={:?}, hash={:?})", + self.commit.as_str(), + self.hash + ) + } +} + +/// Multiple (Nix) kernel locks keyed by the dependency they resolve. +/// +/// +/// This data structure is used to store lock files to be consumed +/// by nix-builder. +#[pyclass(name = "NixKernelLocks", frozen, eq, hash)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct PyNixKernelLocks { + locks: BTreeMap, +} + +impl From for PyNixKernelLocks { + fn from(locks: NixKernelLocks) -> Self { + Self { + locks: locks + .locks + .into_iter() + .map(|(dep, lock)| (dep.into(), lock.into())) + .collect(), + } + } +} + +impl From for NixKernelLocks { + fn from(locks: PyNixKernelLocks) -> Self { + Self { + locks: locks + .locks + .into_iter() + .map(|(dep, lock)| (dep.into(), lock.into())) + .collect(), + } + } +} + +#[pymethods] +impl PyNixKernelLocks { + #[new] + fn new(locks: BTreeMap) -> Self { + Self { locks } + } + + fn __len__(&self) -> usize { + self.locks.len() + } + + fn __getitem__(&self, dependency: &PyKernelDependency) -> PyResult { + self.locks + .get(dependency) + .cloned() + .ok_or_else(|| PyKeyError::new_err(dependency.__repr__())) + } + + fn __contains__(&self, dependency: &PyBound<'_, PyAny>) -> bool { + dependency + .extract::() + .is_ok_and(|dependency| self.locks.contains_key(&dependency)) + } + + fn __iter__(&self) -> PyKernelDependencyIterator { + PyKernelDependencyIterator { + dependencies: self.keys().into_iter(), + } + } + + /// Get the lock for `dependency`, or `default` if it is not locked. + #[pyo3(signature = (dependency, default = None))] + fn get( + &self, + dependency: &PyBound<'_, PyAny>, + default: Option, + ) -> Option { + dependency + .extract::() + .ok() + .and_then(|dependency| self.locks.get(&dependency).cloned()) + .or(default) + } + + /// Get the locked dependencies. + fn keys(&self) -> Vec { + self.locks.keys().cloned().collect() + } + + /// Get the kernel locks. + fn values(&self) -> Vec { + self.locks.values().cloned().collect() + } + + /// Get the (dependency, lock) pairs. + fn items(&self) -> Vec<(PyKernelDependency, PyNixKernelLock)> { + self.locks + .iter() + .map(|(dependency, lock)| (dependency.clone(), lock.clone())) + .collect() + } + + /// Parse a `NixKernelLocks` collection from a JSON string. + #[staticmethod] + #[pyo3(name = "from_json")] + fn py_from_json(s: &str) -> PyResult { + let locks: NixKernelLocks = serde_json::from_str(s).map_err(|err| { + PyValueError::new_err(format!("Cannot parse NixKernelLocks: {err:#}")) + })?; + Ok(locks.into()) + } + + /// Serialize the locks collection to a pretty-printed JSON string. + fn to_json(&self) -> PyResult { + let locks: NixKernelLocks = self.clone().into(); + serde_json::to_string_pretty(&locks).map_err(|err| { + PyValueError::new_err(format!("Cannot serialize NixKernelLocks: {err:#}")) + }) + } + + fn __repr__(&self) -> String { + let locks = self + .locks + .iter() + .map(|(dependency, lock)| format!("{}: {}", dependency.__repr__(), lock.__repr__())) + .collect::>() + .join(", "); + format!("NixKernelLocks({{{locks}}})") + } +} + +/// A collection of kernel paths keyed by the dependency they resolve. +#[pyclass(name = "KernelPaths", frozen, eq, hash)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct PyKernelPaths { + paths: BTreeMap, +} + +impl From for PyKernelPaths { + fn from(paths: KernelPaths) -> Self { + Self { + paths: paths + .paths + .into_iter() + .map(|(dep, path)| (dep.into(), path)) + .collect(), + } + } +} + +impl From for KernelPaths { + fn from(paths: PyKernelPaths) -> Self { + Self { + paths: paths + .paths + .into_iter() + .map(|(dep, path)| (dep.into(), path)) + .collect(), + } + } +} + +#[pymethods] +impl PyKernelPaths { + #[new] + fn new(paths: BTreeMap) -> Self { + Self { paths } + } + + fn __len__(&self) -> usize { + self.paths.len() + } + + fn __getitem__(&self, dependency: &PyKernelDependency) -> PyResult { + self.paths + .get(dependency) + .cloned() + .ok_or_else(|| PyKeyError::new_err(dependency.__repr__())) + } + + fn __contains__(&self, dependency: &PyBound<'_, PyAny>) -> bool { + dependency + .extract::() + .is_ok_and(|dependency| self.paths.contains_key(&dependency)) + } + + fn __iter__(&self) -> PyKernelDependencyIterator { + PyKernelDependencyIterator { + dependencies: self.keys().into_iter(), + } + } + + /// Get the path for `dependency`, or `default` if it has no path. + #[pyo3(signature = (dependency, default = None))] + fn get(&self, dependency: &PyBound<'_, PyAny>, default: Option) -> Option { + dependency + .extract::() + .ok() + .and_then(|dependency| self.paths.get(&dependency).cloned()) + .or(default) + } + + /// Get the dependencies. + fn keys(&self) -> Vec { + self.paths.keys().cloned().collect() + } + + /// Get the kernel paths. + fn values(&self) -> Vec { + self.paths.values().cloned().collect() + } + + /// Get the (dependency, path) pairs. + fn items(&self) -> Vec<(PyKernelDependency, PathBuf)> { + self.paths + .iter() + .map(|(dependency, path)| (dependency.clone(), path.clone())) + .collect() + } + + /// Parse a `KernelPaths` collection from a JSON string. + #[staticmethod] + #[pyo3(name = "from_json")] + fn py_from_json(s: &str) -> PyResult { + let paths: KernelPaths = serde_json::from_str(s) + .map_err(|err| PyValueError::new_err(format!("Cannot parse KernelPaths: {err:#}")))?; + Ok(paths.into()) + } + + /// Serialize the paths collection to a pretty-printed JSON string. + fn to_json(&self) -> PyResult { + let paths: KernelPaths = self.clone().into(); + serde_json::to_string_pretty(&paths) + .map_err(|err| PyValueError::new_err(format!("Cannot serialize KernelPaths: {err:#}"))) + } + + fn __repr__(&self) -> String { + let paths = self + .paths + .iter() + .map(|(dependency, path)| format!("{}: {:?}", dependency.__repr__(), path)) + .collect::>() + .join(", "); + format!("KernelPaths({{{paths}}})") + } +} diff --git a/kernels-data/bindings/python/tests/test_kernel_lock.py b/kernels-data/bindings/python/tests/test_kernel_lock.py new file mode 100644 index 000000000..0a60b53e3 --- /dev/null +++ b/kernels-data/bindings/python/tests/test_kernel_lock.py @@ -0,0 +1,202 @@ +from pathlib import Path + +import pytest + +from kernels_data import ( + KernelDependency, + KernelLock, + KernelLocks, + KernelPaths, + KernelVersion, + NixKernelLock, + NixKernelLocks, +) + +COMMIT_RELU = "d649efb56fb249ac8f7a57fa1866728ad0c60e52" +COMMIT_VERSIONS = "f609e51b856b3d874b0ae8445913e200f02c1735" +SRI_HASH = "sha256-1CM0MGEOCqqnYV989jcUANEbiB727JftjXTdQVUHPwk=" + + +def _dep(repo_id, version): + return KernelDependency(repo_id=repo_id, version=KernelVersion.Version(version)) + + +def _relu_dep(): + return _dep("kernels-community/relu", 1) + + +def _versions_dep(): + return _dep("kernels-test/versions", 2) + + +def _sample_locks(): + return KernelLocks( + { + _versions_dep(): KernelLock(COMMIT_VERSIONS), + _relu_dep(): KernelLock(COMMIT_RELU), + } + ) + + +def _sample_nix_locks(): + return NixKernelLocks( + { + _versions_dep(): NixKernelLock(COMMIT_VERSIONS, SRI_HASH), + _relu_dep(): NixKernelLock(COMMIT_RELU, SRI_HASH), + } + ) + + +def test_kernel_lock_commit(): + lock = KernelLock(COMMIT_RELU) + assert lock.commit == COMMIT_RELU + + +def test_kernel_lock_invalid_commit(): + with pytest.raises(ValueError): + KernelLock("d649efb") + + +def test_kernel_lock_json_round_trip(): + lock = KernelLock(COMMIT_RELU) + assert KernelLock.from_json(lock.to_json()) == lock + + with pytest.raises(ValueError): + KernelLock.from_json("{not json") + + +def test_kernel_locks_mapping_protocol(): + locks = _sample_locks() + + assert len(locks) == 2 + assert locks[_relu_dep()] == KernelLock(COMMIT_RELU) + assert _versions_dep() in locks + assert _dep("kernels-community/absent", 1) not in locks + assert "not-a-dependency" not in locks + assert set(iter(locks)) == {_relu_dep(), _versions_dep()} + + +def test_kernel_locks_keys_values_items(): + locks = _sample_locks() + + assert locks.keys() == [_relu_dep(), _versions_dep()] + assert locks.values() == [KernelLock(COMMIT_RELU), KernelLock(COMMIT_VERSIONS)] + assert locks.items() == [ + (_relu_dep(), KernelLock(COMMIT_RELU)), + (_versions_dep(), KernelLock(COMMIT_VERSIONS)), + ] + + +def test_kernel_locks_get_default(): + locks = _sample_locks() + missing = _dep("kernels-community/absent", 1) + default = KernelLock(COMMIT_VERSIONS) + + assert locks.get(_relu_dep()) == KernelLock(COMMIT_RELU) + assert locks.get(missing) is None + assert locks.get(missing, default) == default + + +def test_kernel_locks_getitem_missing_raises_key_error(): + with pytest.raises(KeyError): + _sample_locks()[_dep("kernels-community/absent", 1)] + + +def test_kernel_locks_iteration_order_is_sorted(): + forward = KernelLocks( + { + _relu_dep(): KernelLock(COMMIT_RELU), + _versions_dep(): KernelLock(COMMIT_VERSIONS), + } + ) + reverse = KernelLocks( + { + _versions_dep(): KernelLock(COMMIT_VERSIONS), + _relu_dep(): KernelLock(COMMIT_RELU), + } + ) + + assert forward.keys() == [_relu_dep(), _versions_dep()] + assert forward.keys() == reverse.keys() + + +def test_kernel_locks_json_round_trip(): + locks = _sample_locks() + assert KernelLocks.from_json(locks.to_json()) == locks + + +def test_kernel_locks_from_json_rejects_invalid(): + with pytest.raises(ValueError): + KernelLocks.from_json("{not json") + + duplicate = f"""[ + {{"dependency": {{"repo-id": "kernels-test/versions", "version": 2}}, + "lock": {{"commit": "{COMMIT_VERSIONS}"}}}}, + {{"dependency": {{"repo-id": "kernels-test/versions", "version": 2}}, + "lock": {{"commit": "{COMMIT_RELU}"}}}} + ]""" + with pytest.raises(ValueError): + KernelLocks.from_json(duplicate) + + +def test_nix_kernel_lock_construction(): + lock = NixKernelLock(COMMIT_RELU, SRI_HASH) + assert lock.commit == COMMIT_RELU + assert lock.hash == SRI_HASH + + with pytest.raises(ValueError): + NixKernelLock("d649efb", SRI_HASH) + + +def test_nix_kernel_lock_json_round_trip(): + lock = NixKernelLock(COMMIT_RELU, SRI_HASH) + assert NixKernelLock.from_json(lock.to_json()) == lock + + +def test_nix_kernel_locks_mapping_smoke(): + locks = _sample_nix_locks() + + assert len(locks) == 2 + assert locks[_relu_dep()] == NixKernelLock(COMMIT_RELU, SRI_HASH) + assert _versions_dep() in locks + assert locks.get(_dep("kernels-community/absent", 1)) is None + + +def test_nix_kernel_locks_json_round_trip(): + locks = _sample_nix_locks() + assert NixKernelLocks.from_json(locks.to_json()) == locks + + +def test_nix_kernel_locks_entry_without_hash_is_rejected(): + json = ( + '[{"dependency": {"repo-id": "kernels-test/versions", "version": 2},' + f' "lock": {{"commit": "{COMMIT_VERSIONS}"}}}}]' + ) + with pytest.raises(ValueError): + NixKernelLocks.from_json(json) + + +def test_kernel_paths_mapping_smoke(): + paths = KernelPaths( + { + _versions_dep(): "/kernels/versions", + _relu_dep(): Path("/kernels/relu"), + } + ) + + assert len(paths) == 2 + assert paths[_relu_dep()] == Path("/kernels/relu") + assert paths[_versions_dep()] == Path("/kernels/versions") + assert _versions_dep() in paths + assert paths.get(_dep("kernels-community/absent", 1)) is None + assert paths.values() == [Path("/kernels/relu"), Path("/kernels/versions")] + + +def test_kernel_paths_json_round_trip(): + paths = KernelPaths( + { + _versions_dep(): Path("/kernels/versions"), + _relu_dep(): Path("/kernels/relu"), + } + ) + assert KernelPaths.from_json(paths.to_json()) == paths diff --git a/kernels-data/src/config/kernel_deps.rs b/kernels-data/src/config/kernel_deps.rs index a00c72000..2573a0f4c 100644 --- a/kernels-data/src/config/kernel_deps.rs +++ b/kernels-data/src/config/kernel_deps.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize, de}; /// Kernel version (numeric or Git revision). -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] #[serde(untagged, rename_all = "kebab-case")] pub enum KernelVersion { Version(usize), @@ -9,7 +9,7 @@ pub enum KernelVersion { } /// A kernel dependency. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct KernelDependency { pub repo_id: String, pub version: KernelVersion, @@ -27,7 +27,12 @@ pub struct KernelDependency { #[serde(deny_unknown_fields, rename_all = "kebab-case")] struct KernelDependencyRepr { repo_id: String, + // `version` and `revision` are mutually exclusive, so only ever serialize + // the variant that is in use. Missing fields deserialize as `None`, since + // serde treats `Option` fields as optional. + #[serde(skip_serializing_if = "Option::is_none")] version: Option, + #[serde(skip_serializing_if = "Option::is_none")] revision: Option, } @@ -114,6 +119,47 @@ mod tests { )); } + #[test] + fn only_the_version_in_use_is_serialized_as_json() { + let version = KernelDependency { + repo_id: "kernels-staging/einops".to_string(), + version: KernelVersion::Version(1), + }; + assert_eq!( + serde_json::to_value(&version).unwrap(), + serde_json::json!({"repo-id": "kernels-staging/einops", "version": 1}) + ); + + let revision = KernelDependency { + repo_id: "kernels-staging/einops".to_string(), + version: KernelVersion::Revision("34fa".to_string()), + }; + assert_eq!( + serde_json::to_value(&revision).unwrap(), + serde_json::json!({"repo-id": "kernels-staging/einops", "revision": "34fa"}) + ); + } + + #[test] + fn json_round_trips() { + for dep in [ + KernelDependency { + repo_id: "kernels-staging/einops".to_string(), + version: KernelVersion::Version(1), + }, + KernelDependency { + repo_id: "kernels-staging/einops".to_string(), + version: KernelVersion::Revision("34fa".to_string()), + }, + ] { + let json = serde_json::to_string(&dep).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + dep + ); + } + } + #[test] fn version_string_is_rejected() { let toml_str = r#" diff --git a/kernels-data/src/lib.rs b/kernels-data/src/lib.rs index 0ede64729..5dc92f3bc 100644 --- a/kernels-data/src/lib.rs +++ b/kernels-data/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod digest; pub mod git; +pub mod lock; pub mod metadata; pub mod version; diff --git a/kernels-data/src/lock.rs b/kernels-data/src/lock.rs new file mode 100644 index 000000000..dbfa8c1db --- /dev/null +++ b/kernels-data/src/lock.rs @@ -0,0 +1,529 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::config::KernelDependency; +use crate::git::Oid; + +/// A locked kernel revision. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct KernelLock { + /// Locked git SHA. + pub commit: Oid, +} + +/// Multiple kernel locks keyed by the dependency they resolve. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct KernelLocks { + #[serde(with = "dependency_map_as_list")] + pub locks: BTreeMap, +} + +impl FromIterator<(KernelDependency, KernelLock)> for KernelLocks { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + Self { + locks: iter.into_iter().collect(), + } + } +} + +/// A locked kernel revision with the SRI hash of the Nix output path. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct NixKernelLock { + /// Locked git SHA. + pub commit: Oid, + + /// SRI hash of the Nix store path. + pub hash: String, +} + +/// Multiple (Nix) kernel locks keyed by the dependency they resolve. +/// +/// This data structure is used to store lock files to be consumed +/// by nix-builder. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct NixKernelLocks { + #[serde(with = "dependency_map_as_list")] + pub locks: BTreeMap, +} + +impl FromIterator<(KernelDependency, NixKernelLock)> for NixKernelLocks { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + Self { + locks: iter.into_iter().collect(), + } + } +} + +/// A collection of kernel paths keyed by the dependency they resolve. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct KernelPaths { + #[serde(with = "paths_as_list")] + pub paths: BTreeMap, +} + +impl FromIterator<(KernelDependency, PathBuf)> for KernelPaths { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + Self { + paths: iter.into_iter().collect(), + } + } +} + +/// Serialize `BTreeMap` as a list of +/// `{"dependency": ..., "lock": ...}` objects so that the dependency can be +/// preserved as a structured value while remaining valid JSON (JSON object +/// keys must be strings). +/// +/// This is generic over the lock type so that [`KernelLocks`](super::KernelLocks) +/// and [`NixKernelLocks`](super::NixKernelLocks) share a single representation. +mod dependency_map_as_list { + use std::collections::{BTreeMap, btree_map}; + + use serde::{Deserialize, Serialize, de, ser}; + + use crate::config::KernelDependency; + + #[derive(Serialize)] + #[serde(rename_all = "kebab-case")] + struct EntryRef<'a, L> { + dependency: &'a KernelDependency, + lock: &'a L, + } + + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Entry { + dependency: KernelDependency, + lock: L, + } + + pub fn serialize( + locks: &BTreeMap, + serializer: S, + ) -> Result + where + S: ser::Serializer, + L: Serialize, + { + serializer.collect_seq( + locks + .iter() + .map(|(dependency, lock)| EntryRef { dependency, lock }), + ) + } + + pub fn deserialize<'de, D, L>( + deserializer: D, + ) -> Result, D::Error> + where + D: de::Deserializer<'de>, + L: Deserialize<'de>, + { + let entries = Vec::>::deserialize(deserializer)?; + let mut locks = BTreeMap::new(); + for entry in entries { + match locks.entry(entry.dependency) { + btree_map::Entry::Vacant(slot) => { + slot.insert(entry.lock); + } + btree_map::Entry::Occupied(slot) => { + return Err(de::Error::custom(format!( + "duplicate dependency: {:?}", + slot.key() + ))); + } + } + } + Ok(locks) + } +} + +/// Serialize `BTreeMap` as a list of +/// `{"dependency": ..., "path": ...}` objects so that the dependency can be +/// preserved as a structured value while remaining valid JSON (JSON object +/// keys must be strings). +mod paths_as_list { + // Note, despite the similarities of this module to `dependency_map_as_list`, + // we cannot make reuse that since the field names are different. Using a + // macro probably only complicates the already dense serialization code. + // + // Basic rule: when in doubt, don't use macros. + + use std::collections::{BTreeMap, btree_map}; + use std::path::{Path, PathBuf}; + + use serde::{Deserialize, Serialize, de, ser}; + + use crate::config::KernelDependency; + + /// Borrowing counterpart of [`Entry`], so that serializing does not have to + /// clone the paths. + #[derive(Serialize)] + #[serde(rename_all = "kebab-case")] + struct EntryRef<'a> { + dependency: &'a KernelDependency, + path: &'a Path, + } + + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Entry { + dependency: KernelDependency, + path: PathBuf, + } + + pub fn serialize( + paths: &BTreeMap, + serializer: S, + ) -> Result + where + S: ser::Serializer, + { + serializer.collect_seq( + paths + .iter() + .map(|(dependency, path)| EntryRef { dependency, path }), + ) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: de::Deserializer<'de>, + { + let entries = Vec::::deserialize(deserializer)?; + let mut paths = BTreeMap::new(); + for entry in entries { + match paths.entry(entry.dependency) { + btree_map::Entry::Vacant(slot) => { + slot.insert(entry.path); + } + btree_map::Entry::Occupied(slot) => { + return Err(de::Error::custom(format!( + "duplicate dependency: {:?}", + slot.key() + ))); + } + } + } + Ok(paths) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::str::FromStr; + + use serde_json::json; + + use crate::config::{KernelDependency, KernelVersion}; + use crate::git::Oid; + + use super::{KernelLock, KernelLocks, KernelPaths, NixKernelLock, NixKernelLocks}; + + fn dependency(repo_id: &str, version: usize) -> KernelDependency { + KernelDependency { + repo_id: repo_id.to_string(), + version: KernelVersion::Version(version), + } + } + + fn oid(commit: &str) -> Oid { + Oid::from_str(commit).unwrap() + } + + fn lock(commit: &str) -> KernelLock { + KernelLock { + commit: oid(commit), + } + } + + fn sample_locks() -> KernelLocks { + KernelLocks::from_iter([ + ( + dependency("kernels-test/versions", 2), + lock("f609e51b856b3d874b0ae8445913e200f02c1735"), + ), + ( + dependency("kernels-community/relu", 1), + lock("d649efb56fb249ac8f7a57fa1866728ad0c60e52"), + ), + ]) + } + + #[test] + fn kernel_lock_serializes_to_expected_json() { + assert_eq!( + serde_json::to_value(lock("d649efb56fb249ac8f7a57fa1866728ad0c60e52")).unwrap(), + json!({"commit": "d649efb56fb249ac8f7a57fa1866728ad0c60e52"}) + ); + } + + #[test] + fn kernel_lock_unknown_field_is_rejected() { + let json = r#"{ + "commit": "d649efb56fb249ac8f7a57fa1866728ad0c60e52", + "frobnicate": true + }"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn kernel_locks_serializes_to_expected_json() { + assert_eq!( + serde_json::to_value(sample_locks()).unwrap(), + json!([ + { + "dependency": { + "repo-id": "kernels-community/relu", + "version": 1, + }, + "lock": {"commit": "d649efb56fb249ac8f7a57fa1866728ad0c60e52"}, + }, + { + "dependency": { + "repo-id": "kernels-test/versions", + "version": 2, + }, + "lock": {"commit": "f609e51b856b3d874b0ae8445913e200f02c1735"}, + }, + ]) + ); + } + + #[test] + fn kernel_locks_round_trips_through_json() { + let locks = sample_locks(); + let json = serde_json::to_string(&locks).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), locks); + } + + #[test] + fn kernel_locks_with_revision_dependency_round_trips() { + let locks = KernelLocks::from_iter([( + KernelDependency { + repo_id: "kernels-test/versions".to_string(), + version: KernelVersion::Revision("34fa".to_string()), + }, + lock("f609e51b856b3d874b0ae8445913e200f02c1735"), + )]); + + let json = serde_json::to_string(&locks).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), locks); + } + + #[test] + fn locks_order_is_independent_of_insertion_order() { + let forward = sample_locks(); + let reverse = forward + .locks + .iter() + .rev() + .map(|(dependency, lock)| (dependency.clone(), lock.clone())) + .collect::(); + + assert_eq!( + serde_json::to_string(&forward).unwrap(), + serde_json::to_string(&reverse).unwrap() + ); + } + + #[test] + fn kernel_locks_parses_from_json() { + let json = r#"[ + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "lock": {"commit": "f609e51b856b3d874b0ae8445913e200f02c1735"} + } + ]"#; + + let locks: KernelLocks = serde_json::from_str(json).unwrap(); + assert_eq!(locks.locks.len(), 1); + assert_eq!( + locks.locks.get(&dependency("kernels-test/versions", 2)), + Some(&lock("f609e51b856b3d874b0ae8445913e200f02c1735")) + ); + } + + #[test] + fn duplicate_lock_is_rejected() { + let json = r#"[ + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "lock": {"commit": "f609e51b856b3d874b0ae8445913e200f02c1735"} + }, + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "lock": {"commit": "1111111111111111111111111111111111111111"} + } + ]"#; + + let err = serde_json::from_str::(json).unwrap_err(); + assert!( + err.to_string().contains("duplicate dependency"), + "unexpected error: {err}" + ); + } + + #[test] + fn nix_kernel_locks_only_add_a_hash_to_each_lock() { + let locks = sample_locks(); + let nix_locks = locks + .locks + .iter() + .map(|(dependency, lock)| { + ( + dependency.clone(), + NixKernelLock { + commit: lock.commit.clone(), + hash: "sha256-1CM0MGEOCqqnYV989jcUANEbiB727JftjXTdQVUHPwk=".to_string(), + }, + ) + }) + .collect::(); + + let mut expected = serde_json::to_value(&locks).unwrap(); + for entry in expected.as_array_mut().unwrap() { + entry["lock"]["hash"] = json!("sha256-1CM0MGEOCqqnYV989jcUANEbiB727JftjXTdQVUHPwk="); + } + + assert_eq!(serde_json::to_value(&nix_locks).unwrap(), expected); + } + + #[test] + fn nix_kernel_locks_round_trip_through_json() { + let locks = NixKernelLocks::from_iter([( + dependency("kernels-community/relu", 1), + NixKernelLock { + commit: oid("d649efb56fb249ac8f7a57fa1866728ad0c60e52"), + hash: "sha256-1CM0MGEOCqqnYV989jcUANEbiB727JftjXTdQVUHPwk=".to_string(), + }, + )]); + + let json = serde_json::to_string(&locks).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + locks + ); + } + + #[test] + fn nix_kernel_lock_without_hash_is_rejected() { + let json = r#"[ + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "lock": {"commit": "f609e51b856b3d874b0ae8445913e200f02c1735"} + } + ]"#; + + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn lock_with_invalid_commit_is_rejected() { + let json = r#"[ + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "lock": {"commit": "f609e51"} + } + ]"#; + + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn locks_as_object_is_rejected() { + assert!(serde_json::from_str::("{}").is_err()); + } + + #[test] + fn lock_entry_without_lock_is_rejected() { + let json = r#"[ + {"dependency": {"repo-id": "kernels-test/versions", "version": 2}} + ]"#; + assert!(serde_json::from_str::(json).is_err()); + } + + fn sample_paths() -> KernelPaths { + KernelPaths::from_iter([ + ( + dependency("kernels-test/versions", 2), + PathBuf::from("/kernels/versions"), + ), + ( + dependency("kernels-community/relu", 1), + PathBuf::from("/kernels/relu"), + ), + ]) + } + + #[test] + fn kernel_paths_serialize_to_expected_json() { + assert_eq!( + serde_json::to_value(sample_paths()).unwrap(), + json!([ + { + "dependency": {"repo-id": "kernels-community/relu", "version": 1}, + "path": "/kernels/relu", + }, + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "path": "/kernels/versions", + }, + ]) + ); + } + + #[test] + fn kernel_paths_round_trip_through_json() { + let paths = sample_paths(); + let json = serde_json::to_string(&paths).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), paths); + } + + #[test] + fn duplicate_path_is_rejected() { + let json = r#"[ + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "path": "/kernels/versions" + }, + { + "dependency": {"repo-id": "kernels-test/versions", "version": 2}, + "path": "/kernels/versions-2" + } + ]"#; + + let err = serde_json::from_str::(json).unwrap_err(); + assert!( + err.to_string().contains("duplicate dependency"), + "unexpected error: {err}" + ); + } + + #[test] + fn path_entry_without_path_is_rejected() { + let json = r#"[ + {"dependency": {"repo-id": "kernels-test/versions", "version": 2}} + ]"#; + assert!(serde_json::from_str::(json).is_err()); + } +}