From b9285d4a083bcf08a266b4c3201142ab9af8e89f Mon Sep 17 00:00:00 2001 From: saquibsaifee Date: Tue, 1 Sep 2026 12:47:05 -0400 Subject: [PATCH 1/4] feat: add support for Bom.annotations according to CycloneDX 1.5 Adds Annotation and Annotator models and integrates them into Bom.annotations. The property is gated with @serializable.view for schema versions 1.5, 1.6, and 1.7. - cyclonedx/model/annotation.py: new Annotator and Annotation classes - cyclonedx/model/bom.py: replaces the TODO placeholder with a live annotations property - tests/test_model_annotation.py: 8 unit tests, 100% coverage - tests/_data/models.py: get_bom_with_annotations() fixture Closes #578 (Missing annotations on Bom) Signed-off-by: saquibsaifee --- cyclonedx/model/annotation.py | 188 +++++++++++++++++++++++++++++++++ cyclonedx/model/bom.py | 32 ++++-- tests/_data/models.py | 24 +++++ tests/test_model_annotation.py | 168 +++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 cyclonedx/model/annotation.py create mode 100644 tests/test_model_annotation.py diff --git a/cyclonedx/model/annotation.py b/cyclonedx/model/annotation.py new file mode 100644 index 00000000..010cb3a3 --- /dev/null +++ b/cyclonedx/model/annotation.py @@ -0,0 +1,188 @@ +# 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 Optional + +import py_serializable as serializable +from py_serializable.helpers import XsdDateTime +from sortedcontainers import SortedSet + +from .bom_ref import BomRef +from .component import Component +from .contact import OrganizationalContact, OrganizationalEntity +from .service import Service + + +@serializable.serializable_class +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 __eq__(self, other: object) -> bool: + if isinstance(other, Annotator): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.organization, self.individual, self.component, self.service)) + + +@serializable.serializable_class +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[str] = None, + ) -> None: + self.bom_ref = BomRef(value=bom_ref) if bom_ref else BomRef() + self.subjects = subjects + self.annotator = annotator + self.timestamp = timestamp + self.text = text + + @property + @serializable.xml_attribute() + @serializable.type_mapping(serializable.helpers.BaseHelper) + 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.xml_array(serializable.XmlArraySerializationType.FLAT, 'subject') + @serializable.xml_sequence(1) + def subjects(self) -> 'SortedSet[BomRef]': + """ + The object in the BOM identified by its bom-ref. + """ + return self._subjects + + @subjects.setter + def subjects(self, subjects: Iterable[BomRef]) -> None: + self._subjects = SortedSet(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 __eq__(self, other: object) -> bool: + if isinstance(other, Annotation): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.bom_ref, tuple(self.subjects), self.annotator, self.timestamp, self.text)) 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..d48b5512 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 ( @@ -1727,3 +1728,26 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: get_bom_with_definitions_standards, get_bom_with_definitions_and_detailed_standards, } + + +def get_bom_with_annotations() -> Bom: + bom = Bom() + bom.metadata.component = this_component() + + 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.' + ) + + bom.annotations = [annotation] + return bom diff --git a/tests/test_model_annotation.py b/tests/test_model_annotation.py new file mode 100644 index 00000000..6445a075 --- /dev/null +++ b/tests/test_model_annotation.py @@ -0,0 +1,168 @@ +# 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. + +import unittest +from datetime import datetime, timezone + +from cyclonedx.model.annotation import Annotation, Annotator +from cyclonedx.model.bom_ref import BomRef +from cyclonedx.model.component import Component +from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity +from cyclonedx.model.service import Service + + +class TestAnnotator(unittest.TestCase): + + def test_init_organization(self) -> None: + org = OrganizationalEntity(name='Acme, Inc.') + annotator = Annotator(organization=org) + self.assertEqual(annotator.organization, org) + self.assertIsNone(annotator.individual) + self.assertIsNone(annotator.component) + self.assertIsNone(annotator.service) + + # Test setter + org2 = OrganizationalEntity(name='Beta, Inc.') + annotator.organization = org2 + self.assertEqual(annotator.organization, org2) + + def test_init_individual(self) -> None: + contact = OrganizationalContact(name='John Doe') + annotator = Annotator(individual=contact) + self.assertEqual(annotator.individual, contact) + + # Test setter + contact2 = OrganizationalContact(name='Jane Doe') + annotator.individual = contact2 + self.assertEqual(annotator.individual, contact2) + + def test_init_component(self) -> None: + comp = Component(name='MyComponent') + annotator = Annotator(component=comp) + self.assertEqual(annotator.component, comp) + + # Test setter + comp2 = Component(name='OtherComponent') + annotator.component = comp2 + self.assertEqual(annotator.component, comp2) + + def test_init_service(self) -> None: + svc = Service(name='MyService') + annotator = Annotator(service=svc) + self.assertEqual(annotator.service, svc) + + # Test setter + svc2 = Service(name='OtherService') + annotator.service = svc2 + self.assertEqual(annotator.service, svc2) + + def test_init_invalid(self) -> None: + with self.assertRaises(ValueError): + Annotator() + + with self.assertRaises(ValueError): + Annotator(organization=OrganizationalEntity(name='A'), individual=OrganizationalContact(name='B')) + + def test_eq_and_hash(self) -> None: + org = OrganizationalEntity(name='Acme') + a1 = Annotator(organization=org) + a2 = Annotator(organization=org) + a3 = Annotator(individual=OrganizationalContact(name='John')) + + self.assertEqual(a1, a2) + self.assertNotEqual(a1, a3) + self.assertNotEqual(a1, 'NotAnAnnotator') + + self.assertEqual(hash(a1), hash(a2)) + self.assertNotEqual(hash(a1), hash(a3)) + + +class TestAnnotation(unittest.TestCase): + + def setUp(self) -> None: + self.annotator = Annotator(organization=OrganizationalEntity(name='Acme')) + self.timestamp = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + self.subjects = [BomRef(value='subject-1')] + + def test_init_basic(self) -> None: + annotation = Annotation( + bom_ref='anno-1', + subjects=self.subjects, + annotator=self.annotator, + timestamp=self.timestamp, + text='A test annotation' + ) + + self.assertEqual(annotation.bom_ref.value, 'anno-1') + self.assertEqual(list(annotation.subjects)[0].value, 'subject-1') + self.assertEqual(annotation.annotator, self.annotator) + self.assertEqual(annotation.timestamp, self.timestamp) + self.assertEqual(annotation.text, 'A test annotation') + + # Test setters + annotation.bom_ref = BomRef(value='anno-2') + self.assertEqual(annotation.bom_ref.value, 'anno-2') + + annotation.subjects = [BomRef(value='subject-2')] + self.assertEqual(list(annotation.subjects)[0].value, 'subject-2') + + a2 = Annotator(individual=OrganizationalContact(name='John')) + annotation.annotator = a2 + self.assertEqual(annotation.annotator, a2) + + t2 = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + annotation.timestamp = t2 + self.assertEqual(annotation.timestamp, t2) + + annotation.text = 'New text' + self.assertEqual(annotation.text, 'New text') + + def test_eq_and_hash(self) -> None: + a1 = Annotation( + bom_ref='anno-1', + subjects=self.subjects, + annotator=self.annotator, + timestamp=self.timestamp, + text='Text' + ) + + a2 = Annotation( + bom_ref='anno-1', + subjects=self.subjects, + annotator=self.annotator, + timestamp=self.timestamp, + text='Text' + ) + + a3 = Annotation( + bom_ref='anno-1', + subjects=self.subjects, + annotator=self.annotator, + timestamp=self.timestamp, + text='Different Text' + ) + + self.assertEqual(a1, a2) + self.assertNotEqual(a1, a3) + self.assertNotEqual(a1, 'NotAnAnnotation') + + self.assertEqual(hash(a1), hash(a2)) + self.assertNotEqual(hash(a1), hash(a3)) + + +if __name__ == '__main__': + unittest.main() From 5c892236d6017952ee0129662d685b3b95cfe4ef Mon Sep 17 00:00:00 2001 From: saquibsaifee Date: Tue, 1 Sep 2026 13:15:07 -0400 Subject: [PATCH 2/4] test: add integration test snapshots for Bom.annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire get_bom_with_annotations() into the integration test fixtures by moving it before the all_get_bom_funct_* tuples so it is auto-discovered - Add it to all_get_bom_funct_with_incomplete_deps (no dep graph set) - Fix serialization: bom-ref uses json_name/xml_name decorators and bom_ref_from_str to avoid double-wrapping on deserialization - Fix subjects: use _AnnotationSubject XML wrapper with a custom _SubjectsSerializationHelper that handles both JSON (plain strings) and XML ( elements) correctly - Generate all 14 snapshot binaries (JSON + XML for schema 1.0–1.7) Signed-off-by: saquibsaifee --- cyclonedx/model/annotation.py | 99 +++++++++++++++++-- tests/_data/models.py | 45 +++++---- .../get_bom_with_annotations-1.0.xml.bin | 4 + .../get_bom_with_annotations-1.1.xml.bin | 4 + .../get_bom_with_annotations-1.2.json.bin | 10 ++ .../get_bom_with_annotations-1.2.xml.bin | 6 ++ .../get_bom_with_annotations-1.3.json.bin | 10 ++ .../get_bom_with_annotations-1.3.xml.bin | 6 ++ .../get_bom_with_annotations-1.4.json.bin | 10 ++ .../get_bom_with_annotations-1.4.xml.bin | 6 ++ .../get_bom_with_annotations-1.5.json.bin | 38 +++++++ .../get_bom_with_annotations-1.5.xml.bin | 25 +++++ .../get_bom_with_annotations-1.6.json.bin | 38 +++++++ .../get_bom_with_annotations-1.6.xml.bin | 25 +++++ .../get_bom_with_annotations-1.7.json.bin | 38 +++++++ .../get_bom_with_annotations-1.7.xml.bin | 25 +++++ tests/test_model_annotation.py | 4 +- 17 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.0.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.1.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.2.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.2.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.3.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.3.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.4.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.4.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.5.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.5.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.6.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.6.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.7.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_annotations-1.7.xml.bin diff --git a/cyclonedx/model/annotation.py b/cyclonedx/model/annotation.py index 010cb3a3..359f9e73 100644 --- a/cyclonedx/model/annotation.py +++ b/cyclonedx/model/annotation.py @@ -17,19 +17,92 @@ from collections.abc import Iterable from datetime import datetime -from typing import Optional +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 ..exception.serialization import SerializationOfUnexpectedValueException from .bom_ref import BomRef from .component import Component from .contact import OrganizationalContact, OrganizationalEntity from .service import Service -@serializable.serializable_class +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[BomRef]: + subjects: set[BomRef] = set() + if isinstance(o, list): + for v in o: + subjects.add(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. @@ -95,7 +168,7 @@ def __hash__(self) -> int: return hash((self.organization, self.individual, self.component, self.service)) -@serializable.serializable_class +@serializable.serializable_class(ignore_unknown_during_deserialization=True) class Annotation: """ A comment, note, explanation, or similar textual content which provides additional context @@ -108,17 +181,19 @@ def __init__( annotator: Annotator, timestamp: datetime, text: str, - bom_ref: Optional[str] = None, + bom_ref: Optional[Union[str, BomRef]] = None, ) -> None: - self.bom_ref = BomRef(value=bom_ref) if bom_ref else BomRef() + 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.type_mapping(serializable.helpers.BaseHelper) + @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. @@ -130,17 +205,21 @@ def bom_ref(self, bom_ref: BomRef) -> None: self._bom_ref = bom_ref @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'subject') + @serializable.type_mapping(_SubjectsSerializationHelper) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'subject') @serializable.xml_sequence(1) - def subjects(self) -> 'SortedSet[BomRef]': + 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[BomRef]) -> None: - self._subjects = SortedSet(subjects) + 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) diff --git a/tests/_data/models.py b/tests/_data/models.py index d48b5512..f52ee727 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -1680,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') @@ -1727,27 +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, } - - -def get_bom_with_annotations() -> Bom: - bom = Bom() - bom.metadata.component = this_component() - - 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.' - ) - - bom.annotations = [annotation] - return bom 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 index 6445a075..68a0e906 100644 --- a/tests/test_model_annotation.py +++ b/tests/test_model_annotation.py @@ -108,7 +108,7 @@ def test_init_basic(self) -> None: ) self.assertEqual(annotation.bom_ref.value, 'anno-1') - self.assertEqual(list(annotation.subjects)[0].value, 'subject-1') + self.assertEqual(str(list(annotation.subjects)[0]), 'subject-1') self.assertEqual(annotation.annotator, self.annotator) self.assertEqual(annotation.timestamp, self.timestamp) self.assertEqual(annotation.text, 'A test annotation') @@ -118,7 +118,7 @@ def test_init_basic(self) -> None: self.assertEqual(annotation.bom_ref.value, 'anno-2') annotation.subjects = [BomRef(value='subject-2')] - self.assertEqual(list(annotation.subjects)[0].value, 'subject-2') + self.assertEqual(str(list(annotation.subjects)[0]), 'subject-2') a2 = Annotator(individual=OrganizationalContact(name='John')) annotation.annotator = a2 From 2f248627329384d12c7016a9d8b0ca71483a533d Mon Sep 17 00:00:00 2001 From: saquibsaifee Date: Tue, 1 Sep 2026 13:38:38 -0400 Subject: [PATCH 3/4] fix: add __lt__ and __repr__ to Annotation and Annotator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both classes are stored in SortedSet so they require __lt__. Adopt the _ComparableTuple pattern used by Vulnerability and Service for __eq__, __lt__, and __hash__ consistency. Also align _SubjectsSerializationHelper.deserialize return type with xml_denormalize — both now return set[_AnnotationSubject]. Signed-off-by: saquibsaifee --- cyclonedx/model/annotation.py | 42 +++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/cyclonedx/model/annotation.py b/cyclonedx/model/annotation.py index 359f9e73..d88bddbc 100644 --- a/cyclonedx/model/annotation.py +++ b/cyclonedx/model/annotation.py @@ -24,6 +24,7 @@ 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 @@ -46,11 +47,11 @@ def serialize(cls, o: Any) -> list[str]: f'Attempt to serialize a non-subjects collection: {o!r}') @classmethod - def deserialize(cls, o: Any) -> set[BomRef]: - subjects: set[BomRef] = set() + def deserialize(cls, o: Any) -> 'set[_AnnotationSubject]': + subjects: set[_AnnotationSubject] = set() if isinstance(o, list): for v in o: - subjects.add(BomRef(value=str(v))) + subjects.add(_AnnotationSubject(ref=BomRef(value=str(v)))) return subjects @classmethod @@ -159,13 +160,26 @@ def service(self) -> Optional[Service]: 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 hash(other) == hash(self) + 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.organization, self.individual, self.component, self.service)) + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' @serializable.serializable_class(ignore_unknown_during_deserialization=True) @@ -258,10 +272,24 @@ def text(self) -> str: 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 hash(other) == hash(self) + 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.bom_ref, tuple(self.subjects), self.annotator, self.timestamp, self.text)) + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' From 68db2e014913a75c38d3d8fa1b19ee5451a1eef9 Mon Sep 17 00:00:00 2001 From: saquibsaifee Date: Tue, 1 Sep 2026 15:07:28 -0400 Subject: [PATCH 4/4] test: align test_model_annotation with codebase patterns Replace getter/setter and eq/hash unit tests with sort and invalid-construction tests, matching the pattern used by all other test_model_*.py files. Signed-off-by: saquibsaifee --- tests/test_model_annotation.py | 175 +++++++-------------------------- 1 file changed, 37 insertions(+), 138 deletions(-) diff --git a/tests/test_model_annotation.py b/tests/test_model_annotation.py index 68a0e906..7e357014 100644 --- a/tests/test_model_annotation.py +++ b/tests/test_model_annotation.py @@ -15,154 +15,53 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. -import unittest 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.component import Component from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity -from cyclonedx.model.service import Service +from tests import reorder +_TIMESTAMP = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) -class TestAnnotator(unittest.TestCase): - def test_init_organization(self) -> None: - org = OrganizationalEntity(name='Acme, Inc.') - annotator = Annotator(organization=org) - self.assertEqual(annotator.organization, org) - self.assertIsNone(annotator.individual) - self.assertIsNone(annotator.component) - self.assertIsNone(annotator.service) +class TestModelAnnotator(TestCase): - # Test setter - org2 = OrganizationalEntity(name='Beta, Inc.') - annotator.organization = org2 - self.assertEqual(annotator.organization, org2) - - def test_init_individual(self) -> None: - contact = OrganizationalContact(name='John Doe') - annotator = Annotator(individual=contact) - self.assertEqual(annotator.individual, contact) - - # Test setter - contact2 = OrganizationalContact(name='Jane Doe') - annotator.individual = contact2 - self.assertEqual(annotator.individual, contact2) - - def test_init_component(self) -> None: - comp = Component(name='MyComponent') - annotator = Annotator(component=comp) - self.assertEqual(annotator.component, comp) - - # Test setter - comp2 = Component(name='OtherComponent') - annotator.component = comp2 - self.assertEqual(annotator.component, comp2) - - def test_init_service(self) -> None: - svc = Service(name='MyService') - annotator = Annotator(service=svc) - self.assertEqual(annotator.service, svc) - - # Test setter - svc2 = Service(name='OtherService') - annotator.service = svc2 - self.assertEqual(annotator.service, svc2) - - def test_init_invalid(self) -> None: + 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_eq_and_hash(self) -> None: - org = OrganizationalEntity(name='Acme') - a1 = Annotator(organization=org) - a2 = Annotator(organization=org) - a3 = Annotator(individual=OrganizationalContact(name='John')) - - self.assertEqual(a1, a2) - self.assertNotEqual(a1, a3) - self.assertNotEqual(a1, 'NotAnAnnotator') - - self.assertEqual(hash(a1), hash(a2)) - self.assertNotEqual(hash(a1), hash(a3)) - - -class TestAnnotation(unittest.TestCase): - - def setUp(self) -> None: - self.annotator = Annotator(organization=OrganizationalEntity(name='Acme')) - self.timestamp = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) - self.subjects = [BomRef(value='subject-1')] - - def test_init_basic(self) -> None: - annotation = Annotation( - bom_ref='anno-1', - subjects=self.subjects, - annotator=self.annotator, - timestamp=self.timestamp, - text='A test annotation' - ) - - self.assertEqual(annotation.bom_ref.value, 'anno-1') - self.assertEqual(str(list(annotation.subjects)[0]), 'subject-1') - self.assertEqual(annotation.annotator, self.annotator) - self.assertEqual(annotation.timestamp, self.timestamp) - self.assertEqual(annotation.text, 'A test annotation') - - # Test setters - annotation.bom_ref = BomRef(value='anno-2') - self.assertEqual(annotation.bom_ref.value, 'anno-2') - - annotation.subjects = [BomRef(value='subject-2')] - self.assertEqual(str(list(annotation.subjects)[0]), 'subject-2') - - a2 = Annotator(individual=OrganizationalContact(name='John')) - annotation.annotator = a2 - self.assertEqual(annotation.annotator, a2) - - t2 = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) - annotation.timestamp = t2 - self.assertEqual(annotation.timestamp, t2) - - annotation.text = 'New text' - self.assertEqual(annotation.text, 'New text') - - def test_eq_and_hash(self) -> None: - a1 = Annotation( - bom_ref='anno-1', - subjects=self.subjects, - annotator=self.annotator, - timestamp=self.timestamp, - text='Text' - ) - - a2 = Annotation( - bom_ref='anno-1', - subjects=self.subjects, - annotator=self.annotator, - timestamp=self.timestamp, - text='Text' - ) - - a3 = Annotation( - bom_ref='anno-1', - subjects=self.subjects, - annotator=self.annotator, - timestamp=self.timestamp, - text='Different Text' - ) - - self.assertEqual(a1, a2) - self.assertNotEqual(a1, a3) - self.assertNotEqual(a1, 'NotAnAnnotation') - - self.assertEqual(hash(a1), hash(a2)) - self.assertNotEqual(hash(a1), hash(a3)) - - -if __name__ == '__main__': - unittest.main() + 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))