From aa27b0a69602a2de4fa9934c6819dd473b5f835e Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 18 Nov 2025 10:35:02 +0100 Subject: [PATCH 1/8] feat: introduce ModelCard data structure for CycloneDX schema v1.5+ Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/model_card.py | 159 ++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 cyclonedx/model/model_card.py diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py new file mode 100644 index 00000000..5060a547 --- /dev/null +++ b/cyclonedx/model/model_card.py @@ -0,0 +1,159 @@ +"""CycloneDX Model Card data structures. + +This module introduces support for the CycloneDX `modelCard` object (schema v1.5+). +Only the top-level container (`ModelCard`) is implemented here first; nested types +will follow in subsequent TODOs to keep changes reviewable. + +References: +- CycloneDX 1.5/1.6/1.7 modelCardType definitions (JSON & XSD) +""" + +from __future__ import annotations + +from typing import Optional, Iterable, Any, 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 .bom_ref import BomRef +from . import Property +from ..schema.schema import ( + SchemaVersion1Dot5, + SchemaVersion1Dot6, + SchemaVersion1Dot7, +) +from .._internal.compare import ComparableTuple as _ComparableTuple + + +@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) + + NOTE: Nested complex objects (modelParameters, quantitativeAnalysis, considerations) will be + implemented in later steps. For now they are treated as opaque objects so that `Component.model_card` + can reference this container. + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + model_parameters: Optional[Any] = None, # will be refined + quantitative_analysis: Optional[Any] = None, # will be refined + considerations: Optional[Any] = None, # will be refined + 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.xml_name('bom-ref') + @serializable.xml_attribute() + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + def bom_ref(self) -> BomRef: + return self._bom_ref + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + def model_parameters(self) -> Optional[Any]: # placeholder type + return self._model_parameters + + @model_parameters.setter + def model_parameters(self, model_parameters: Optional[Any]) -> None: + self._model_parameters = model_parameters + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(2) + def quantitative_analysis(self) -> Optional[Any]: # placeholder type + return self._quantitative_analysis + + @quantitative_analysis.setter + def quantitative_analysis(self, quantitative_analysis: Optional[Any]) -> None: + self._quantitative_analysis = quantitative_analysis + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + def considerations(self) -> Optional[Any]: # placeholder type + return self._considerations + + @considerations.setter + def considerations(self, considerations: Optional[Any]) -> None: + self._considerations = considerations + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + 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.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 __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +# TODO ModelParameters, MachineLearningApproach enum, InputOutputMLParameters + +# TODO PerformanceMetric, ConfidenceInterval + +# TODO GraphicsCollection, Graphic (using existing AttachedText for images) + +# TODO Considerations, Risk, FairnessAssessment (+ environmental considerations for >=1.6) + +# TODO only for 1.6/1.7: EnvironmentalConsiderations, EnergyConsumptions, EnergyConsumption, EnergyMeasure, Co2Measure, EnergyProvider, plus the activity/unit enums per XSD. + +# TODO (not in this file) +# - Add `Component.model_card` with `@serializable.view` gating for 1.5/1.6/1.7. +# - Use `@serializable.xml_sequence(26)` and ensure correct JSON name `modelCard`. +# - Add to `__comparable_tuple` for equality/hash. + +# TODO (not in this file) +# - JSON tests across views; XML order validation; round-trip tests. +# - Docs & changelog updates. \ No newline at end of file From c20401cd301f3a642acf86e2f92efc65c89cc7b7 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 18 Nov 2025 10:53:20 +0100 Subject: [PATCH 2/8] feat: enhance ModelCard with ModelParameters and MachineLearningApproach support Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/model_card.py | 461 +++++++++++++++++++++++++--------- 1 file changed, 345 insertions(+), 116 deletions(-) diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py index 5060a547..a5535bd1 100644 --- a/cyclonedx/model/model_card.py +++ b/cyclonedx/model/model_card.py @@ -11,6 +11,7 @@ from __future__ import annotations from typing import Optional, Iterable, Any, Union +from enum import Enum import py_serializable as serializable from sortedcontainers import SortedSet @@ -19,127 +20,355 @@ from .bom_ref import BomRef from . import Property from ..schema.schema import ( - SchemaVersion1Dot5, - SchemaVersion1Dot6, - SchemaVersion1Dot7, + SchemaVersion1Dot5, + SchemaVersion1Dot6, + SchemaVersion1Dot7, ) from .._internal.compare import ComparableTuple as _ComparableTuple @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) - - NOTE: Nested complex objects (modelParameters, quantitativeAnalysis, considerations) will be - implemented in later steps. For now they are treated as opaque objects so that `Component.model_card` - can reference this container. - """ - - def __init__( - self, *, - bom_ref: Optional[Union[str, BomRef]] = None, - model_parameters: Optional[Any] = None, # will be refined - quantitative_analysis: Optional[Any] = None, # will be refined - considerations: Optional[Any] = None, # will be refined - 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.xml_name('bom-ref') - @serializable.xml_attribute() - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.view(SchemaVersion1Dot7) - def bom_ref(self) -> BomRef: - return self._bom_ref - - @property - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.view(SchemaVersion1Dot7) - @serializable.xml_sequence(1) - def model_parameters(self) -> Optional[Any]: # placeholder type - return self._model_parameters - - @model_parameters.setter - def model_parameters(self, model_parameters: Optional[Any]) -> None: - self._model_parameters = model_parameters - - @property - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.view(SchemaVersion1Dot7) - @serializable.xml_sequence(2) - def quantitative_analysis(self) -> Optional[Any]: # placeholder type - return self._quantitative_analysis - - @quantitative_analysis.setter - def quantitative_analysis(self, quantitative_analysis: Optional[Any]) -> None: - self._quantitative_analysis = quantitative_analysis - - @property - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.view(SchemaVersion1Dot7) - @serializable.xml_sequence(3) - def considerations(self) -> Optional[Any]: # placeholder type - return self._considerations - - @considerations.setter - def considerations(self, considerations: Optional[Any]) -> None: - self._considerations = considerations - - @property - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.view(SchemaVersion1Dot7) - @serializable.xml_sequence(4) - 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.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 __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -# TODO ModelParameters, MachineLearningApproach enum, InputOutputMLParameters + """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) + + NOTE: Nested complex objects (modelParameters, quantitativeAnalysis, considerations) will be + implemented in later steps. For now they are treated as opaque objects so that `Component.model_card` + can reference this container. + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + model_parameters: Optional['ModelParameters'] = None, + quantitative_analysis: Optional[Any] = None, # will be refined + considerations: Optional[Any] = None, # will be refined + 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.xml_name('bom-ref') + @serializable.xml_attribute() + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + def bom_ref(self) -> BomRef: + return self._bom_ref + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(1) + 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) + def quantitative_analysis(self) -> Optional[Any]: # placeholder type + return self._quantitative_analysis + + @quantitative_analysis.setter + def quantitative_analysis(self, quantitative_analysis: Optional[Any]) -> None: + self._quantitative_analysis = quantitative_analysis + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(3) + def considerations(self) -> Optional[Any]: # placeholder type + return self._considerations + + @considerations.setter + def considerations(self, considerations: Optional[Any]) -> None: + self._considerations = considerations + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(4) + 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.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 __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@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 __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 __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, # TODO: refine (componentData or ref) + 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 + self.datasets = datasets or [] + 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.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + 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.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + 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.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + 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 + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(5) + def datasets(self) -> 'SortedSet[Any]': # TODO: refine type + return self._datasets + + @datasets.setter + def datasets(self, datasets: Iterable[Any]) -> None: + self._datasets = SortedSet(datasets) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'input') + 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.xml_array(serializable.XmlArraySerializationType.NESTED, 'output') + 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 __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' # TODO PerformanceMetric, ConfidenceInterval @@ -156,4 +385,4 @@ def __repr__(self) -> str: # TODO (not in this file) # - JSON tests across views; XML order validation; round-trip tests. -# - Docs & changelog updates. \ No newline at end of file +# - Docs & changelog updates. From b3798535b6ecb5f43adc35c52b69f9ed508e3436 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 18 Nov 2025 10:50:58 +0100 Subject: [PATCH 3/8] feat: extend ModelCard with additional properties and support for performance metrics Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/model_card.py | 360 +++++++++++++++++++++++++++++++++- 1 file changed, 356 insertions(+), 4 deletions(-) diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py index a5535bd1..7f1e2d2a 100644 --- a/cyclonedx/model/model_card.py +++ b/cyclonedx/model/model_card.py @@ -18,7 +18,7 @@ from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str from .bom_ref import BomRef -from . import Property +from . import Property, AttachedText from ..schema.schema import ( SchemaVersion1Dot5, SchemaVersion1Dot6, @@ -71,6 +71,8 @@ def bom_ref(self) -> BomRef: @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 @@ -83,11 +85,13 @@ def model_parameters(self, model_parameters: Optional['ModelParameters']) -> Non @serializable.view(SchemaVersion1Dot6) @serializable.view(SchemaVersion1Dot7) @serializable.xml_sequence(2) - def quantitative_analysis(self) -> Optional[Any]: # placeholder type + @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[Any]) -> None: + def quantitative_analysis(self, quantitative_analysis: Optional['QuantitativeAnalysis']) -> None: self._quantitative_analysis = quantitative_analysis @property @@ -271,7 +275,9 @@ def approach(self, approach: Optional[Approach]) -> None: @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 @@ -284,7 +290,9 @@ def task(self, task: Optional[str]) -> None: @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 @@ -297,7 +305,9 @@ def architecture_family(self, architecture_family: Optional[str]) -> None: @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 @@ -306,10 +316,33 @@ def model_architecture(self, model_architecture: Optional[str]) -> None: self._model_architecture = model_architecture @property + # NOTE on `datasets` placeholder (to be refined with #913): + # The CycloneDX spec allows two shapes for each dataset entry under `modelParameters.datasets`: + # - Inline dataset information via the `componentData` object (XSD: element `dataset` of type `componentDataType`) + # - A reference object with a single child `ref` that points to an existing data component by bom-ref + # (XSD: element `ref` with union type of `refLinkType` or `bomLinkElementType`). + # + # In JSON (1.5/1.6/1.7) this is expressed as an array with items oneOf: { componentData | { ref: ... } }. + # In XML (1.5/1.6/1.7) this is expressed as a choice between or entries. + # + # The parent issue (#913) tracks adding first-class support for Component.data (`component.data`) and its + # associated types. To avoid duplicating transient types and serializers here, we intentionally keep the + # `datasets` field as `SortedSet[Any]` for now. Once `component.data` is modeled, we will: + # - Introduce a concrete type (e.g., `ModelDatasetEntry`) that can represent either a `componentData` inline + # object or a `{ref: ...}` reference, similar to how other union-like schemas are modeled in this codebase. + # - Provide serialization helpers that emit either the `` element (mapped to the `componentData` type) + # or the `` element, and the equivalent JSON oneOf shape, based on the actual instance provided. + # - Ensure both `component.data` and `modelParameters.datasets` reuse the same `componentData` type definition + # and the same normalization logic, so producers/consumers see consistent structures across the BOM. + # + # Until #913 lands, using `Any` here keeps the public API unblocked for the `modelCard` work, while making the + # intended future refinement explicit and localized. @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.view(SchemaVersion1Dot7) @serializable.xml_sequence(5) + @serializable.json_name('datasets') + @serializable.xml_name('datasets') def datasets(self) -> 'SortedSet[Any]': # TODO: refine type return self._datasets @@ -322,7 +355,9 @@ def datasets(self, datasets: Iterable[Any]) -> None: @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 @@ -335,7 +370,9 @@ def inputs(self, inputs: Iterable[InputOutputMLParameters]) -> None: @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 @@ -370,7 +407,322 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f'' -# TODO PerformanceMetric, ConfidenceInterval + +@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 __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 __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 __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 __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 __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' # TODO GraphicsCollection, Graphic (using existing AttachedText for images) From 68e3a1677cd992c48685c5150daf64a5e7233129 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 18 Nov 2025 11:27:56 +0100 Subject: [PATCH 4/8] feat: implement full ModelCard structure with ethical and environmental considerations (`modelParameters.datasets` remains a staged placeholder until #913) Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/model_card.py | 768 +++++++++++++++++++++++++++++++++- 1 file changed, 758 insertions(+), 10 deletions(-) diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py index 7f1e2d2a..ba5b6f33 100644 --- a/cyclonedx/model/model_card.py +++ b/cyclonedx/model/model_card.py @@ -1,8 +1,9 @@ """CycloneDX Model Card data structures. This module introduces support for the CycloneDX `modelCard` object (schema v1.5+). -Only the top-level container (`ModelCard`) is implemented here first; nested types -will follow in subsequent TODOs to keep changes reviewable. +Implements the full `modelCard` structure including `modelParameters`, +`quantitativeAnalysis`, `considerations`, and environmental energy types. +Note: `modelParameters.datasets` remains a staged placeholder until #913. References: - CycloneDX 1.5/1.6/1.7 modelCardType definitions (JSON & XSD) @@ -18,7 +19,8 @@ from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str from .bom_ref import BomRef -from . import Property, AttachedText +from . import Property, AttachedText, ExternalReference +from .contact import OrganizationalEntity from ..schema.schema import ( SchemaVersion1Dot5, SchemaVersion1Dot6, @@ -45,8 +47,8 @@ def __init__( self, *, bom_ref: Optional[Union[str, BomRef]] = None, model_parameters: Optional['ModelParameters'] = None, - quantitative_analysis: Optional[Any] = None, # will be refined - considerations: Optional[Any] = None, # will be refined + 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) @@ -99,11 +101,13 @@ def quantitative_analysis(self, quantitative_analysis: Optional['QuantitativeAna @serializable.view(SchemaVersion1Dot6) @serializable.view(SchemaVersion1Dot7) @serializable.xml_sequence(3) - def considerations(self) -> Optional[Any]: # placeholder type + @serializable.json_name('considerations') + @serializable.xml_name('considerations') + def considerations(self) -> Optional['Considerations']: return self._considerations @considerations.setter - def considerations(self, considerations: Optional[Any]) -> None: + def considerations(self, considerations: Optional['Considerations']) -> None: self._considerations = considerations @property @@ -724,11 +728,755 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f'' -# TODO GraphicsCollection, Graphic (using existing AttachedText for images) -# TODO Considerations, Risk, FairnessAssessment (+ environmental considerations for >=1.6) +# Considerations and nested structures -# TODO only for 1.6/1.7: EnvironmentalConsiderations, EnergyConsumptions, EnergyConsumption, EnergyMeasure, Co2Measure, EnergyProvider, plus the activity/unit enums per XSD. +@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 __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 __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 __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 __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 __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.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.type_mapping(BomRef) + @serializable.xml_attribute() + @serializable.json_name('bom-ref') + @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 __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_name('energyProviders') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'energyProvider') + 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 __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 __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return ( + f'' + ) # TODO (not in this file) # - Add `Component.model_card` with `@serializable.view` gating for 1.5/1.6/1.7. From 10e3fc3fb84571ffd7b0a44092bf8e5d93cf0688 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 18 Nov 2025 13:11:37 +0100 Subject: [PATCH 5/8] feat: integrate ModelCard into Component and add basic tests for JSON/XML validation Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/component.py | 25 +++- cyclonedx/model/model_card.py | 249 ++++++++++++++++----------------- tests/test_model_model_card.py | 121 ++++++++++++++++ 3 files changed, 262 insertions(+), 133 deletions(-) create mode 100644 tests/test_model_model_card.py diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index c4e4e5c6..95c3f392 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -65,6 +65,7 @@ from .issue import IssueType from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper from .release_note import ReleaseNotes +from .model_card import ModelCard @serializable.serializable_class(ignore_unknown_during_deserialization=True) @@ -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.model_card, self.crypto_properties, _ComparableTuple(self.tags), )) def __eq__(self, other: object) -> bool: diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py index ba5b6f33..610968bf 100644 --- a/cyclonedx/model/model_card.py +++ b/cyclonedx/model/model_card.py @@ -9,8 +9,6 @@ - CycloneDX 1.5/1.6/1.7 modelCardType definitions (JSON & XSD) """ -from __future__ import annotations - from typing import Optional, Iterable, Any, Union from enum import Enum @@ -29,125 +27,6 @@ from .._internal.compare import ComparableTuple as _ComparableTuple -@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) - - NOTE: Nested complex objects (modelParameters, quantitativeAnalysis, considerations) will be - implemented in later steps. For now they are treated as opaque objects so that `Component.model_card` - can reference this container. - """ - - 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.xml_name('bom-ref') - @serializable.xml_attribute() - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.view(SchemaVersion1Dot7) - 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_sequence(4) - 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.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 __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - @serializable.serializable_enum class MachineLearningApproach(str, Enum): """Enumeration for `machineLearningApproachType`. @@ -1098,11 +977,11 @@ def __init__( self.external_references = external_references or [] @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) @serializable.view(SchemaVersion1Dot6) @serializable.view(SchemaVersion1Dot7) - @serializable.type_mapping(BomRef) @serializable.xml_attribute() - @serializable.json_name('bom-ref') @serializable.xml_name('bom-ref') def bom_ref(self) -> BomRef: return self._bom_ref @@ -1232,8 +1111,7 @@ def activity(self, activity: EnergyActivity) -> None: @serializable.view(SchemaVersion1Dot7) @serializable.xml_sequence(2) @serializable.json_name('energyProviders') - @serializable.xml_name('energyProviders') - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'energyProvider') + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'energyProviders') def energy_providers(self) -> 'SortedSet[EnergyProvider]': return self._energy_providers @@ -1478,11 +1356,118 @@ def __repr__(self) -> str: f'ethics={len(self.ethical_considerations)} fairness={len(self.fairness_assessments)}>' ) -# TODO (not in this file) -# - Add `Component.model_card` with `@serializable.view` gating for 1.5/1.6/1.7. -# - Use `@serializable.xml_sequence(26)` and ensure correct JSON name `modelCard`. -# - Add to `__comparable_tuple` for equality/hash. -# TODO (not in this file) -# - JSON tests across views; XML order validation; round-trip tests. -# - Docs & changelog updates. +@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_sequence(4) + 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.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 __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..9cd9a577 --- /dev/null +++ b/tests/test_model_model_card.py @@ -0,0 +1,121 @@ +# This file is part of CycloneDX Python Library +# +# SPDX-License-Identifier: Apache-2.0 + +from unittest import TestCase +from warnings import warn + +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, + Considerations, + EnergyActivity, + EnergyConsumption, + EnergyMeasure, + EnergyProvider, + EnergySource, + InputOutputMLParameters, + MachineLearningApproach, + ModelCard, + ModelParameters, + PerformanceMetric, + QuantitativeAnalysis, + EnvironmentalConsiderations, + Co2Measure, +) +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 cyclonedx.exception import MissingOptionalDependencyException + + +class TestModelCardOnComponent(TestCase): + + def _make_basic_model_card(self) -> ModelCard: + 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: + 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: + 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) From e0365a27bddf1e75d7ed9d3998445fe4a1072e99 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 18 Nov 2025 13:40:26 +0100 Subject: [PATCH 6/8] feat: update licensing information in model_card and test files Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/component.py | 2 +- cyclonedx/model/model_card.py | 30 ++++++++++++++++++++++-------- tests/test_model_model_card.py | 19 ++++++++++++++++--- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 95c3f392..8ddfe8a4 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -64,8 +64,8 @@ from .dependency import Dependable from .issue import IssueType from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper -from .release_note import ReleaseNotes from .model_card import ModelCard +from .release_note import ReleaseNotes @serializable.serializable_class(ignore_unknown_during_deserialization=True) diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py index 610968bf..3a039b45 100644 --- a/cyclonedx/model/model_card.py +++ b/cyclonedx/model/model_card.py @@ -1,3 +1,20 @@ +# 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. + """CycloneDX Model Card data structures. This module introduces support for the CycloneDX `modelCard` object (schema v1.5+). @@ -9,22 +26,19 @@ - CycloneDX 1.5/1.6/1.7 modelCardType definitions (JSON & XSD) """ -from typing import Optional, Iterable, Any, Union +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 . import Property, AttachedText, ExternalReference from .contact import OrganizationalEntity -from ..schema.schema import ( - SchemaVersion1Dot5, - SchemaVersion1Dot6, - SchemaVersion1Dot7, -) -from .._internal.compare import ComparableTuple as _ComparableTuple @serializable.serializable_enum diff --git a/tests/test_model_model_card.py b/tests/test_model_model_card.py index 9cd9a577..6db5081a 100644 --- a/tests/test_model_model_card.py +++ b/tests/test_model_model_card.py @@ -1,36 +1,49 @@ # 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.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, - EnvironmentalConsiderations, - Co2Measure, ) 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 cyclonedx.exception import MissingOptionalDependencyException class TestModelCardOnComponent(TestCase): From cd2653718132fd43c5dbcca24de1ac483aedf9c6 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 18 Nov 2025 14:39:23 +0100 Subject: [PATCH 7/8] feat: updated modelCard and added more extensive tests Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/model_card.py | 64 +++------ tests/test_model_model_card.py | 256 +++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 44 deletions(-) diff --git a/cyclonedx/model/model_card.py b/cyclonedx/model/model_card.py index 3a039b45..291e3967 100644 --- a/cyclonedx/model/model_card.py +++ b/cyclonedx/model/model_card.py @@ -15,15 +15,16 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. -"""CycloneDX Model Card data structures. +""" +This set of classes represents the model card types in the CycloneDX standard. -This module introduces support for the CycloneDX `modelCard` object (schema v1.5+). -Implements the full `modelCard` structure including `modelParameters`, -`quantitativeAnalysis`, `considerations`, and environmental energy types. -Note: `modelParameters.datasets` remains a staged placeholder until #913. +.. note:: + Introduced in CycloneDX v1.5. Environmental considerations were added in v1.6. -References: -- CycloneDX 1.5/1.6/1.7 modelCardType definitions (JSON & XSD) +.. 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 @@ -143,7 +144,7 @@ def __init__( task: Optional[str] = None, architecture_family: Optional[str] = None, model_architecture: Optional[str] = None, - datasets: Optional[Iterable[Any]] = None, # TODO: refine (componentData or ref) + datasets: Optional[Iterable[Any]] = None, # Unsupported placeholder until #913 lands. inputs: Optional[Iterable[InputOutputMLParameters]] = None, outputs: Optional[Iterable[InputOutputMLParameters]] = None, ) -> None: @@ -151,7 +152,14 @@ def __init__( self.task = task self.architecture_family = architecture_family self.model_architecture = model_architecture - self.datasets = datasets or [] + # 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 and len(list(datasets)) > 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 [] @@ -212,40 +220,8 @@ def model_architecture(self) -> Optional[str]: def model_architecture(self, model_architecture: Optional[str]) -> None: self._model_architecture = model_architecture - @property - # NOTE on `datasets` placeholder (to be refined with #913): - # The CycloneDX spec allows two shapes for each dataset entry under `modelParameters.datasets`: - # - Inline dataset information via the `componentData` object (XSD: element `dataset` of type `componentDataType`) - # - A reference object with a single child `ref` that points to an existing data component by bom-ref - # (XSD: element `ref` with union type of `refLinkType` or `bomLinkElementType`). - # - # In JSON (1.5/1.6/1.7) this is expressed as an array with items oneOf: { componentData | { ref: ... } }. - # In XML (1.5/1.6/1.7) this is expressed as a choice between or entries. - # - # The parent issue (#913) tracks adding first-class support for Component.data (`component.data`) and its - # associated types. To avoid duplicating transient types and serializers here, we intentionally keep the - # `datasets` field as `SortedSet[Any]` for now. Once `component.data` is modeled, we will: - # - Introduce a concrete type (e.g., `ModelDatasetEntry`) that can represent either a `componentData` inline - # object or a `{ref: ...}` reference, similar to how other union-like schemas are modeled in this codebase. - # - Provide serialization helpers that emit either the `` element (mapped to the `componentData` type) - # or the `` element, and the equivalent JSON oneOf shape, based on the actual instance provided. - # - Ensure both `component.data` and `modelParameters.datasets` reuse the same `componentData` type definition - # and the same normalization logic, so producers/consumers see consistent structures across the BOM. - # - # Until #913 lands, using `Any` here keeps the public API unblocked for the `modelCard` work, while making the - # intended future refinement explicit and localized. - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.view(SchemaVersion1Dot7) - @serializable.xml_sequence(5) - @serializable.json_name('datasets') - @serializable.xml_name('datasets') - def datasets(self) -> 'SortedSet[Any]': # TODO: refine type - return self._datasets - - @datasets.setter - def datasets(self, datasets: Iterable[Any]) -> None: - self._datasets = SortedSet(datasets) + # datasets intentionally omitted from serialization until #913 implemented. + # A future implementation will add a concrete union type and proper annotations. @property @serializable.view(SchemaVersion1Dot5) @@ -283,7 +259,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self.task, self.architecture_family, self.model_architecture, - _ComparableTuple(self.datasets), + _ComparableTuple(self._datasets), _ComparableTuple(self.inputs), _ComparableTuple(self.outputs), )) diff --git a/tests/test_model_model_card.py b/tests/test_model_model_card.py index 6db5081a..a90eeda0 100644 --- a/tests/test_model_model_card.py +++ b/tests/test_model_model_card.py @@ -19,6 +19,7 @@ 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 @@ -44,11 +45,14 @@ 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), @@ -68,6 +72,7 @@ def _make_basic_model_card(self) -> ModelCard: ) 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]) @@ -93,6 +98,7 @@ def test_model_card_basic_v15_json_xml(self) -> None: 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, @@ -132,3 +138,253 @@ def test_model_card_environmental_v16_json_xml(self) -> None: 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) From 3c116462d8c00c3f63d7aa285a1f0b92488471b6 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Fri, 21 Nov 2025 10:53:18 +0100 Subject: [PATCH 8/8] feat: enhance comparison methods in ModelCard Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/component.py | 2 +- cyclonedx/model/model_card.py | 190 +++++++++++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 6 deletions(-) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 8ddfe8a4..fd45060b 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -1717,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.model_card, 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 index 291e3967..1a77e9cc 100644 --- a/cyclonedx/model/model_card.py +++ b/cyclonedx/model/model_card.py @@ -87,6 +87,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -127,6 +137,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -155,10 +175,12 @@ def __init__( # 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 and len(list(datasets)) > 0: - raise NotImplementedError( - 'modelParameters.datasets is not yet supported. Tracked by issue #913.' - ) + 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 [] @@ -274,6 +296,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -332,6 +364,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -423,6 +465,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -476,6 +528,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -532,6 +594,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -591,6 +663,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -649,6 +731,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -741,6 +833,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -805,6 +907,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -871,6 +983,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -923,6 +1045,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -1058,6 +1190,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -1181,6 +1323,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -1337,6 +1489,16 @@ def __lt__(self, other: Any) -> bool: 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()) @@ -1429,8 +1591,16 @@ def considerations(self, considerations: Optional[Considerations]) -> None: @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.view(SchemaVersion1Dot7) - @serializable.xml_sequence(4) + @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 @@ -1456,6 +1626,16 @@ def __lt__(self, other: Any) -> bool: 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())