diff --git a/cyclonedx/model/annotation.py b/cyclonedx/model/annotation.py
new file mode 100644
index 00000000..d88bddbc
--- /dev/null
+++ b/cyclonedx/model/annotation.py
@@ -0,0 +1,295 @@
+# 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 collections.abc import Iterable
+from datetime import datetime
+from typing import Any, Optional, Union
+
+import py_serializable as serializable
+from py_serializable.helpers import XsdDateTime
+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 ..exception.serialization import SerializationOfUnexpectedValueException
+from .bom_ref import BomRef
+from .component import Component
+from .contact import OrganizationalContact, OrganizationalEntity
+from .service import Service
+
+
+class _SubjectsSerializationHelper(serializable.helpers.BaseHelper):
+ """THIS CLASS IS NON-PUBLIC API
+
+ JSON: subjects is a plain list of bom-ref strings.
+ XML: subjects wrapper element containing children.
+ """
+
+ @classmethod
+ def serialize(cls, o: Any) -> list[str]:
+ if isinstance(o, (SortedSet, set, list)):
+ return [str(i) for i in o]
+ raise SerializationOfUnexpectedValueException(
+ f'Attempt to serialize a non-subjects collection: {o!r}')
+
+ @classmethod
+ def deserialize(cls, o: Any) -> 'set[_AnnotationSubject]':
+ subjects: set[_AnnotationSubject] = set()
+ if isinstance(o, list):
+ for v in o:
+ subjects.add(_AnnotationSubject(ref=BomRef(value=str(v))))
+ return subjects
+
+ @classmethod
+ def xml_denormalize(cls, o: Any, *,
+ default_ns: Any,
+ prop_info: Any,
+ ctx: Any,
+ **kwargs: Any) -> set['_AnnotationSubject']:
+ subjects: set[_AnnotationSubject] = set()
+ if o is None:
+ return subjects
+ for child in o:
+ ref_val = child.get('ref')
+ if ref_val:
+ subjects.add(_AnnotationSubject(ref=BomRef(value=ref_val)))
+ return subjects
+
+
+@serializable.serializable_class(ignore_unknown_during_deserialization=True)
+class _AnnotationSubject:
+ """THIS CLASS IS NON-PUBLIC API
+
+ Wrapper that renders as ```` in XML.
+ """
+
+ def __init__(self, ref: BomRef) -> None:
+ self._ref = ref
+
+ @property
+ @serializable.type_mapping(BomRef)
+ @serializable.xml_attribute()
+ def ref(self) -> BomRef:
+ return self._ref
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, _AnnotationSubject):
+ return self._ref == other._ref
+ return False
+
+ def __hash__(self) -> int:
+ return hash(self._ref)
+
+ def __lt__(self, other: object) -> bool:
+ if isinstance(other, _AnnotationSubject):
+ return str(self._ref) < str(other._ref)
+ return NotImplemented
+
+ def __str__(self) -> str:
+ return str(self._ref)
+
+
+@serializable.serializable_class(ignore_unknown_during_deserialization=True)
+class Annotator:
+ """
+ The organization, person, component, or service which created the textual content of the annotation.
+ """
+
+ def __init__(
+ self, *,
+ organization: Optional[OrganizationalEntity] = None,
+ individual: Optional[OrganizationalContact] = None,
+ component: Optional[Component] = None,
+ service: Optional[Service] = None,
+ ) -> None:
+ if sum(x is not None for x in (organization, individual, component, service)) != 1:
+ raise ValueError('Exactly one of organization, individual, component, or service must be provided.')
+
+ self.organization = organization
+ self.individual = individual
+ self.component = component
+ self.service = service
+
+ @property
+ @serializable.xml_sequence(1)
+ def organization(self) -> Optional[OrganizationalEntity]:
+ return self._organization
+
+ @organization.setter
+ def organization(self, organization: Optional[OrganizationalEntity]) -> None:
+ self._organization = organization
+
+ @property
+ @serializable.xml_sequence(2)
+ def individual(self) -> Optional[OrganizationalContact]:
+ return self._individual
+
+ @individual.setter
+ def individual(self, individual: Optional[OrganizationalContact]) -> None:
+ self._individual = individual
+
+ @property
+ @serializable.xml_sequence(3)
+ def component(self) -> Optional[Component]:
+ return self._component
+
+ @component.setter
+ def component(self, component: Optional[Component]) -> None:
+ self._component = component
+
+ @property
+ @serializable.xml_sequence(4)
+ def service(self) -> Optional[Service]:
+ return self._service
+
+ @service.setter
+ def service(self, service: Optional[Service]) -> None:
+ self._service = service
+
+ def __comparable_tuple(self) -> _ComparableTuple:
+ return _ComparableTuple((
+ self.organization, self.individual, self.component, self.service
+ ))
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, Annotator):
+ return self.__comparable_tuple() == other.__comparable_tuple()
+ return False
+
+ def __lt__(self, other: Any) -> bool:
+ if isinstance(other, Annotator):
+ 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 Annotation:
+ """
+ A comment, note, explanation, or similar textual content which provides additional context
+ to the object(s) being annotated.
+ """
+
+ def __init__(
+ self, *,
+ subjects: Iterable[BomRef],
+ annotator: Annotator,
+ timestamp: datetime,
+ text: str,
+ bom_ref: Optional[Union[str, BomRef]] = None,
+ ) -> None:
+ self._bom_ref = _bom_ref_from_str(bom_ref)
+ self.subjects = subjects
+ self.annotator = annotator
+ self.timestamp = timestamp
+ self.text = text
+
+ @property
+ @serializable.json_name('bom-ref')
+ @serializable.type_mapping(BomRef)
+ @serializable.xml_attribute()
+ @serializable.xml_name('bom-ref')
+ def bom_ref(self) -> BomRef:
+ """
+ An optional identifier which can be used to reference the annotation elsewhere in the BOM.
+ """
+ return self._bom_ref
+
+ @bom_ref.setter
+ def bom_ref(self, bom_ref: BomRef) -> None:
+ self._bom_ref = bom_ref
+
+ @property
+ @serializable.type_mapping(_SubjectsSerializationHelper)
+ @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'subject')
+ @serializable.xml_sequence(1)
+ def subjects(self) -> 'SortedSet[_AnnotationSubject]':
+ """
+ The object in the BOM identified by its bom-ref.
+ """
+ return self._subjects
+
+ @subjects.setter
+ def subjects(self, subjects: Iterable[Union[BomRef, '_AnnotationSubject']]) -> None:
+ self._subjects = SortedSet(
+ s if isinstance(s, _AnnotationSubject) else _AnnotationSubject(ref=s)
+ for s in subjects
+ )
+
+ @property
+ @serializable.xml_sequence(2)
+ def annotator(self) -> Annotator:
+ """
+ The organization, person, component, or service which created the textual content of the annotation.
+ """
+ return self._annotator
+
+ @annotator.setter
+ def annotator(self, annotator: Annotator) -> None:
+ self._annotator = annotator
+
+ @property
+ @serializable.type_mapping(XsdDateTime)
+ @serializable.xml_sequence(3)
+ def timestamp(self) -> datetime:
+ """
+ The date and time (timestamp) when the annotation was created.
+ """
+ return self._timestamp
+
+ @timestamp.setter
+ def timestamp(self, timestamp: datetime) -> None:
+ self._timestamp = timestamp
+
+ @property
+ @serializable.xml_sequence(4)
+ def text(self) -> str:
+ """
+ The textual content of the annotation.
+ """
+ return self._text
+
+ @text.setter
+ def text(self, text: str) -> None:
+ self._text = text
+
+ def __comparable_tuple(self) -> _ComparableTuple:
+ return _ComparableTuple((
+ self.bom_ref.value, _ComparableTuple(self.subjects),
+ self.annotator, self.timestamp, self.text
+ ))
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, Annotation):
+ return self.__comparable_tuple() == other.__comparable_tuple()
+ return False
+
+ def __lt__(self, other: Any) -> bool:
+ if isinstance(other, Annotation):
+ 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/cyclonedx/model/bom.py b/cyclonedx/model/bom.py
index 7cb0081e..44475629 100644
--- a/cyclonedx/model/bom.py
+++ b/cyclonedx/model/bom.py
@@ -43,6 +43,7 @@
)
from ..serialization import UrnUuidHelper
from . import _BOM_LINK_PREFIX, ExternalReference, Property
+from .annotation import Annotation
from .bom_ref import BomRef
from .component import Component
from .contact import OrganizationalContact, OrganizationalEntity
@@ -444,6 +445,7 @@ def __init__(
vulnerabilities: Optional[Iterable[Vulnerability]] = None,
properties: Optional[Iterable[Property]] = None,
definitions: Optional[Definitions] = None,
+ annotations: Optional[Iterable[Annotation]] = None,
) -> None:
"""
Create a new Bom that you can manually/programmatically add data to later.
@@ -461,6 +463,7 @@ def __init__(
self.dependencies = dependencies or []
self.properties = properties or []
self.definitions = definitions or Definitions()
+ self.annotations = annotations or []
@property
@serializable.type_mapping(UrnUuidHelper)
@@ -655,16 +658,25 @@ def vulnerabilities(self) -> 'SortedSet[Vulnerability]':
def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None:
self._vulnerabilities = SortedSet(vulnerabilities)
- # @property
- # ...
- # @serializable.view(SchemaVersion1Dot5)
- # @serializable.xml_sequence(9)
- # def annotations(self) -> ...:
- # ... # TODO Since CDX 1.5
- #
- # @annotations.setter
- # def annotations(self, ...) -> None:
- # ... # TODO Since CDX 1.5
+ @property
+ @serializable.view(SchemaVersion1Dot5)
+ @serializable.view(SchemaVersion1Dot6)
+ @serializable.view(SchemaVersion1Dot7)
+ @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'annotation')
+ @serializable.xml_sequence(90)
+ def annotations(self) -> 'SortedSet[Annotation]':
+ """
+ Comments, notes, explanations, or similar textual content which provides additional context
+ to the object(s) being annotated.
+
+ Returns:
+ Set of `Annotation`
+ """
+ return self._annotations
+
+ @annotations.setter
+ def annotations(self, annotations: Iterable[Annotation]) -> None:
+ self._annotations = SortedSet(annotations)
# @property
# ...
diff --git a/tests/_data/models.py b/tests/_data/models.py
index e2052878..f52ee727 100644
--- a/tests/_data/models.py
+++ b/tests/_data/models.py
@@ -42,6 +42,7 @@
Property,
XsUri,
)
+from cyclonedx.model.annotation import Annotation, Annotator
from cyclonedx.model.bom import Bom, BomMetaData, DistributionConstraints, TlpClassification
from cyclonedx.model.bom_ref import BomRef
from cyclonedx.model.component import (
@@ -1679,6 +1680,27 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom:
# ---
+def get_bom_with_annotations() -> Bom:
+ annotator = Annotator(
+ organization=OrganizationalEntity(
+ name='Acme, Inc.',
+ urls=[XsUri('https://example.com')]
+ )
+ )
+
+ annotation = Annotation(
+ bom_ref='annotation-1',
+ subjects=[BomRef('subject-1')],
+ annotator=annotator,
+ timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
+ text='This is an annotation.'
+ )
+
+ return _make_bom(annotations=[annotation])
+
+
+# ---
+
all_get_bom_funct_valid = tuple(
(n, f) for n, f in getmembers(sys.modules[__name__], isfunction)
if n.startswith('get_bom_') and not n.endswith('_invalid')
@@ -1726,4 +1748,5 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom:
get_bom_with_distribution_constraints,
get_bom_with_definitions_standards,
get_bom_with_definitions_and_detailed_standards,
+ get_bom_with_annotations,
}
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.0.xml.bin
new file mode 100644
index 00000000..acb06612
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.0.xml.bin
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.1.xml.bin
new file mode 100644
index 00000000..55ef5cda
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.1.xml.bin
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.2.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.2.json.bin
new file mode 100644
index 00000000..8f473bd3
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.2.json.bin
@@ -0,0 +1,10 @@
+{
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.2"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.2.xml.bin
new file mode 100644
index 00000000..df1938ec
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.2.xml.bin
@@ -0,0 +1,6 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.3.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.3.json.bin
new file mode 100644
index 00000000..02943890
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.3.json.bin
@@ -0,0 +1,10 @@
+{
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.3"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.3.xml.bin
new file mode 100644
index 00000000..8341ff60
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.3.xml.bin
@@ -0,0 +1,6 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.4.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.4.json.bin
new file mode 100644
index 00000000..48f1745d
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.4.json.bin
@@ -0,0 +1,10 @@
+{
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.4"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.4.xml.bin
new file mode 100644
index 00000000..d0a7d4c9
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.4.xml.bin
@@ -0,0 +1,6 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.5.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.5.json.bin
new file mode 100644
index 00000000..32115b8a
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.5.json.bin
@@ -0,0 +1,38 @@
+{
+ "annotations": [
+ {
+ "annotator": {
+ "organization": {
+ "name": "Acme, Inc.",
+ "url": [
+ "https://example.com"
+ ]
+ }
+ },
+ "bom-ref": "annotation-1",
+ "subjects": [
+ "subject-1"
+ ],
+ "text": "This is an annotation.",
+ "timestamp": "2024-01-01T12:00:00+00:00"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "properties": [
+ {
+ "name": "key1",
+ "value": "val1"
+ },
+ {
+ "name": "key2",
+ "value": "val2"
+ }
+ ],
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.5.xml.bin
new file mode 100644
index 00000000..3a00ca8a
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.5.xml.bin
@@ -0,0 +1,25 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+ val1
+ val2
+
+
+
+
+
+
+
+
+ Acme, Inc.
+ https://example.com
+
+
+ 2024-01-01T12:00:00+00:00
+ This is an annotation.
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.6.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.6.json.bin
new file mode 100644
index 00000000..383c6ff9
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.6.json.bin
@@ -0,0 +1,38 @@
+{
+ "annotations": [
+ {
+ "annotator": {
+ "organization": {
+ "name": "Acme, Inc.",
+ "url": [
+ "https://example.com"
+ ]
+ }
+ },
+ "bom-ref": "annotation-1",
+ "subjects": [
+ "subject-1"
+ ],
+ "text": "This is an annotation.",
+ "timestamp": "2024-01-01T12:00:00+00:00"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "properties": [
+ {
+ "name": "key1",
+ "value": "val1"
+ },
+ {
+ "name": "key2",
+ "value": "val2"
+ }
+ ],
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.6"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.6.xml.bin
new file mode 100644
index 00000000..6aa0d772
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.6.xml.bin
@@ -0,0 +1,25 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+ val1
+ val2
+
+
+
+
+
+
+
+
+ Acme, Inc.
+ https://example.com
+
+
+ 2024-01-01T12:00:00+00:00
+ This is an annotation.
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.7.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.7.json.bin
new file mode 100644
index 00000000..8d1b988c
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.7.json.bin
@@ -0,0 +1,38 @@
+{
+ "annotations": [
+ {
+ "annotator": {
+ "organization": {
+ "name": "Acme, Inc.",
+ "url": [
+ "https://example.com"
+ ]
+ }
+ },
+ "bom-ref": "annotation-1",
+ "subjects": [
+ "subject-1"
+ ],
+ "text": "This is an annotation.",
+ "timestamp": "2024-01-01T12:00:00+00:00"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "properties": [
+ {
+ "name": "key1",
+ "value": "val1"
+ },
+ {
+ "name": "key2",
+ "value": "val2"
+ }
+ ],
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.7"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.7.xml.bin
new file mode 100644
index 00000000..726e3b92
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_annotations-1.7.xml.bin
@@ -0,0 +1,25 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+ val1
+ val2
+
+
+
+
+
+
+
+
+ Acme, Inc.
+ https://example.com
+
+
+ 2024-01-01T12:00:00+00:00
+ This is an annotation.
+
+
+
diff --git a/tests/test_model_annotation.py b/tests/test_model_annotation.py
new file mode 100644
index 00000000..7e357014
--- /dev/null
+++ b/tests/test_model_annotation.py
@@ -0,0 +1,67 @@
+# 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 datetime import datetime, timezone
+from unittest import TestCase
+
+from cyclonedx.model.annotation import Annotation, Annotator
+from cyclonedx.model.bom_ref import BomRef
+from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity
+from tests import reorder
+
+_TIMESTAMP = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+
+
+class TestModelAnnotator(TestCase):
+
+ def test_invalid_no_args(self) -> None:
+ with self.assertRaises(ValueError):
+ Annotator()
+
+ def test_invalid_multiple_args(self) -> None:
+ with self.assertRaises(ValueError):
+ Annotator(
+ organization=OrganizationalEntity(name='A'),
+ individual=OrganizationalContact(name='B'),
+ )
+
+ def test_sort(self) -> None:
+ # expected sort order: (organization, individual, component, service)
+ expected_order = [0, 2, 1]
+ annotators = [
+ Annotator(organization=OrganizationalEntity(name='Acme')),
+ Annotator(individual=OrganizationalContact(name='Zoe')),
+ Annotator(individual=OrganizationalContact(name='Alice')),
+ ]
+ self.assertListEqual(sorted(annotators), reorder(annotators, expected_order))
+
+
+class TestModelAnnotation(TestCase):
+
+ def test_sort(self) -> None:
+ # expected sort order: (bom_ref.value, subjects, annotator, timestamp, text)
+ annotator = Annotator(organization=OrganizationalEntity(name='Acme'))
+ expected_order = [0, 2, 1]
+ annotations = [
+ Annotation(bom_ref='a', subjects=[BomRef('s')], annotator=annotator,
+ timestamp=_TIMESTAMP, text='alpha'),
+ Annotation(bom_ref='c', subjects=[BomRef('s')], annotator=annotator,
+ timestamp=_TIMESTAMP, text='gamma'),
+ Annotation(bom_ref='b', subjects=[BomRef('s')], annotator=annotator,
+ timestamp=_TIMESTAMP, text='beta'),
+ ]
+ self.assertListEqual(sorted(annotations), reorder(annotations, expected_order))