diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index c4e4e5c6..fd45060b 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -64,6 +64,7 @@ from .dependency import Dependable from .issue import IssueType from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper +from .model_card import ModelCard from .release_note import ReleaseNotes @@ -1003,6 +1004,7 @@ def __init__( external_references: Optional[Iterable[ExternalReference]] = None, properties: Optional[Iterable[Property]] = None, release_notes: Optional[ReleaseNotes] = None, + model_card: Optional[ModelCard] = None, cpe: Optional[str] = None, swid: Optional[Swid] = None, pedigree: Optional[Pedigree] = None, @@ -1043,6 +1045,7 @@ def __init__( self.components = components or [] self.evidence = evidence self.release_notes = release_notes + self.model_card = model_card self.crypto_properties = crypto_properties self.tags = tags or [] # spec-deprecated properties below @@ -1602,6 +1605,26 @@ def release_notes(self) -> Optional[ReleaseNotes]: def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None: self._release_notes = release_notes + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(26) + @serializable.json_name('modelCard') + @serializable.xml_name('modelCard') + def model_card(self) -> Optional[ModelCard]: + """ + Specifies the model card for components of type `machine-learning-model`. + + Returns: + `ModelCard` or `None` + """ + return self._model_card + + @model_card.setter + def model_card(self, model_card: Optional[ModelCard]) -> None: + self._model_card = model_card + # @property # ... # @serializable.view(SchemaVersion1Dot5) @@ -1694,7 +1717,7 @@ def __comparable_tuple(self) -> _ComparableTuple: _ComparableTuple(self.external_references), _ComparableTuple(self.properties), _ComparableTuple(self.components), self.evidence, self.release_notes, self.modified, _ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer, - self.crypto_properties, _ComparableTuple(self.tags), + self.crypto_properties, _ComparableTuple(self.tags), self.model_card, )) def __eq__(self, other: object) -> bool: diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py new file mode 100644 index 00000000..1a77e9cc --- /dev/null +++ b/cyclonedx/model/model_card.py @@ -0,0 +1,1643 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +""" +This set of classes represents the model card types in the CycloneDX standard. + +.. note:: + Introduced in CycloneDX v1.5. Environmental considerations were added in v1.6. + +.. note:: + See the CycloneDX Schema for model cards:\n + - XML: https://cyclonedx.org/docs/1.7/xml/#type_modelCardType\n + - JSON: https://cyclonedx.org/docs/1.7/json/#components_items_modelCard +""" + +from collections.abc import Iterable +from enum import Enum +from typing import Any, Optional, Union + +import py_serializable as serializable +from sortedcontainers import SortedSet + +from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6, SchemaVersion1Dot7 +from . import AttachedText, ExternalReference, Property +from .bom_ref import BomRef +from .contact import OrganizationalEntity + + +@serializable.serializable_enum +class MachineLearningApproach(str, Enum): + """Enumeration for `machineLearningApproachType`. + + Values are stable across 1.5–1.7. + """ + SUPERVISED = 'supervised' + UNSUPERVISED = 'unsupervised' + REINFORCEMENT_LEARNING = 'reinforcement-learning' + SEMI_SUPERVISED = 'semi-supervised' + SELF_SUPERVISED = 'self-supervised' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class Approach: + """Container for the `approach` element within `modelParameters`.""" + + def __init__(self, *, type: Optional[MachineLearningApproach] = None) -> None: + self.type = type + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + def type(self) -> Optional[MachineLearningApproach]: + return self._type + + @type.setter + def type(self, type: Optional[MachineLearningApproach]) -> None: + self._type = type + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.type,)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Approach): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Approach): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, Approach): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, Approach): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class InputOutputMLParameters: + """Definition for items under `modelParameters.inputs[]` and `outputs[]`.""" + + def __init__(self, *, format: str) -> None: + self.format = format + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def format(self) -> str: + return self._format + + @format.setter + def format(self, format: str) -> None: + self._format = format + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.format,)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, InputOutputMLParameters): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, InputOutputMLParameters): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, InputOutputMLParameters): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, InputOutputMLParameters): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class ModelParameters: + """`modelParameters` block within `modelCard`.""" + + def __init__( + self, *, + approach: Optional[Approach] = None, + task: Optional[str] = None, + architecture_family: Optional[str] = None, + model_architecture: Optional[str] = None, + datasets: Optional[Iterable[Any]] = None, # Unsupported placeholder until #913 lands. + inputs: Optional[Iterable[InputOutputMLParameters]] = None, + outputs: Optional[Iterable[InputOutputMLParameters]] = None, + ) -> None: + self.approach = approach + self.task = task + self.architecture_family = architecture_family + self.model_architecture = model_architecture + # datasets: The CycloneDX spec allows inline componentData or ref entries. + # This library has not yet implemented component.data (#913). To avoid emitting + # invalid or partial structures, any attempt to populate datasets is rejected. + if datasets is not None: + datasets_list = list(datasets) + if len(datasets_list) > 0: + raise NotImplementedError( + 'modelParameters.datasets is not yet supported. Tracked by issue #913.' + ) + self._datasets: 'SortedSet[Any]' = SortedSet() # always empty until implemented + self.inputs = inputs or [] + self.outputs = outputs or [] + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + def approach(self) -> Optional[Approach]: + return self._approach + + @approach.setter + def approach(self, approach: Optional[Approach]) -> None: + self._approach = approach + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.json_name('task') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_name('task') + def task(self) -> Optional[str]: + return self._task + + @task.setter + def task(self, task: Optional[str]) -> None: + self._task = task + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + @serializable.json_name('architectureFamily') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_name('architectureFamily') + def architecture_family(self) -> Optional[str]: + return self._architecture_family + + @architecture_family.setter + def architecture_family(self, architecture_family: Optional[str]) -> None: + self._architecture_family = architecture_family + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + @serializable.json_name('modelArchitecture') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_name('modelArchitecture') + def model_architecture(self) -> Optional[str]: + return self._model_architecture + + @model_architecture.setter + def model_architecture(self, model_architecture: Optional[str]) -> None: + self._model_architecture = model_architecture + + # datasets intentionally omitted from serialization until #913 implemented. + # A future implementation will add a concrete union type and proper annotations. + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(6) + @serializable.json_name('inputs') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'input') + @serializable.xml_name('inputs') + def inputs(self) -> 'SortedSet[InputOutputMLParameters]': + return self._inputs + + @inputs.setter + def inputs(self, inputs: Iterable[InputOutputMLParameters]) -> None: + self._inputs = SortedSet(inputs) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(7) + @serializable.json_name('outputs') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'output') + @serializable.xml_name('outputs') + def outputs(self) -> 'SortedSet[InputOutputMLParameters]': + return self._outputs + + @outputs.setter + def outputs(self, outputs: Iterable[InputOutputMLParameters]) -> None: + self._outputs = SortedSet(outputs) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.approach, + self.task, + self.architecture_family, + self.model_architecture, + _ComparableTuple(self._datasets), + _ComparableTuple(self.inputs), + _ComparableTuple(self.outputs), + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, ModelParameters): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, ModelParameters): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, ModelParameters): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, ModelParameters): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class ConfidenceInterval: + """Confidence interval with lower/upper bounds.""" + + def __init__(self, *, lower_bound: Optional[str] = None, upper_bound: Optional[str] = None) -> None: + self.lower_bound = lower_bound + self.upper_bound = upper_bound + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.json_name('lowerBound') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_name('lowerBound') + def lower_bound(self) -> Optional[str]: + return self._lower_bound + + @lower_bound.setter + def lower_bound(self, lower_bound: Optional[str]) -> None: + self._lower_bound = lower_bound + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.json_name('upperBound') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_name('upperBound') + def upper_bound(self) -> Optional[str]: + return self._upper_bound + + @upper_bound.setter + def upper_bound(self, upper_bound: Optional[str]) -> None: + self._upper_bound = upper_bound + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.lower_bound, self.upper_bound)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, ConfidenceInterval): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, ConfidenceInterval): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, ConfidenceInterval): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, ConfidenceInterval): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class PerformanceMetric: + """A single performance metric entry.""" + + def __init__( + self, *, + type: Optional[str] = None, + value: Optional[str] = None, + slice: Optional[str] = None, + confidence_interval: Optional[ConfidenceInterval] = None, + ) -> None: + self.type = type + self.value = value + self.slice = slice + self.confidence_interval = confidence_interval + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def type(self) -> Optional[str]: + return self._type + + @type.setter + def type(self, type: Optional[str]) -> None: + self._type = type + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def value(self) -> Optional[str]: + return self._value + + @value.setter + def value(self, value: Optional[str]) -> None: + self._value = value + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + @serializable.json_name('slice') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_name('slice') + def slice(self) -> Optional[str]: + return self._slice + + @slice.setter + def slice(self, slice: Optional[str]) -> None: + self._slice = slice + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + @serializable.json_name('confidenceInterval') + @serializable.xml_name('confidenceInterval') + def confidence_interval(self) -> Optional[ConfidenceInterval]: + return self._confidence_interval + + @confidence_interval.setter + def confidence_interval(self, confidence_interval: Optional[ConfidenceInterval]) -> None: + self._confidence_interval = confidence_interval + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.type, self.value, self.slice, self.confidence_interval)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, PerformanceMetric): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, PerformanceMetric): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, PerformanceMetric): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, PerformanceMetric): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class Graphic: + """Graphic entry with optional name and image (AttachedText).""" + + def __init__(self, *, name: Optional[str] = None, image: Optional[AttachedText] = None) -> None: + self.name = name + self.image = image + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def name(self) -> Optional[str]: + return self._name + + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + def image(self) -> Optional[AttachedText]: + return self._image + + @image.setter + def image(self, image: Optional[AttachedText]) -> None: + self._image = image + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.name, self.image)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Graphic): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Graphic): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, Graphic): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, Graphic): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class GraphicsCollection: + """A collection of graphics with optional description.""" + + def __init__(self, *, description: Optional[str] = None, collection: Optional[Iterable[Graphic]] = None) -> None: + self.description = description + self.collection = collection or [] + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def description(self) -> Optional[str]: + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.json_name('collection') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'graphic') + @serializable.xml_name('collection') + def collection(self) -> 'SortedSet[Graphic]': + return self._collection + + @collection.setter + def collection(self, collection: Iterable[Graphic]) -> None: + self._collection = SortedSet(collection) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.description, _ComparableTuple(self.collection))) + + def __eq__(self, other: object) -> bool: + if isinstance(other, GraphicsCollection): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, GraphicsCollection): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, GraphicsCollection): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, GraphicsCollection): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class QuantitativeAnalysis: + """`quantitativeAnalysis` block within `modelCard`.""" + + def __init__( + self, *, + performance_metrics: Optional[Iterable[PerformanceMetric]] = None, + graphics: Optional[GraphicsCollection] = None, + ) -> None: + self.performance_metrics = performance_metrics or [] + self.graphics = graphics + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.json_name('performanceMetrics') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'performanceMetric') + @serializable.xml_name('performanceMetrics') + def performance_metrics(self) -> 'SortedSet[PerformanceMetric]': + return self._performance_metrics + + @performance_metrics.setter + def performance_metrics(self, performance_metrics: Iterable[PerformanceMetric]) -> None: + self._performance_metrics = SortedSet(performance_metrics) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + def graphics(self) -> Optional[GraphicsCollection]: + return self._graphics + + @graphics.setter + def graphics(self, graphics: Optional[GraphicsCollection]) -> None: + self._graphics = graphics + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((_ComparableTuple(self.performance_metrics), self.graphics)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, QuantitativeAnalysis): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, QuantitativeAnalysis): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, QuantitativeAnalysis): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, QuantitativeAnalysis): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +# Considerations and nested structures + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class EthicalConsideration: + """Entry in `ethicalConsiderations` with name and mitigation strategy.""" + + def __init__(self, *, name: Optional[str] = None, mitigation_strategy: Optional[str] = None) -> None: + self.name = name + self.mitigation_strategy = mitigation_strategy + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def name(self) -> Optional[str]: + return self._name + + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.json_name('mitigationStrategy') + @serializable.xml_name('mitigationStrategy') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def mitigation_strategy(self) -> Optional[str]: + return self._mitigation_strategy + + @mitigation_strategy.setter + def mitigation_strategy(self, mitigation_strategy: Optional[str]) -> None: + self._mitigation_strategy = mitigation_strategy + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.name, self.mitigation_strategy)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, EthicalConsideration): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, EthicalConsideration): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, EthicalConsideration): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, EthicalConsideration): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class FairnessAssessment: + """Entry in `fairnessAssessments`.""" + + def __init__( + self, *, + group_at_risk: Optional[str] = None, + benefits: Optional[str] = None, + harms: Optional[str] = None, + mitigation_strategy: Optional[str] = None, + ) -> None: + self.group_at_risk = group_at_risk + self.benefits = benefits + self.harms = harms + self.mitigation_strategy = mitigation_strategy + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.json_name('groupAtRisk') + @serializable.xml_name('groupAtRisk') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def group_at_risk(self) -> Optional[str]: + return self._group_at_risk + + @group_at_risk.setter + def group_at_risk(self, group_at_risk: Optional[str]) -> None: + self._group_at_risk = group_at_risk + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def benefits(self) -> Optional[str]: + return self._benefits + + @benefits.setter + def benefits(self, benefits: Optional[str]) -> None: + self._benefits = benefits + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def harms(self) -> Optional[str]: + return self._harms + + @harms.setter + def harms(self, harms: Optional[str]) -> None: + self._harms = harms + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + @serializable.json_name('mitigationStrategy') + @serializable.xml_name('mitigationStrategy') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def mitigation_strategy(self) -> Optional[str]: + return self._mitigation_strategy + + @mitigation_strategy.setter + def mitigation_strategy(self, mitigation_strategy: Optional[str]) -> None: + self._mitigation_strategy = mitigation_strategy + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.group_at_risk, self.benefits, self.harms, self.mitigation_strategy)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, FairnessAssessment): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, FairnessAssessment): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, FairnessAssessment): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, FairnessAssessment): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class EnvironmentalConsiderations: + """Environmental considerations (1.6+). Energy consumptions and properties. + + NOTE: Prior revisions kept `energy_consumptions` opaque. This has been replaced by + concrete types that match CycloneDX 1.6+/1.7 schema: `EnergyConsumption`, `EnergyMeasure`, + `Co2Measure`, `EnergyProvider`, and enumerations for `activity` and `energySource`. + """ + + def __init__( + self, *, + energy_consumptions: Optional[Iterable['EnergyConsumption']] = None, + properties: Optional[Iterable[Property]] = None, + ) -> None: + self.energy_consumptions = energy_consumptions or [] + self.properties = properties or [] + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.json_name('energyConsumptions') + @serializable.xml_name('energyConsumptions') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'energyConsumption') + def energy_consumptions(self) -> 'SortedSet[EnergyConsumption]': + return self._energy_consumptions + + @energy_consumptions.setter + def energy_consumptions(self, energy_consumptions: Iterable['EnergyConsumption']) -> None: + self._energy_consumptions = SortedSet(energy_consumptions) + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.xml_name('properties') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') + def properties(self) -> 'SortedSet[Property]': + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = SortedSet(properties) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((_ComparableTuple(self.energy_consumptions), _ComparableTuple(self.properties))) + + def __eq__(self, other: object) -> bool: + if isinstance(other, EnvironmentalConsiderations): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, EnvironmentalConsiderations): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, EnvironmentalConsiderations): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, EnvironmentalConsiderations): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_enum +class EnergyActivity(str, Enum): + """Enumeration for lifecycle activity in `energyConsumption.activity` (1.6+).""" + DESIGN = 'design' + DATA_COLLECTION = 'data-collection' + DATA_PREPARATION = 'data-preparation' + TRAINING = 'training' + FINE_TUNING = 'fine-tuning' + VALIDATION = 'validation' + DEPLOYMENT = 'deployment' + INFERENCE = 'inference' + OTHER = 'other' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class EnergyMeasure: + """A measure of energy. Schema `energyMeasure` (1.6+): value + unit (kWh).""" + + def __init__(self, *, value: float, unit: str = 'kWh') -> None: + self.value = value + self.unit = unit + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + def value(self) -> float: + return self._value + + @value.setter + def value(self, value: float) -> None: + self._value = value + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def unit(self) -> str: + return self._unit + + @unit.setter + def unit(self, unit: str) -> None: + # Spec allows only "kWh" + self._unit = unit + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.value, self.unit)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, EnergyMeasure): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, EnergyMeasure): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, EnergyMeasure): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, EnergyMeasure): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class Co2Measure: + """A measure of CO2. Schema `co2Measure` (1.6+): value + unit (tCO2eq).""" + + def __init__(self, *, value: float, unit: str = 'tCO2eq') -> None: + self.value = value + self.unit = unit + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + def value(self) -> float: + return self._value + + @value.setter + def value(self, value: float) -> None: + self._value = value + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def unit(self) -> str: + return self._unit + + @unit.setter + def unit(self, unit: str) -> None: + # Spec allows only "tCO2eq" + self._unit = unit + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.value, self.unit)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Co2Measure): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Co2Measure): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, Co2Measure): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, Co2Measure): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_enum +class EnergySource(str, Enum): + """Enumeration for provider `energySource` (1.6+).""" + COAL = 'coal' + OIL = 'oil' + NATURAL_GAS = 'natural-gas' + NUCLEAR = 'nuclear' + WIND = 'wind' + SOLAR = 'solar' + GEOTHERMAL = 'geothermal' + HYDROPOWER = 'hydropower' + BIOFUEL = 'biofuel' + UNKNOWN = 'unknown' + OTHER = 'other' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class EnergyProvider: + """Energy provider per schema `energyProvider` (1.6+).""" + + def __init__( + self, *, + organization: OrganizationalEntity, + energy_source: EnergySource, + energy_provided: EnergyMeasure, + bom_ref: Optional[Union[str, BomRef]] = None, + description: Optional[str] = None, + external_references: Optional[Iterable[ExternalReference]] = None, + ) -> None: + self._bom_ref = _bom_ref_from_str(bom_ref) if bom_ref is not None else _bom_ref_from_str(None) + self.description = description + self.organization = organization + self.energy_source = energy_source + self.energy_provided = energy_provided + self.external_references = external_references or [] + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> BomRef: + return self._bom_ref + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def description(self) -> Optional[str]: + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + def organization(self) -> OrganizationalEntity: + return self._organization + + @organization.setter + def organization(self, organization: OrganizationalEntity) -> None: + self._organization = organization + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + @serializable.json_name('energySource') + @serializable.xml_name('energySource') + def energy_source(self) -> EnergySource: + return self._energy_source + + @energy_source.setter + def energy_source(self, energy_source: EnergySource) -> None: + self._energy_source = energy_source + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + @serializable.json_name('energyProvided') + @serializable.xml_name('energyProvided') + def energy_provided(self) -> EnergyMeasure: + return self._energy_provided + + @energy_provided.setter + def energy_provided(self, energy_provided: EnergyMeasure) -> None: + self._energy_provided = energy_provided + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(5) + @serializable.xml_name('externalReferences') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') + def external_references(self) -> 'SortedSet[ExternalReference]': + return self._external_references + + @external_references.setter + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = SortedSet(external_references) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self._bom_ref.value, + self.description, + self.organization, + self.energy_source, + self.energy_provided, + _ComparableTuple(self.external_references), + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, EnergyProvider): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, EnergyProvider): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, EnergyProvider): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, EnergyProvider): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class EnergyConsumption: + """Energy consumption entry. Matches schema `energyConsumption` (1.6+).""" + + def __init__( + self, *, + activity: EnergyActivity, + energy_providers: Iterable[EnergyProvider], + activity_energy_cost: EnergyMeasure, + co2_cost_equivalent: Optional[Co2Measure] = None, + co2_cost_offset: Optional[Co2Measure] = None, + properties: Optional[Iterable[Property]] = None, + ) -> None: + self.activity = activity + self.energy_providers = energy_providers + self.activity_energy_cost = activity_energy_cost + self.co2_cost_equivalent = co2_cost_equivalent + self.co2_cost_offset = co2_cost_offset + self.properties = properties or [] + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + def activity(self) -> EnergyActivity: + return self._activity + + @activity.setter + def activity(self, activity: EnergyActivity) -> None: + self._activity = activity + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.json_name('energyProviders') + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'energyProviders') + def energy_providers(self) -> 'SortedSet[EnergyProvider]': + return self._energy_providers + + @energy_providers.setter + def energy_providers(self, energy_providers: Iterable[EnergyProvider]) -> None: + self._energy_providers = SortedSet(energy_providers) + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + @serializable.json_name('activityEnergyCost') + @serializable.xml_name('activityEnergyCost') + def activity_energy_cost(self) -> EnergyMeasure: + return self._activity_energy_cost + + @activity_energy_cost.setter + def activity_energy_cost(self, activity_energy_cost: EnergyMeasure) -> None: + self._activity_energy_cost = activity_energy_cost + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + @serializable.json_name('co2CostEquivalent') + @serializable.xml_name('co2CostEquivalent') + def co2_cost_equivalent(self) -> Optional[Co2Measure]: + return self._co2_cost_equivalent + + @co2_cost_equivalent.setter + def co2_cost_equivalent(self, co2_cost_equivalent: Optional[Co2Measure]) -> None: + self._co2_cost_equivalent = co2_cost_equivalent + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(5) + @serializable.json_name('co2CostOffset') + @serializable.xml_name('co2CostOffset') + def co2_cost_offset(self) -> Optional[Co2Measure]: + return self._co2_cost_offset + + @co2_cost_offset.setter + def co2_cost_offset(self, co2_cost_offset: Optional[Co2Measure]) -> None: + self._co2_cost_offset = co2_cost_offset + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(6) + @serializable.xml_name('properties') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') + def properties(self) -> 'SortedSet[Property]': + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = SortedSet(properties) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.activity, + _ComparableTuple(self.energy_providers), + self.activity_energy_cost, + self.co2_cost_equivalent, + self.co2_cost_offset, + _ComparableTuple(self.properties), + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, EnergyConsumption): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, EnergyConsumption): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, EnergyConsumption): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, EnergyConsumption): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return ( + f'' + ) + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class Considerations: + """`considerations` block within `modelCard`.""" + + def __init__( + self, *, + users: Optional[Iterable[str]] = None, + use_cases: Optional[Iterable[str]] = None, + technical_limitations: Optional[Iterable[str]] = None, + performance_tradeoffs: Optional[Iterable[str]] = None, + ethical_considerations: Optional[Iterable[EthicalConsideration]] = None, + environmental_considerations: Optional[EnvironmentalConsiderations] = None, + fairness_assessments: Optional[Iterable[FairnessAssessment]] = None, + ) -> None: + self.users = users or [] + self.use_cases = use_cases or [] + self.technical_limitations = technical_limitations or [] + self.performance_tradeoffs = performance_tradeoffs or [] + self.ethical_considerations = ethical_considerations or [] + self.environmental_considerations = environmental_considerations + self.fairness_assessments = fairness_assessments or [] + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.xml_name('users') + @serializable.json_name('users') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'user') + def users(self) -> 'SortedSet[str]': + return self._users + + @users.setter + def users(self, users: Iterable[str]) -> None: + self._users = SortedSet(users) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.json_name('useCases') + @serializable.xml_name('useCases') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'useCase') + def use_cases(self) -> 'SortedSet[str]': + return self._use_cases + + @use_cases.setter + def use_cases(self, use_cases: Iterable[str]) -> None: + self._use_cases = SortedSet(use_cases) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + @serializable.json_name('technicalLimitations') + @serializable.xml_name('technicalLimitations') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'technicalLimitation') + def technical_limitations(self) -> 'SortedSet[str]': + return self._technical_limitations + + @technical_limitations.setter + def technical_limitations(self, technical_limitations: Iterable[str]) -> None: + self._technical_limitations = SortedSet(technical_limitations) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + @serializable.json_name('performanceTradeoffs') + @serializable.xml_name('performanceTradeoffs') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'performanceTradeoff') + def performance_tradeoffs(self) -> 'SortedSet[str]': + return self._performance_tradeoffs + + @performance_tradeoffs.setter + def performance_tradeoffs(self, performance_tradeoffs: Iterable[str]) -> None: + self._performance_tradeoffs = SortedSet(performance_tradeoffs) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(5) + @serializable.json_name('ethicalConsiderations') + @serializable.xml_name('ethicalConsiderations') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'ethicalConsideration') + def ethical_considerations(self) -> 'SortedSet[EthicalConsideration]': + return self._ethical_considerations + + @ethical_considerations.setter + def ethical_considerations(self, ethical_considerations: Iterable[EthicalConsideration]) -> None: + self._ethical_considerations = SortedSet(ethical_considerations) + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(6) + @serializable.json_name('environmentalConsiderations') + @serializable.xml_name('environmentalConsiderations') + def environmental_considerations(self) -> Optional[EnvironmentalConsiderations]: + return self._environmental_considerations + + @environmental_considerations.setter + def environmental_considerations(self, environmental_considerations: Optional[EnvironmentalConsiderations]) -> None: + self._environmental_considerations = environmental_considerations + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(7) + @serializable.json_name('fairnessAssessments') + @serializable.xml_name('fairnessAssessments') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'fairnessAssessment') + def fairness_assessments(self) -> 'SortedSet[FairnessAssessment]': + return self._fairness_assessments + + @fairness_assessments.setter + def fairness_assessments(self, fairness_assessments: Iterable[FairnessAssessment]) -> None: + self._fairness_assessments = SortedSet(fairness_assessments) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + _ComparableTuple(self.users), + _ComparableTuple(self.use_cases), + _ComparableTuple(self.technical_limitations), + _ComparableTuple(self.performance_tradeoffs), + _ComparableTuple(self.ethical_considerations), + self.environmental_considerations, + _ComparableTuple(self.fairness_assessments), + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Considerations): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Considerations): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, Considerations): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, Considerations): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return ( + f'' + ) + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class ModelCard: + """Internal representation of CycloneDX `modelCardType`. + + Version gating: + - Introduced in schema 1.5 + - Unchanged structurally in 1.6 except for additional nested environmental considerations inside `considerations` + - 1.7 retains 1.6 structure (additions in nested types only) + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + model_parameters: Optional[ModelParameters] = None, + quantitative_analysis: Optional[QuantitativeAnalysis] = None, + considerations: Optional[Considerations] = None, + properties: Optional[Iterable[Property]] = None, + ) -> None: + self._bom_ref = _bom_ref_from_str(bom_ref) if bom_ref is not None else _bom_ref_from_str(None) + self.model_parameters = model_parameters + self.quantitative_analysis = quantitative_analysis + self.considerations = considerations + self.properties = properties or [] + + # bom-ref attribute + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> BomRef: + return self._bom_ref + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + @serializable.json_name('modelParameters') + @serializable.xml_name('modelParameters') + def model_parameters(self) -> Optional[ModelParameters]: + return self._model_parameters + + @model_parameters.setter + def model_parameters(self, model_parameters: Optional[ModelParameters]) -> None: + self._model_parameters = model_parameters + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + @serializable.json_name('quantitativeAnalysis') + @serializable.xml_name('quantitativeAnalysis') + def quantitative_analysis(self) -> Optional[QuantitativeAnalysis]: + return self._quantitative_analysis + + @quantitative_analysis.setter + def quantitative_analysis(self, quantitative_analysis: Optional[QuantitativeAnalysis]) -> None: + self._quantitative_analysis = quantitative_analysis + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + @serializable.json_name('considerations') + @serializable.xml_name('considerations') + def considerations(self) -> Optional[Considerations]: + return self._considerations + + @considerations.setter + def considerations(self, considerations: Optional[Considerations]) -> None: + self._considerations = considerations + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') + @serializable.xml_sequence(22) + def properties(self) -> 'SortedSet[Property]': + """ + Provides the ability to document properties in a key/value store. This provides flexibility to include data not + officially supported in the standard without having to use additional namespaces or create extensions. + + Return: + Set of `Property` + """ + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = SortedSet(properties) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.bom_ref.value, + self.model_parameters, + self.quantitative_analysis, + self.considerations, + _ComparableTuple(self.properties), + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, ModelCard): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, ModelCard): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __le__(self, other: Any) -> bool: + if isinstance(other, ModelCard): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, ModelCard): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' diff --git a/tests/test_model_model_card.py b/tests/test_model_model_card.py new file mode 100644 index 00000000..a90eeda0 --- /dev/null +++ b/tests/test_model_model_card.py @@ -0,0 +1,390 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +from unittest import TestCase +from warnings import warn + +from cyclonedx.exception import MissingOptionalDependencyException +from cyclonedx.model import AttachedText, Encoding, ExternalReference, ExternalReferenceType, Property, XsUri +from cyclonedx.model.bom import Bom +from cyclonedx.model.component import Component, ComponentType +from cyclonedx.model.contact import OrganizationalEntity +from cyclonedx.model.model_card import ( + Approach, + Co2Measure, + Considerations, + EnergyActivity, + EnergyConsumption, + EnergyMeasure, + EnergyProvider, + EnergySource, + EnvironmentalConsiderations, + InputOutputMLParameters, + MachineLearningApproach, + ModelCard, + ModelParameters, + PerformanceMetric, + QuantitativeAnalysis, +) +from cyclonedx.output.json import BY_SCHEMA_VERSION as JSON_BY_SCHEMA_VERSION +from cyclonedx.output.xml import BY_SCHEMA_VERSION as XML_BY_SCHEMA_VERSION +from cyclonedx.schema import SchemaVersion +from cyclonedx.validation.json import JsonStrictValidator +from cyclonedx.validation.xml import XmlValidator +from tests import reorder + + +class TestModelCardOnComponent(TestCase): + """Test cases for ModelCard integration within Component objects.""" + + def _make_basic_model_card(self) -> ModelCard: + """Helper to create a basic ModelCard instance.""" + return ModelCard( + model_parameters=ModelParameters( + approach=Approach(type=MachineLearningApproach.SUPERVISED), + task='classification', + architecture_family='Transformer', + model_architecture='Tiny-Transformer', + inputs=[InputOutputMLParameters(format='text')], + outputs=[InputOutputMLParameters(format='label')], + ), + quantitative_analysis=QuantitativeAnalysis( + performance_metrics=[PerformanceMetric(type='accuracy', value='0.95')] + ), + considerations=Considerations( + users=['ml-engineer'], + use_cases=['spam-detection'], + ), + ) + + def test_model_card_basic_v15_json_xml(self) -> None: + """Test basic ModelCard serialization in BOM 1.5 JSON and XML formats.""" + mc = self._make_basic_model_card() + c = Component(name='mymodel', type=ComponentType.MACHINE_LEARNING_MODEL, model_card=mc) + bom = Bom(components=[c]) + + # JSON 1.5 + json = JSON_BY_SCHEMA_VERSION[SchemaVersion.V1_5](bom).output_as_string(indent=2) + try: + err = JsonStrictValidator(SchemaVersion.V1_5).validate_str(json) + except MissingOptionalDependencyException: + warn('!!! skipped schema validation', category=UserWarning, stacklevel=0) + else: + self.assertIsNone(err, json) + self.assertIn('"modelCard"', json) + + # XML 1.5 + xml = XML_BY_SCHEMA_VERSION[SchemaVersion.V1_5](bom).output_as_string(indent=2) + try: + errx = XmlValidator(SchemaVersion.V1_5).validate_str(xml) + except MissingOptionalDependencyException: + warn('!!! skipped schema validation', category=UserWarning, stacklevel=0) + else: + self.assertIsNone(errx, xml) + self.assertIn('', xml) + + def test_model_card_environmental_v16_json_xml(self) -> None: + """Test ModelCard with EnvironmentalConsiderations in BOM 1.6 JSON and XML formats.""" + provider = EnergyProvider( + organization=OrganizationalEntity(name='GridCo'), + energy_source=EnergySource.WIND, + energy_provided=EnergyMeasure(value=123.4), + ) + consumption = EnergyConsumption( + activity=EnergyActivity.TRAINING, + energy_providers=[provider], + activity_energy_cost=EnergyMeasure(value=12.0), + co2_cost_equivalent=Co2Measure(value=0.5), + ) + env = EnvironmentalConsiderations(energy_consumptions=[consumption]) + + mc = self._make_basic_model_card() + # add environmental considerations (1.6+ only) + mc.considerations = Considerations(environmental_considerations=env) + + c = Component(name='mymodel', type=ComponentType.MACHINE_LEARNING_MODEL, model_card=mc) + bom = Bom(components=[c]) + + # JSON 1.6 + json = JSON_BY_SCHEMA_VERSION[SchemaVersion.V1_6](bom).output_as_string(indent=2) + try: + err = JsonStrictValidator(SchemaVersion.V1_6).validate_str(json) + except MissingOptionalDependencyException: + warn('!!! skipped schema validation', category=UserWarning, stacklevel=0) + else: + self.assertIsNone(err, json) + self.assertIn('"environmentalConsiderations"', json) + + # XML 1.6 + xml = XML_BY_SCHEMA_VERSION[SchemaVersion.V1_6](bom).output_as_string(indent=2) + try: + errx = XmlValidator(SchemaVersion.V1_6).validate_str(xml) + except MissingOptionalDependencyException: + warn('!!! skipped schema validation', category=UserWarning, stacklevel=0) + else: + self.assertIsNone(errx, xml) + self.assertIn('', xml) + + def test_model_card_environmental_not_in_v15(self) -> None: + """Test that EnvironmentalConsiderations are omitted in BOM 1.5 JSON and XML formats.""" + provider = EnergyProvider( + organization=OrganizationalEntity(name='GridCo'), + energy_source=EnergySource.SOLAR, + energy_provided=EnergyMeasure(value=5.0), + ) + env = EnvironmentalConsiderations( + energy_consumptions=[ + EnergyConsumption( + activity=EnergyActivity.INFERENCE, + energy_providers=[provider], + activity_energy_cost=EnergyMeasure(value=1.0), + ) + ] + ) + mc = self._make_basic_model_card() + mc.considerations = Considerations(environmental_considerations=env) + + c = Component(name='m', type=ComponentType.MACHINE_LEARNING_MODEL, model_card=mc) + bom = Bom(components=[c]) + + # JSON 1.5 should omit environmentalConsiderations + json = JSON_BY_SCHEMA_VERSION[SchemaVersion.V1_5](bom).output_as_string(indent=2) + self.assertIn('"modelCard"', json) + self.assertNotIn('"environmentalConsiderations"', json) + + # XML 1.5 should omit environmentalConsiderations + xml = XML_BY_SCHEMA_VERSION[SchemaVersion.V1_5](bom).output_as_string(indent=2) + self.assertIn('', xml) + self.assertNotIn('', xml) + + def test_model_card_full_v17_json_xml(self) -> None: + """Test full-featured ModelCard serialization in BOM 1.7 JSON and XML formats.""" + # Build a rich model card with most fields populated + graphics = QuantitativeAnalysis( + performance_metrics=[ + PerformanceMetric( + type='f1', value='0.88', + slice='en', + confidence_interval=None, + ), + PerformanceMetric( + type='accuracy', value='0.95', + ), + ], + graphics=None, + ) + + mc = ModelCard( + bom_ref='mc-1', + model_parameters=ModelParameters( + approach=Approach(type=MachineLearningApproach.UNSUPERVISED), + task='clustering', + architecture_family='Transformer', + model_architecture='X-Transformer', + inputs=[InputOutputMLParameters(format='text/plain')], + outputs=[InputOutputMLParameters(format='cluster-id')], + ), + quantitative_analysis=graphics, + considerations=Considerations( + users=['ml-engineer'], + use_cases=['topic-grouping'], + technical_limitations=['small-context'], + performance_tradeoffs=['speed-over-accuracy'], + ), + ) + + # Add rich environmental considerations + provider = EnergyProvider( + bom_ref='prov-1', + description='Primary renewable provider', + organization=OrganizationalEntity(name='Wind&Co'), + energy_source=EnergySource.WIND, + energy_provided=EnergyMeasure(value=321.0), + external_references=[ + ExternalReference( + type=ExternalReferenceType.EVIDENCE, + url=XsUri('https://example.org/energy'), + ) + ], + ) + env = EnvironmentalConsiderations( + energy_consumptions=[ + EnergyConsumption( + activity=EnergyActivity.TRAINING, + energy_providers=[provider], + activity_energy_cost=EnergyMeasure(value=42.0), + co2_cost_equivalent=Co2Measure(value=0.7), + properties=[Property(name='phase', value='exp1')], + ) + ], + properties=[Property(name='footprint', value='low')], + ) + mc.considerations = Considerations( + users=['ml-engineer'], + use_cases=['topic-grouping'], + technical_limitations=['small-context'], + performance_tradeoffs=['speed-over-accuracy'], + environmental_considerations=env, + ) + + # Embed in component and serialize in 1.7 + c = Component(name='advanced-model', type=ComponentType.MACHINE_LEARNING_MODEL, model_card=mc) + bom = Bom(components=[c]) + + # JSON 1.7 + json = JSON_BY_SCHEMA_VERSION[SchemaVersion.V1_7](bom).output_as_string(indent=2) + try: + err = JsonStrictValidator(SchemaVersion.V1_7).validate_str(json) + except MissingOptionalDependencyException: + warn('!!! skipped schema validation', category=UserWarning, stacklevel=0) + else: + self.assertIsNone(err, json) + self.assertIn('"modelCard"', json) + self.assertIn('"bom-ref": "mc-1"', json) + self.assertIn('"environmentalConsiderations"', json) + self.assertIn('"energyProviders"', json) + self.assertIn('"bom-ref": "prov-1"', json) + + # XML 1.7 + xml = XML_BY_SCHEMA_VERSION[SchemaVersion.V1_7](bom).output_as_string(indent=2) + try: + errx = XmlValidator(SchemaVersion.V1_7).validate_str(xml) + except MissingOptionalDependencyException: + warn('!!! skipped schema validation', category=UserWarning, stacklevel=0) + else: + self.assertIsNone(errx, xml) + self.assertIn('', xml) + self.assertIn(' None: + """Test sorting of Approach instances based on MachineLearningApproach enum values.""" + a = [ + Approach(type=MachineLearningApproach.SUPERVISED), + Approach(type=MachineLearningApproach.UNSUPERVISED), + Approach(type=MachineLearningApproach.REINFORCEMENT_LEARNING), + ] + # expected order: by enum value + expected = reorder(a, [2, 0, 1]) + self.assertListEqual(sorted(a), expected) + + def test_io_params_sort(self) -> None: + """Test sorting of InputOutputMLParameters by format string.""" + items = [ + InputOutputMLParameters(format='b'), + InputOutputMLParameters(format='a'), + ] + expected = reorder(items, [1, 0]) + self.assertListEqual(sorted(items), expected) + + def test_graphic_and_text(self) -> None: + """Test AttachedText and PerformanceMetric equality and sorting.""" + img_a = AttachedText(content='imgA', content_type='image/png', encoding=Encoding.BASE_64) + img_b = AttachedText(content='imgB') + g1 = PerformanceMetric(type='acc', value='0.9') + g2 = PerformanceMetric(type='f1', value='0.8') + qa1 = QuantitativeAnalysis(performance_metrics=[g1, g2]) + self.assertEqual(len(qa1.performance_metrics), 2) + # Ensure AttachedText sorting is stable via imported tests for AttachedText + self.assertNotEqual(img_a, img_b) + + +class TestModelCardContainers(TestCase): + """Test cases for container objects within the ModelCard data structures.""" + + def test_model_parameters_equality(self) -> None: + """Test equality comparison of ModelParameters instances.""" + mp1 = ModelParameters( + approach=Approach(type=MachineLearningApproach.SELF_SUPERVISED), + task='t', + architecture_family='fam', + model_architecture='arch', + inputs=[InputOutputMLParameters(format='x')], + outputs=[InputOutputMLParameters(format='y')], + ) + mp2 = ModelParameters( + approach=Approach(type=MachineLearningApproach.SELF_SUPERVISED), + task='t', + architecture_family='fam', + model_architecture='arch', + inputs=[InputOutputMLParameters(format='x')], + outputs=[InputOutputMLParameters(format='y')], + ) + self.assertEqual(mp1, mp2) + + def test_model_card_equality_and_sort(self) -> None: + """Test equality and sorting of ModelCard instances.""" + mc1 = ModelCard(bom_ref='a', model_parameters=ModelParameters(task='a')) + mc2 = ModelCard(bom_ref='a', model_parameters=ModelParameters(task='a')) + mc3 = ModelCard(bom_ref='b', model_parameters=ModelParameters(task='a')) + self.assertEqual(hash(mc1), hash(mc2)) + self.assertEqual(mc1, mc2) + # sort by bom-ref then nested fields + sorted_list = sorted([mc3, mc1]) + self.assertListEqual(sorted_list, [mc1, mc3]) + + +class TestModelCardEnvironmental(TestCase): + """Test cases for EnvironmentalConsiderations related value objects.""" + + def test_energy_measure_equality(self) -> None: + """Test equality comparison of EnergyMeasure instances.""" + e1 = EnergyMeasure(value=1.0) + e2 = EnergyMeasure(value=1.0) + self.assertEqual(e1, e2) + + def test_energy_provider_sort(self) -> None: + """Test sorting of EnergyProvider instances by bom-ref and other fields.""" + org = OrganizationalEntity(name='Org') + p1 = EnergyProvider(organization=org, energy_source=EnergySource.COAL, + energy_provided=EnergyMeasure(value=1.0), bom_ref='a') + p2 = EnergyProvider(organization=org, energy_source=EnergySource.OIL, + energy_provided=EnergyMeasure(value=1.0), bom_ref='b') + p3 = EnergyProvider(organization=org, energy_source=EnergySource.WIND, + energy_provided=EnergyMeasure(value=2.0), bom_ref='a') + # Comparable tuple uses bom-ref first + expected = reorder([p1, p2, p3], [0, 2, 1]) + self.assertListEqual(sorted([p2, p3, p1]), expected) + + def test_energy_consumption_sort(self) -> None: + """Test sorting of EnergyConsumption instances by energy providers and other fields.""" + org = OrganizationalEntity(name='GridCo') + prov_a = EnergyProvider(organization=org, energy_source=EnergySource.WIND, + energy_provided=EnergyMeasure(value=1.0)) + prov_b = EnergyProvider(organization=org, energy_source=EnergySource.SOLAR, + energy_provided=EnergyMeasure(value=2.0)) + + c1 = EnergyConsumption( + activity=EnergyActivity.TRAINING, + energy_providers=[prov_a], + activity_energy_cost=EnergyMeasure(value=10.0), + ) + c2 = EnergyConsumption( + activity=EnergyActivity.TRAINING, + energy_providers=[prov_b], + activity_energy_cost=EnergyMeasure(value=10.0), + ) + # energy_providers affects ordering + # Solar providers sort before Wind providers + expected = reorder([c1, c2], [1, 0]) + self.assertListEqual(sorted([c2, c1]), expected)