diff --git a/.github/actions/setup-test-environment/action.yml b/.github/actions/setup-test-environment/action.yml index ffe3390a..aa37434b 100644 --- a/.github/actions/setup-test-environment/action.yml +++ b/.github/actions/setup-test-environment/action.yml @@ -44,7 +44,7 @@ runs: - name: Setup Python tools shell: bash run: | - pip install "ruff >= 0.7.0, < 0.8.0" + pip install "ruff >= 0.9.0, < 0.10.0" pip install pytest-github-actions-annotate-failures pip install "twine >= 6.0.0, < 7.0.0" pip install build diff --git a/python/ommx-python-mip-adapter/pyproject.toml b/python/ommx-python-mip-adapter/pyproject.toml index 64933265..356144ea 100644 --- a/python/ommx-python-mip-adapter/pyproject.toml +++ b/python/ommx-python-mip-adapter/pyproject.toml @@ -37,7 +37,7 @@ dev = [ "numpy", "pyright", "pytest", - "ruff >= 0.7.0, < 0.10.0", + "ruff >= 0.9.0, < 0.10.0", "sphinx", "sphinx-autoapi", "sphinx_fontawesome", diff --git a/python/ommx/ommx/artifact.py b/python/ommx/ommx/artifact.py index 99a5e388..704aab3a 100644 --- a/python/ommx/ommx/artifact.py +++ b/python/ommx/ommx/artifact.py @@ -6,7 +6,6 @@ import numpy from dataclasses import dataclass from pathlib import Path -from dateutil import parser from abc import ABC, abstractmethod from ._ommx_rust import ( @@ -16,7 +15,7 @@ ArtifactArchiveBuilder as _ArtifactArchiveBuilder, ArtifactDirBuilder as _ArtifactDirBuilder, ) -from .v1 import Instance, Solution +from .v1 import Instance, Solution, ParametricInstance, SampleSet class ArtifactBase(ABC): @@ -248,32 +247,9 @@ def get_instance(self, descriptor: Descriptor) -> Instance: """ assert descriptor.media_type == "application/org.ommx.v1.instance" - blob = self.get_blob(descriptor) instance = Instance.from_bytes(blob) instance.annotations = descriptor.annotations - if "org.ommx.v1.instance.created" in instance.annotations: - instance.created = parser.isoparse( - instance.annotations["org.ommx.v1.instance.created"] - ) - if "org.ommx.v1.instance.title" in instance.annotations: - instance.title = instance.annotations["org.ommx.v1.instance.title"] - if "org.ommx.v1.instance.authors" in instance.annotations: - instance.authors = instance.annotations[ - "org.ommx.v1.instance.authors" - ].split(",") - if "org.ommx.v1.instance.license" in instance.annotations: - instance.license = instance.annotations["org.ommx.v1.instance.license"] - if "org.ommx.v1.instance.dataset" in instance.annotations: - instance.dataset = instance.annotations["org.ommx.v1.instance.dataset"] - if "org.ommx.v1.instance.variables" in instance.annotations: - instance.num_variables = int( - instance.annotations["org.ommx.v1.instance.variables"] - ) - if "org.ommx.v1.instance.constraints" in instance.annotations: - instance.num_constraints = int( - instance.annotations["org.ommx.v1.instance.constraints"] - ) return instance @property @@ -292,30 +268,59 @@ def solution(self) -> Solution: def get_solution(self, descriptor: Descriptor) -> Solution: assert descriptor.media_type == "application/org.ommx.v1.solution" - blob = self.get_blob(descriptor) solution = Solution.from_bytes(blob) solution.annotations = descriptor.annotations - if "org.ommx.v1.solution.instance" in descriptor.annotations: - solution.instance = descriptor.annotations["org.ommx.v1.solution.instance"] - if "org.ommx.v1.solution.solver" in descriptor.annotations: - solution.solver = json.loads( - descriptor.annotations["org.ommx.v1.solution.solver"] - ) - if "org.ommx.v1.solution.parameters" in descriptor.annotations: - solution.parameters = json.loads( - descriptor.annotations["org.ommx.v1.solution.parameters"] - ) - if "org.ommx.v1.solution.start" in descriptor.annotations: - solution.start = parser.isoparse( - descriptor.annotations["org.ommx.v1.solution.start"] - ) - if "org.ommx.v1.solution.end" in descriptor.annotations: - solution.end = parser.isoparse( - descriptor.annotations["org.ommx.v1.solution.end"] - ) return solution + @property + def parametric_instance(self) -> ParametricInstance: + """ + Take the first parametric instance layer in the artifact + + - If the artifact does not have a parametric instance layer, it raises an :py:exc:`ValueError`. + - For multiple parametric instance layers, use :py:meth:`Artifact.get_parametric_instance` instead. + """ + for desc in self.layers: + if desc.media_type == "application/org.ommx.v1.parametric-instance": + return self.get_parametric_instance(desc) + else: + raise ValueError("Parametric instance layer not found") + + def get_parametric_instance(self, descriptor: Descriptor) -> ParametricInstance: + """ + Get an parametric instance from the artifact + """ + assert descriptor.media_type == "application/org.ommx.v1.parametric-instance" + blob = self.get_blob(descriptor) + instance = ParametricInstance.from_bytes(blob) + instance.annotations = descriptor.annotations + return instance + + @property + def sample_set(self) -> SampleSet: + """ + Take the first sample set layer in the artifact + + - If the artifact does not have a sample set layer, it raises an :py:exc:`ValueError`. + - For multiple sample set layers, use :py:meth:`Artifact.get_sample_set` instead. + """ + for desc in self.layers: + if desc.media_type == "application/org.ommx.v1.sample-set": + return self.get_sample_set(desc) + else: + raise ValueError("Sample set layer not found") + + def get_sample_set(self, descriptor: Descriptor) -> SampleSet: + """ + Get a sample set from the artifact + """ + assert descriptor.media_type == "application/org.ommx.v1.sample-set" + blob = self.get_blob(descriptor) + sample_set = SampleSet.from_bytes(blob) + sample_set.annotations = descriptor.annotations + return sample_set + def get_ndarray(self, descriptor: Descriptor) -> numpy.ndarray: """ Get a numpy array from an artifact layer stored by :py:meth:`ArtifactBuilder.add_ndarray` @@ -600,24 +605,18 @@ def add_instance(self, instance: Instance) -> Descriptor: """ blob = instance.to_bytes() - annotations = instance.annotations.copy() - if instance.created: - annotations["org.ommx.v1.instance.created"] = instance.created.isoformat() - if instance.title: - annotations["org.ommx.v1.instance.title"] = instance.title - if instance.authors: - annotations["org.ommx.v1.instance.authors"] = ",".join(instance.authors) - if instance.license: - annotations["org.ommx.v1.instance.license"] = instance.license - if instance.dataset: - annotations["org.ommx.v1.instance.dataset"] = instance.dataset - if instance.num_variables: - annotations["org.ommx.v1.instance.variables"] = str(instance.num_variables) - if instance.num_constraints: - annotations["org.ommx.v1.instance.constraints"] = str( - instance.num_constraints - ) - return self.add_layer("application/org.ommx.v1.instance", blob, annotations) + return self.add_layer( + "application/org.ommx.v1.instance", blob, instance.annotations + ) + + def add_parametric_instance(self, instance: ParametricInstance) -> Descriptor: + """ + Add a parametric instance to the artifact with annotations + """ + blob = instance.to_bytes() + return self.add_layer( + "application/org.ommx.v1.parametric-instance", blob, instance.annotations + ) def add_solution(self, solution: Solution) -> Descriptor: """ @@ -652,20 +651,18 @@ def add_solution(self, solution: Solution) -> Descriptor: """ blob = solution.to_bytes() - annotations = solution.annotations.copy() - if solution.instance: - annotations["org.ommx.v1.solution.instance"] = solution.instance - if solution.solver: - annotations["org.ommx.v1.solution.solver"] = json.dumps(solution.solver) - if solution.parameters: - annotations["org.ommx.v1.solution.parameters"] = json.dumps( - solution.parameters - ) - if solution.start: - annotations["org.ommx.v1.solution.start"] = solution.start.isoformat() - if solution.end: - annotations["org.ommx.v1.solution.end"] = solution.end.isoformat() - return self.add_layer("application/org.ommx.v1.solution", blob, annotations) + return self.add_layer( + "application/org.ommx.v1.solution", blob, solution.annotations + ) + + def add_sample_set(self, sample_set: SampleSet) -> Descriptor: + """ + Add a sample set to the artifact with annotations + """ + blob = sample_set.to_bytes() + return self.add_layer( + "application/org.ommx.v1.sample-set", blob, sample_set.annotations + ) def add_ndarray( self, diff --git a/python/ommx/ommx/v1/__init__.py b/python/ommx/ommx/v1/__init__.py index 6aad093c..65e7f57a 100644 --- a/python/ommx/ommx/v1/__init__.py +++ b/python/ommx/ommx/v1/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations from typing import Optional, Iterable, overload, Mapping from typing_extensions import deprecated, TypeAlias, Union -from datetime import datetime from dataclasses import dataclass, field from pandas import DataFrame, NA, Series from abc import ABC, abstractmethod @@ -28,6 +27,14 @@ SampledValues as _SampledValues, SampledConstraint as _SampledConstraint, ) +from .annotation import ( + UserAnnotationBase, + str_annotation_property, + str_list_annotation_property, + datetime_annotation_property, + json_annotation_property, + int_annotation_property, +) from .. import _ommx_rust @@ -147,48 +154,6 @@ def removed_constraints(self) -> DataFrame: return df -class UserAnnotationBase(ABC): - @property - @abstractmethod - def _annotations(self) -> dict[str, str]: ... - - def add_user_annotation( - self, key: str, value: str, *, annotation_namespace: str = "org.ommx.user." - ): - if not annotation_namespace.endswith("."): - annotation_namespace += "." - self._annotations[annotation_namespace + key] = value - - def add_user_annotations( - self, - annotations: dict[str, str], - *, - annotation_namespace: str = "org.ommx.user.", - ): - for key, value in annotations.items(): - self.add_user_annotation( - key, value, annotation_namespace=annotation_namespace - ) - - def get_user_annotation( - self, key: str, *, annotation_namespace: str = "org.ommx.user." - ): - if not annotation_namespace.endswith("."): - annotation_namespace += "." - return self._annotations[annotation_namespace + key] - - def get_user_annotations( - self, *, annotation_namespace: str = "org.ommx.user." - ) -> dict[str, str]: - if not annotation_namespace.endswith("."): - annotation_namespace += "." - return { - key[len(annotation_namespace) :]: value - for key, value in self._annotations.items() - if key.startswith(annotation_namespace) - } - - @dataclass class Instance(InstanceBase, UserAnnotationBase): """ @@ -235,38 +200,25 @@ class Instance(InstanceBase, UserAnnotationBase): """The raw protobuf message.""" # Annotations - title: Optional[str] = None - """ - The title of the instance, stored as ``org.ommx.v1.instance.title`` annotation in OMMX artifact. - """ - created: Optional[datetime] = None - """ - The creation date of the instance, stored as ``org.ommx.v1.instance.created`` annotation in RFC3339 format in OMMX artifact. - """ - authors: list[str] = field(default_factory=list) - """ - Authors of this instance. This is stored as ``org.ommx.v1.instance.authors`` annotation in OMMX artifact. - """ - license: Optional[str] = None - """ - License of this instance in the SPDX license identifier. This is stored as ``org.ommx.v1.instance.license`` annotation in OMMX artifact. - """ - dataset: Optional[str] = None - """ - Dataset name which this instance belongs to, stored as ``org.ommx.v1.instance.dataset`` annotation in OMMX artifact. - """ - num_variables: Optional[int] = None - """ - Number of variables in this instance, stored as ``org.ommx.v1.instance.variables`` annotation in OMMX artifact. - """ - num_constraints: Optional[int] = None - """ - Number of constraints in this instance, stored as ``org.ommx.v1.instance.constraints`` annotation in OMMX artifact. - """ annotations: dict[str, str] = field(default_factory=dict) """ Arbitrary annotations stored in OMMX artifact. Use :py:attr:`title` or other specific attributes if possible. """ + annotation_namespace = "org.ommx.v1.instance" + title = str_annotation_property("title") + "The title of the instance, stored as ``org.ommx.v1.instance.title`` annotation in OMMX artifact." + license = str_annotation_property("license") + "License of this instance in the SPDX license identifier. This is stored as ``org.ommx.v1.instance.license`` annotation in OMMX artifact." + dataset = str_annotation_property("dataset") + "Dataset name which this instance belongs to, stored as ``org.ommx.v1.instance.dataset`` annotation in OMMX artifact." + authors = str_list_annotation_property("authors") + "Authors of this instance, stored as ``org.ommx.v1.instance.authors`` annotation in OMMX artifact." + num_variables = int_annotation_property("variables") + "Number of variables in this instance, stored as ``org.ommx.v1.instance.variables`` annotation in OMMX artifact." + num_constraints = int_annotation_property("constraints") + "Number of constraints in this instance, stored as ``org.ommx.v1.instance.constraints`` annotation in OMMX artifact." + created = datetime_annotation_property("created") + "The creation date of the instance, stored as ``org.ommx.v1.instance.created`` annotation in RFC3339 format in OMMX artifact." @property def _annotations(self) -> dict[str, str]: @@ -780,13 +732,34 @@ def restore_constraint(self, constraint_id: int): @dataclass -class ParametricInstance(InstanceBase): +class ParametricInstance(InstanceBase, UserAnnotationBase): """ Idiomatic wrapper of ``ommx.v1.ParametricInstance`` protobuf message. """ raw: _ParametricInstance + annotations: dict[str, str] = field(default_factory=dict) + annotation_namespace = "org.ommx.v1.parametric-instance" + title = str_annotation_property("title") + "The title of the instance, stored as ``org.ommx.v1.parametric-instance.title`` annotation in OMMX artifact." + license = str_annotation_property("license") + "License of this instance in the SPDX license identifier. This is stored as ``org.ommx.v1.parametric-instance.license`` annotation in OMMX artifact." + dataset = str_annotation_property("dataset") + "Dataset name which this instance belongs to, stored as ``org.ommx.v1.parametric-instance.dataset`` annotation in OMMX artifact." + authors = str_list_annotation_property("authors") + "Authors of this instance, stored as ``org.ommx.v1.parametric-instance.authors`` annotation in OMMX artifact." + num_variables = int_annotation_property("variables") + "Number of variables in this instance, stored as ``org.ommx.v1.parametric-instance.variables`` annotation in OMMX artifact." + num_constraints = int_annotation_property("constraints") + "Number of constraints in this instance, stored as ``org.ommx.v1.parametric-instance.constraints`` annotation in OMMX artifact." + created = datetime_annotation_property("created") + "The creation date of the instance, stored as ``org.ommx.v1.parametric-instance.created`` annotation in RFC3339 format in OMMX artifact." + + @property + def _annotations(self) -> dict[str, str]: + return self.annotations + @staticmethod def from_bytes(data: bytes) -> ParametricInstance: raw = _ParametricInstance() @@ -982,37 +955,23 @@ class Solution(UserAnnotationBase): raw: _Solution """The raw protobuf message.""" - instance: Optional[str] = None + annotation_namespace = "org.ommx.v1.solution" + instance = str_annotation_property("instance") """ The digest of the instance layer, stored as ``org.ommx.v1.solution.instance`` annotation in OMMX artifact. This ``Solution`` is the solution of the mathematical programming problem described by the instance. """ - - solver: Optional[object] = None - """ - The solver which generated this solution, stored as ``org.ommx.v1.solution.solver`` annotation as a JSON in OMMX artifact. - """ - - parameters: Optional[object] = None - """ - The parameters used in the optimization, stored as ``org.ommx.v1.solution.parameters`` annotation as a JSON in OMMX artifact. - """ - - start: Optional[datetime] = None - """ - When the optimization started, stored as ``org.ommx.v1.solution.start`` annotation in RFC3339 format in OMMX artifact. - """ - - end: Optional[datetime] = None - """ - When the optimization ended, stored as ``org.ommx.v1.solution.end`` annotation in RFC3339 format in OMMX artifact. - """ - + solver = json_annotation_property("solver") + """The solver which generated this solution, stored as ``org.ommx.v1.solution.solver`` annotation as a JSON in OMMX artifact.""" + parameters = json_annotation_property("parameters") + """The parameters used in the optimization, stored as ``org.ommx.v1.solution.parameters`` annotation as a JSON in OMMX artifact.""" + start = datetime_annotation_property("start") + """When the optimization started, stored as ``org.ommx.v1.solution.start`` annotation in RFC3339 format in OMMX artifact.""" + end = datetime_annotation_property("end") + """When the optimization ended, stored as ``org.ommx.v1.solution.end`` annotation in RFC3339 format in OMMX artifact.""" annotations: dict[str, str] = field(default_factory=dict) - """ - Arbitrary annotations stored in OMMX artifact. Use :py:attr:`parameters` or other specific attributes if possible. - """ + """Arbitrary annotations stored in OMMX artifact. Use :py:attr:`parameters` or other specific attributes if possible.""" @property def _annotations(self) -> dict[str, str]: @@ -2513,7 +2472,7 @@ def _as_pandas_entry(self) -> dict: @dataclass -class SampleSet: +class SampleSet(UserAnnotationBase): r""" The output of sampling-based optimization algorithms, e.g. simulated annealing (SA). @@ -2603,6 +2562,24 @@ class SampleSet: raw: _SampleSet + annotation_namespace = "org.ommx.v1.sample-set" + instance = str_annotation_property("instance") + """The digest of the instance layer, stored as ``org.ommx.v1.sample-set.instance`` annotation in OMMX artifact.""" + solver = json_annotation_property("solver") + """The solver which generated this sample set, stored as ``org.ommx.v1.sample-set.solver`` annotation as a JSON in OMMX artifact.""" + parameters = json_annotation_property("parameters") + """The parameters used in the optimization, stored as ``org.ommx.v1.sample-set.parameters`` annotation as a JSON in OMMX artifact.""" + start = datetime_annotation_property("start") + """When the optimization started, stored as ``org.ommx.v1.sample-set.start`` annotation in RFC3339 format in OMMX artifact.""" + end = datetime_annotation_property("end") + """When the optimization ended, stored as ``org.ommx.v1.sample-set.end`` annotation in RFC3339 format in OMMX artifact.""" + annotations: dict[str, str] = field(default_factory=dict) + """Arbitrary annotations stored in OMMX artifact. Use :py:attr:`parameters` or other specific attributes if possible.""" + + @property + def _annotations(self) -> dict[str, str]: + return self.annotations + @staticmethod def from_bytes(data: bytes) -> SampleSet: new = SampleSet(_SampleSet()) diff --git a/python/ommx/ommx/v1/annotation.py b/python/ommx/ommx/v1/annotation.py new file mode 100644 index 00000000..9b25d429 --- /dev/null +++ b/python/ommx/ommx/v1/annotation.py @@ -0,0 +1,113 @@ +from __future__ import annotations +from datetime import datetime +from dateutil import parser +from abc import ABC, abstractmethod +import json + + +class UserAnnotationBase(ABC): + @property + @abstractmethod + def _annotations(self) -> dict[str, str]: ... + + def add_user_annotation( + self, key: str, value: str, *, annotation_namespace: str = "org.ommx.user." + ): + if not annotation_namespace.endswith("."): + annotation_namespace += "." + self._annotations[annotation_namespace + key] = value + + def add_user_annotations( + self, + annotations: dict[str, str], + *, + annotation_namespace: str = "org.ommx.user.", + ): + for key, value in annotations.items(): + self.add_user_annotation( + key, value, annotation_namespace=annotation_namespace + ) + + def get_user_annotation( + self, key: str, *, annotation_namespace: str = "org.ommx.user." + ): + if not annotation_namespace.endswith("."): + annotation_namespace += "." + return self._annotations[annotation_namespace + key] + + def get_user_annotations( + self, *, annotation_namespace: str = "org.ommx.user." + ) -> dict[str, str]: + if not annotation_namespace.endswith("."): + annotation_namespace += "." + return { + key[len(annotation_namespace) :]: value + for key, value in self._annotations.items() + if key.startswith(annotation_namespace) + } + + +def str_annotation_property(name: str): + def getter(self): + return self._annotations.get(f"{self.annotation_namespace}.{name}") + + def setter(self, value: str): + self._annotations[f"{self.annotation_namespace}.{name}"] = value + + return property(getter, setter) + + +def str_list_annotation_property(name: str): + def getter(self): + value = self._annotations.get(f"{self.annotation_namespace}.{name}") + if value: + return value.split(",") + else: + return [] + + def setter(self, value: list[str]): + self._annotations[f"{self.annotation_namespace}.{name}"] = ",".join(value) + + return property(getter, setter) + + +def int_annotation_property(name: str): + def getter(self): + value = self._annotations.get(f"{self.annotation_namespace}.{name}") + if value: + return int(value) + else: + return None + + def setter(self, value: int): + self._annotations[f"{self.annotation_namespace}.{name}"] = str(value) + + return property(getter, setter) + + +def datetime_annotation_property(name: str): + def getter(self): + value = self._annotations.get(f"{self.annotation_namespace}.{name}") + if value: + return parser.isoparse(value) + else: + return None + + def setter(self, value: datetime): + self._annotations[f"{self.annotation_namespace}.{name}"] = value.isoformat() + + return property(getter, setter) + + +def json_annotation_property(name: str): + def getter(self): + value = self._annotations.get(f"{self.annotation_namespace}.{name}") + if value: + return json.loads(value) + else: + return None + + def setter(self, value: dict): + self._annotations[f"{self.annotation_namespace}.{name}"] = json.dumps(value) + + return property(getter, setter) diff --git a/rust/ommx/src/artifact.rs b/rust/ommx/src/artifact.rs index ca82f155..22f9f62e 100644 --- a/rust/ommx/src/artifact.rs +++ b/rust/ommx/src/artifact.rs @@ -227,41 +227,71 @@ impl Artifact { .collect()) } - pub fn get_solution(&mut self, digest: &Digest) -> Result<(v1::State, SolutionAnnotations)> { + pub fn get_layer(&mut self, digest: &Digest) -> Result<(Descriptor, Vec)> { for (desc, blob) in self.0.get_layers()? { - if desc.media_type() != &media_types::v1_solution() - || desc.digest() != &digest.to_string() - { - continue; + if desc.digest() == &digest.to_string() { + return Ok((desc, blob)); } - let solution = v1::State::decode(blob.as_slice())?; - let annotations = if let Some(annotations) = desc.annotations() { - annotations.clone().into() - } else { - SolutionAnnotations::default() - }; - return Ok((solution, annotations)); } - // TODO: Seek from other artifacts - bail!("Solution of digest {} not found", digest) + bail!("Layer of digest {} not found", digest) + } + + pub fn get_solution(&mut self, digest: &Digest) -> Result<(v1::State, SolutionAnnotations)> { + let (desc, blob) = self.get_layer(digest)?; + ensure!( + desc.media_type() == &media_types::v1_solution(), + "Layer {digest} is not an ommx.v1.Solution: {}", + desc.media_type() + ); + Ok(( + v1::State::decode(blob.as_slice())?, + SolutionAnnotations::from_descriptor(&desc), + )) + } + + pub fn get_sample_set( + &mut self, + digest: &Digest, + ) -> Result<(v1::SampleSet, SampleSetAnnotations)> { + let (desc, blob) = self.get_layer(digest)?; + ensure!( + desc.media_type() == &media_types::v1_sample_set(), + "Layer {digest} is not an ommx.v1.SampleSet: {}", + desc.media_type() + ); + Ok(( + v1::SampleSet::decode(blob.as_slice())?, + SampleSetAnnotations::from_descriptor(&desc), + )) } pub fn get_instance(&mut self, digest: &Digest) -> Result<(v1::Instance, InstanceAnnotations)> { - for (desc, blob) in self.0.get_layers()? { - if desc.media_type() != &media_types::v1_instance() - || desc.digest() != &digest.to_string() - { - continue; - } - let instance = v1::Instance::decode(blob.as_slice())?; - let annotations = if let Some(annotations) = desc.annotations() { - annotations.clone().into() - } else { - InstanceAnnotations::default() - }; - return Ok((instance, annotations)); - } - bail!("Instance of digest {} not found", digest) + let (desc, blob) = self.get_layer(digest)?; + ensure!( + desc.media_type() == &media_types::v1_instance(), + "Layer {digest} is not an ommx.v1.Instance: {}", + desc.media_type() + ); + Ok(( + v1::Instance::decode(blob.as_slice())?, + InstanceAnnotations::from_descriptor(&desc), + )) + } + + pub fn get_parametric_instance( + &mut self, + digest: &Digest, + ) -> Result<(v1::ParametricInstance, ParametricInstanceAnnotations)> { + let (desc, blob) = self.get_layer(digest)?; + ensure!( + desc.media_type() == &media_types::v1_parametric_instance(), + "Layer {digest} is not an ommx.v1.ParametricInstance: {}", + desc.media_type() + ); + Ok(( + v1::ParametricInstance::decode(blob.as_slice())?, + ParametricInstanceAnnotations::from_descriptor(&desc), + )) } pub fn get_solutions(&mut self) -> Result> { diff --git a/rust/ommx/src/artifact/annotations.rs b/rust/ommx/src/artifact/annotations.rs index 7657b865..e34094cf 100644 --- a/rust/ommx/src/artifact/annotations.rs +++ b/rust/ommx/src/artifact/annotations.rs @@ -111,6 +111,116 @@ impl InstanceAnnotations { } } +/// Annotations for [`application/org.ommx.v1.parametric-instance`][crate::artifact::media_types::v1_parametric_instance] +#[derive(Debug, Default, Clone, PartialEq, From, Deref, Into, Serialize, Deserialize)] +pub struct ParametricInstanceAnnotations(HashMap); + +impl ParametricInstanceAnnotations { + pub fn into_inner(self) -> HashMap { + self.0 + } + + fn get(&self, key: &str) -> Result<&String> { + self.0.get(key).context(format!( + "Annotation does not have the entry with the key `{}`", + key + )) + } + + pub fn from_descriptor(desc: &Descriptor) -> Self { + Self(desc.annotations().as_ref().cloned().unwrap_or_default()) + } + + pub fn set_title(&mut self, title: String) { + self.0 + .insert("org.ommx.v1.parametric-instance.title".to_string(), title); + } + + pub fn title(&self) -> Result<&String> { + self.get("org.ommx.v1.parametric-instance.title") + } + + pub fn set_created(&mut self, created: DateTime) { + self.0.insert( + "org.ommx.v1.parametric-instance.created".to_string(), + created.to_rfc3339(), + ); + } + + pub fn set_created_now(&mut self) { + self.set_created(Local::now()); + } + + pub fn created(&self) -> Result> { + let created = self.get("org.ommx.v1.parametric-instance.created")?; + Ok(DateTime::parse_from_rfc3339(created)?.with_timezone(&Local)) + } + + pub fn set_authors(&mut self, authors: Vec) { + self.0.insert( + "org.ommx.v1.parametric-instance.authors".to_string(), + authors.join(","), + ); + } + + pub fn authors(&self) -> Result> { + let authors = self.get("org.ommx.v1.parametric-instance.authors")?; + Ok(authors.split(',')) + } + + pub fn set_license(&mut self, license: String) { + self.0.insert( + "org.ommx.v1.parametric-instance.license".to_string(), + license, + ); + } + + pub fn license(&self) -> Result<&String> { + self.get("org.ommx.v1.parametric-instance.license") + } + + pub fn set_dataset(&mut self, dataset: String) { + self.0.insert( + "org.ommx.v1.parametric-instance.dataset".to_string(), + dataset, + ); + } + + pub fn dataset(&self) -> Result<&String> { + self.get("org.ommx.v1.parametric-instance.dataset") + } + + pub fn set_variables(&mut self, variables: usize) { + self.0.insert( + "org.ommx.v1.parametric-instance.variables".to_string(), + variables.to_string(), + ); + } + + pub fn variables(&self) -> Result { + let variables = self.get("org.ommx.v1.parametric-instance.variables")?; + Ok(variables.parse()?) + } + + pub fn set_constraints(&mut self, constraints: usize) { + self.0.insert( + "org.ommx.v1.parametric-instance.constraints".to_string(), + constraints.to_string(), + ); + } + + pub fn constraints(&self) -> Result { + let constraints = self.get("org.ommx.v1.parametric-instance.constraints")?; + Ok(constraints.parse()?) + } + + /// Set other annotations. The key may not start with `org.ommx.v1.`, but must a valid reverse domain name. + pub fn set_other(&mut self, key: String, value: String) { + // TODO check key + self.0.insert(key, value); + } +} + /// Annotations for [`application/org.ommx.v1.solution`][crate::artifact::media_types::v1_solution] #[derive(Debug, Default, Clone, PartialEq, From, Deref, Into, Serialize, Deserialize)] pub struct SolutionAnnotations(HashMap); @@ -204,3 +314,97 @@ impl SolutionAnnotations { self.0.insert(key, value); } } + +#[derive(Debug, Default, Clone, PartialEq, From, Deref, Into, Serialize, Deserialize)] +pub struct SampleSetAnnotations(HashMap); + +impl SampleSetAnnotations { + pub fn into_inner(self) -> HashMap { + self.0 + } + + pub fn from_descriptor(desc: &Descriptor) -> Self { + Self(desc.annotations().as_ref().cloned().unwrap_or_default()) + } + + pub fn set_start(&mut self, start: DateTime) { + self.0.insert( + "org.ommx.v1.sample-set.start".to_string(), + start.to_rfc3339(), + ); + } + + pub fn start(&self) -> Result> { + let start = self.0.get("org.ommx.v1.sample-set.start").context( + "Annotation does not have the entry with the key `org.ommx.v1.sample-set.start`", + )?; + Ok(DateTime::parse_from_rfc3339(start)?.with_timezone(&Local)) + } + + pub fn set_end(&mut self, end: DateTime) { + self.0 + .insert("org.ommx.v1.sample-set.end".to_string(), end.to_rfc3339()); + } + + pub fn end(&self) -> Result> { + let end = self.0.get("org.ommx.v1.sample-set.end").context( + "Annotation does not have the entry with the key `org.ommx.v1.sample-set.end`", + )?; + Ok(DateTime::parse_from_rfc3339(end)?.with_timezone(&Local)) + } + + /// Set `org.ommx.v1.sample-set.instance` + pub fn set_instance(&mut self, digest: Digest) { + self.0.insert( + "org.ommx.v1.sample-set.instance".to_string(), + digest.to_string(), + ); + } + + /// Get `org.ommx.v1.sample-set.instance` + pub fn instance(&self) -> Result { + let digest = self.0.get("org.ommx.v1.sample-set.instance").context( + "Annotation does not have the entry with the key `org.ommx.v1.sample-set.instance`", + )?; + Digest::new(digest) + } + + /// Set `org.ommx.v1.sample-set.solver` + pub fn set_solver(&mut self, digest: Digest) { + self.0.insert( + "org.ommx.v1.sample-set.solver".to_string(), + digest.to_string(), + ); + } + + /// Get `org.ommx.v1.sample-set.solver` + pub fn solver(&self) -> Result { + let digest = self.0.get("org.ommx.v1.sample-set.solver").context( + "Annotation does not have the entry with the key `org.ommx.v1.sample-set.solver`", + )?; + Digest::new(digest) + } + + /// Set `org.ommx.v1.sample-set.parameters` + pub fn set_parameters(&mut self, parameters: impl Serialize) -> Result<()> { + self.0.insert( + "org.ommx.v1.sample-set.parameters".to_string(), + serde_json::to_string(¶meters)?, + ); + Ok(()) + } + + /// Get `org.ommx.v1.sample-set.parameters` + pub fn parameters<'s: 'de, 'de, P: Deserialize<'de>>(&'s self) -> Result

{ + Ok(serde_json::from_str( + self.0.get("org.ommx.v1.sample-set.parameters").context( + "Annotation does not have the entry with the key `org.ommx.v1.sample-set.parameters`", + )?, + )?) + } + + pub fn set_other(&mut self, key: String, value: String) { + // TODO check key + self.0.insert(key, value); + } +} diff --git a/rust/ommx/src/artifact/builder.rs b/rust/ommx/src/artifact/builder.rs index a9fbf1ec..95be8e7a 100644 --- a/rust/ommx/src/artifact/builder.rs +++ b/rust/ommx/src/artifact/builder.rs @@ -18,6 +18,8 @@ use std::{ use url::Url; use uuid::Uuid; +use super::{ParametricInstanceAnnotations, SampleSetAnnotations}; + /// Build [Artifact] pub struct Builder(OciArtifactBuilder); @@ -106,6 +108,31 @@ impl Builder { Ok(()) } + pub fn add_parametric_instance( + &mut self, + instance: v1::ParametricInstance, + annotations: ParametricInstanceAnnotations, + ) -> Result<()> { + let blob = instance.encode_to_vec(); + self.0.add_layer( + media_types::v1_parametric_instance(), + &blob, + annotations.into(), + )?; + Ok(()) + } + + pub fn add_sample_set( + &mut self, + sample_set: v1::SampleSet, + annotations: SampleSetAnnotations, + ) -> Result<()> { + let blob = sample_set.encode_to_vec(); + self.0 + .add_layer(media_types::v1_sample_set(), &blob, annotations.into())?; + Ok(()) + } + pub fn add_config(&mut self, config: Config) -> Result<()> { let blob = serde_json::to_string_pretty(&config)?; self.0 diff --git a/rust/ommx/src/artifact/media_types.rs b/rust/ommx/src/artifact/media_types.rs index fef8bf05..0d6a6f91 100644 --- a/rust/ommx/src/artifact/media_types.rs +++ b/rust/ommx/src/artifact/media_types.rs @@ -15,7 +15,17 @@ pub fn v1_instance() -> MediaType { MediaType::Other("application/org.ommx.v1.instance".to_string()) } +/// Media type of the layer storing [crate::v1::ParametricInstance] with [crate::artifact::InstanceAnnotations], `application/org.ommx.v1.parametric-instance` +pub fn v1_parametric_instance() -> MediaType { + MediaType::Other("application/org.ommx.v1.parametric-instance".to_string()) +} + /// Media type of the layer storing [crate::v1::Solution] with [crate::artifact::SolutionAnnotations], `application/org.ommx.v1.solution` pub fn v1_solution() -> MediaType { MediaType::Other("application/org.ommx.v1.solution".to_string()) } + +/// Media type of the layer storing [crate::v1::SampleSet] with [crate::artifact::SolutionAnnotations], `application/org.ommx.v1.sample-set` +pub fn v1_sample_set() -> MediaType { + MediaType::Other("application/org.ommx.v1.sample-set".to_string()) +}