From 8a0c307933405c8a641f9aab857f9ef1fbf15bb3 Mon Sep 17 00:00:00 2001 From: Shaun Huynh Date: Mon, 16 Mar 2026 18:56:16 -0400 Subject: [PATCH 1/3] feat: license.licensing implementation Signed-off-by: Shaun Huynh --- cyclonedx/model/license.py | 350 +++++++++++++++++++++++++++++++++-- docs/schema-support.rst | 4 +- tests/test_model_license.py | 358 +++++++++++++++++++++++++++++++++++- 3 files changed, 693 insertions(+), 19 deletions(-) diff --git a/cyclonedx/model/license.py b/cyclonedx/model/license.py index 9d32160e2..0030c8c8e 100644 --- a/cyclonedx/model/license.py +++ b/cyclonedx/model/license.py @@ -20,6 +20,8 @@ License related things """ +from collections.abc import Iterable +from datetime import datetime from enum import Enum from json import loads as json_loads from typing import TYPE_CHECKING, Any, Optional, Union @@ -36,6 +38,7 @@ from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6, SchemaVersion1Dot7 from . import AttachedText, XsUri from .bom_ref import BomRef +from .contact import OrganizationalContact, OrganizationalEntity @serializable.serializable_enum @@ -66,6 +69,319 @@ class LicenseAcknowledgement(str, Enum): """ +@serializable.serializable_enum +class LicenseType(str, Enum): + """ + This is our internal representation of the `licenseTypeEnumeration` ENUM type + within the CycloneDX standard. + + .. note:: + Introduced in CycloneDX v1.5 + + .. note:: + See the CycloneDX Schema: + https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_license_licensing_licenseTypes + """ + + ACADEMIC = 'academic' + APPLIANCE = 'appliance' + CLIENT_ACCESS = 'client-access' + CONCURRENT_USER = 'concurrent-user' + CORE_POINTS = 'core-points' + CUSTOM_METRIC = 'custom-metric' + DEVICE = 'device' + EVALUATION = 'evaluation' + NAMED_USER = 'named-user' + NODE_LOCKED = 'node-locked' + OEM = 'oem' + PERPETUAL = 'perpetual' + PROCESSOR_POINTS = 'processor-points' + SUBSCRIPTION = 'subscription' + USER = 'user' + OTHER = 'other' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class LicenseEntity: + """ + This is our internal representation of the licensor/licensee/purchaser type + within the CycloneDX standard. + + Exactly one of ``organization`` or ``individual`` MUST be provided. + + .. note:: + Introduced in CycloneDX v1.5 + + .. note:: + See the CycloneDX Schema definition: + https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_license_licensing_licensor + """ + + def __init__( + self, *, + organization: Optional[OrganizationalEntity] = None, + individual: Optional[OrganizationalContact] = None, + ) -> None: + if not organization and not individual: + raise MutuallyExclusivePropertiesException( + 'Either `organization` or `individual` MUST be supplied' + ) + if organization and individual: + raise MutuallyExclusivePropertiesException( + 'Only one of `organization` or `individual` MUST be supplied - not both' + ) + self._organization = organization + self._individual = individual + + @property + @serializable.xml_sequence(1) + def organization(self) -> Optional[OrganizationalEntity]: + """ + The organization. + + Returns: + `OrganizationalEntity` or `None` + """ + return self._organization + + @organization.setter + def organization(self, organization: Optional[OrganizationalEntity]) -> None: + self._organization = organization + if organization is not None: + self._individual = None + + @property + @serializable.xml_sequence(2) + def individual(self) -> Optional[OrganizationalContact]: + """ + The individual. + + Returns: + `OrganizationalContact` or `None` + """ + return self._individual + + @individual.setter + def individual(self, individual: Optional[OrganizationalContact]) -> None: + self._individual = individual + if individual is not None: + self._organization = None + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self._organization, self._individual, + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, LicenseEntity): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, LicenseEntity): + 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 Licensing: + """ + This is our internal representation of the `licensingType` complex type + within the CycloneDX standard. + + Licensing details describing the licensor/licensee, license type, renewal + and expiration dates, and other important metadata. + + .. note:: + Introduced in CycloneDX v1.5 + + .. note:: + See the CycloneDX Schema definition: + https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_license_licensing + """ + + def __init__( + self, *, + alt_ids: Optional[Iterable[str]] = None, + licensor: Optional[LicenseEntity] = None, + licensee: Optional[LicenseEntity] = None, + purchaser: Optional[LicenseEntity] = None, + purchase_order: Optional[str] = None, + license_types: Optional[Iterable[LicenseType]] = None, + last_renewal: Optional[datetime] = None, + expiration: Optional[datetime] = None, + ) -> None: + self.alt_ids = alt_ids or [] + self.licensor = licensor + self.licensee = licensee + self.purchaser = purchaser + self.purchase_order = purchase_order + self.license_types = license_types or [] + self.last_renewal = last_renewal + self.expiration = expiration + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'altId') + @serializable.xml_sequence(1) + def alt_ids(self) -> 'SortedSet[str]': + """ + License identifiers that may be used to manage licenses and their lifecycle. + + Returns: + `SortedSet[str]` + """ + return self._alt_ids + + @alt_ids.setter + def alt_ids(self, alt_ids: Iterable[str]) -> None: + self._alt_ids = SortedSet(alt_ids) + + @property + @serializable.xml_sequence(2) + def licensor(self) -> Optional[LicenseEntity]: + """ + The individual or organization that grants a license to another individual or organization. + + Returns: + `LicenseEntity` or `None` + """ + return self._licensor + + @licensor.setter + def licensor(self, licensor: Optional[LicenseEntity]) -> None: + self._licensor = licensor + + @property + @serializable.xml_sequence(3) + def licensee(self) -> Optional[LicenseEntity]: + """ + The individual or organization for which a license was granted to. + + Returns: + `LicenseEntity` or `None` + """ + return self._licensee + + @licensee.setter + def licensee(self, licensee: Optional[LicenseEntity]) -> None: + self._licensee = licensee + + @property + @serializable.xml_sequence(4) + def purchaser(self) -> Optional[LicenseEntity]: + """ + The individual or organization that purchased the license. + + Returns: + `LicenseEntity` or `None` + """ + return self._purchaser + + @purchaser.setter + def purchaser(self, purchaser: Optional[LicenseEntity]) -> None: + self._purchaser = purchaser + + @property + @serializable.xml_sequence(5) + def purchase_order(self) -> Optional[str]: + """ + The purchase order identifier the purchaser sent to a supplier or vendor to + authorize a purchase. + + Returns: + `str` or `None` + """ + return self._purchase_order + + @purchase_order.setter + def purchase_order(self, purchase_order: Optional[str]) -> None: + self._purchase_order = purchase_order + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'licenseType') + @serializable.xml_sequence(6) + def license_types(self) -> 'SortedSet[LicenseType]': + """ + The type of license(s) that was granted to the licensee. + + Returns: + `SortedSet[LicenseType]` + """ + return self._license_types + + @license_types.setter + def license_types(self, license_types: Iterable[LicenseType]) -> None: + self._license_types = SortedSet(license_types) + + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.xml_sequence(7) + def last_renewal(self) -> Optional[datetime]: + """ + The timestamp indicating when the license was last renewed. For new purchases, this is + often the purchase or acquisition date. For non-perpetual licenses or subscriptions, this + is the timestamp of when the license was last renewed. + + Returns: + `datetime` or `None` + """ + return self._last_renewal + + @last_renewal.setter + def last_renewal(self, last_renewal: Optional[datetime]) -> None: + self._last_renewal = last_renewal + + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.xml_sequence(8) + def expiration(self) -> Optional[datetime]: + """ + The timestamp indicating when the current license expires (if applicable). + + Returns: + `datetime` or `None` + """ + return self._expiration + + @expiration.setter + def expiration(self, expiration: Optional[datetime]) -> None: + self._expiration = expiration + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + _ComparableTuple(self._alt_ids), + self._licensor, self._licensee, self._purchaser, + self._purchase_order, + _ComparableTuple(self._license_types), + self._last_renewal, self._expiration, + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Licensing): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Licensing): + 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( name='license', ignore_unknown_during_deserialization=True @@ -84,6 +400,7 @@ def __init__( bom_ref: Optional[Union[str, BomRef]] = None, id: Optional[str] = None, name: Optional[str] = None, text: Optional[AttachedText] = None, url: Optional[XsUri] = None, + licensing: Optional[Licensing] = None, acknowledgement: Optional[LicenseAcknowledgement] = None, ) -> None: if not id and not name: @@ -98,6 +415,7 @@ def __init__( self._name = name if not id else None self._text = text self._url = url + self._licensing = licensing self._acknowledgement = acknowledgement @property @@ -188,17 +506,24 @@ def url(self) -> Optional[XsUri]: def url(self, url: Optional[XsUri]) -> None: self._url = url - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.view(SchemaVersion1Dot6) - # @serializable.xml_sequence(5) - # def licensing(self) -> ...: - # ... # TODO since CDX1.5 - # - # @licensing.setter - # def licensing(self, ...) -> None: - # ... # TODO since CDX1.5 + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(5) + def licensing(self) -> Optional[Licensing]: + """ + Licensing details describing the licensor/licensee, license type, renewal and expiration + dates, and other important metadata. + + Returns: + `Licensing` or `None` + """ + return self._licensing + + @licensing.setter + def licensing(self, licensing: Optional[Licensing]) -> None: + self._licensing = licensing # @property # ... @@ -208,7 +533,7 @@ def url(self, url: Optional[XsUri]) -> None: # def properties(self) -> ...: # ... # TODO since CDX1.5 # - # @licensing.setter + # @properties.setter # def properties(self, ...) -> None: # ... # TODO since CDX1.5 @@ -244,6 +569,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self._id, self._name, self._url, self._text, + self._licensing, self._bom_ref.value, )) diff --git a/docs/schema-support.rst b/docs/schema-support.rst index fe37633dc..07a72f486 100644 --- a/docs/schema-support.rst +++ b/docs/schema-support.rst @@ -71,9 +71,9 @@ This is a snapshot. See the API docs for the latest state. +============================+===============+==============================================================================================+ | ``ComponentEvidence`` |Yes | Not currently supported: ``callstack``, ``identity``, ``occurrences``. | +----------------------------+---------------+----------------------------------------------------------------------------------------------+ -| ``DisjunctiveLicense`` |Yes | Not currently supported: ``@bom-ref``, ``licensing``, ``properties``. | +| ``DisjunctiveLicense`` |Yes | Not currently supported: ``properties``. | +----------------------------+---------------+----------------------------------------------------------------------------------------------+ -| ``LicenseExpression`` |Yes | Not currently supported: ``@bom-ref`` | +| ``LicenseExpression`` |Yes | | +----------------------------+---------------+----------------------------------------------------------------------------------------------+ | ``OrganizationalContact`` |Yes | Not currently supported: ``@bom-ref`` | +----------------------------+---------------+----------------------------------------------------------------------------------------------+ diff --git a/tests/test_model_license.py b/tests/test_model_license.py index 11443e485..29de5c6ef 100644 --- a/tests/test_model_license.py +++ b/tests/test_model_license.py @@ -16,13 +16,22 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. +from datetime import datetime, timezone from random import shuffle from unittest import TestCase from unittest.mock import MagicMock from cyclonedx.exception.model import MutuallyExclusivePropertiesException from cyclonedx.model import AttachedText, XsUri -from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression +from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity +from cyclonedx.model.license import ( + DisjunctiveLicense, + LicenseAcknowledgement, + LicenseEntity, + LicenseExpression, + LicenseType, + Licensing, +) from tests import reorder @@ -30,11 +39,13 @@ class TestModelDisjunctiveLicense(TestCase): def test_create_complete_id(self) -> None: text = MagicMock(spec=AttachedText) url = MagicMock(spec=XsUri) - license = DisjunctiveLicense(id='foo', text=text, url=url) + licensing = Licensing(purchase_order='PO-1') + license = DisjunctiveLicense(id='foo', text=text, url=url, licensing=licensing) self.assertEqual('foo', license.id) self.assertIsNone(license.name) self.assertIs(text, license.text) self.assertIs(url, license.url) + self.assertIs(licensing, license.licensing) def test_update_id_name(self) -> None: license = DisjunctiveLicense(id='foo') @@ -47,11 +58,13 @@ def test_update_id_name(self) -> None: def test_create_complete_named(self) -> None: text = MagicMock(spec=AttachedText) url = MagicMock(spec=XsUri) - license = DisjunctiveLicense(name='foo', text=text, url=url) + licensing = Licensing(purchase_order='PO-2') + license = DisjunctiveLicense(name='foo', text=text, url=url, licensing=licensing) self.assertIsNone(license.id) self.assertEqual('foo', license.name) self.assertIs(text, license.text) self.assertIs(url, license.url) + self.assertIs(licensing, license.licensing) def test_update_name_id(self) -> None: license = DisjunctiveLicense(name='foo') @@ -73,6 +86,21 @@ def test_prefers_id_over_name(self) -> None: self.assertEqual('foo', license.id) self.assertEqual(None, license.name) + def test_licensing_none_by_default(self) -> None: + license = DisjunctiveLicense(id='MIT') + self.assertIsNone(license.licensing) + + def test_licensing_setter(self) -> None: + license = DisjunctiveLicense(id='MIT') + licensing = Licensing( + purchase_order='PO-SET', + licensor=LicenseEntity(organization=OrganizationalEntity(name='Vendor')), + ) + license.licensing = licensing + self.assertIs(licensing, license.licensing) + license.licensing = None + self.assertIsNone(license.licensing) + def test_equal(self) -> None: a = DisjunctiveLicense(id='foo', name='bar') b = DisjunctiveLicense(id='foo', name='bar') @@ -81,6 +109,35 @@ def test_equal(self) -> None: self.assertNotEqual(a, c) self.assertNotEqual(a, 'foo') + def test_equal_different_licensing(self) -> None: + a = DisjunctiveLicense(id='MIT', licensing=Licensing(purchase_order='PO-1')) + b = DisjunctiveLicense(id='MIT', licensing=Licensing(purchase_order='PO-2')) + self.assertNotEqual(a, b) + + def test_equal_different_acknowledgement(self) -> None: + a = DisjunctiveLicense(id='MIT', acknowledgement=LicenseAcknowledgement.DECLARED) + b = DisjunctiveLicense(id='MIT', acknowledgement=LicenseAcknowledgement.CONCLUDED) + self.assertNotEqual(a, b) + + def test_hash(self) -> None: + text = MagicMock(spec=AttachedText) + url = MagicMock(spec=XsUri) + licensing = Licensing(purchase_order='PO-1') + kwargs = dict( + id='foo', text=text, url=url, + licensing=licensing, + acknowledgement=LicenseAcknowledgement.DECLARED, + bom_ref='ref-hash', + ) + a = DisjunctiveLicense(**kwargs) + b = DisjunctiveLicense(**kwargs) + self.assertEqual(hash(a), hash(b)) + + def test_hash_different_licensing(self) -> None: + a = DisjunctiveLicense(id='MIT', licensing=Licensing(purchase_order='PO-1')) + b = DisjunctiveLicense(id='MIT', licensing=Licensing(purchase_order='PO-2')) + self.assertNotEqual(hash(a), hash(b)) + class TestModelLicenseExpression(TestCase): def test_create(self) -> None: @@ -94,13 +151,304 @@ def test_update(self) -> None: self.assertEqual('bar', license.value) def test_equal(self) -> None: - a = LicenseExpression('foo') - b = LicenseExpression('foo') + kwargs = dict( + acknowledgement=LicenseAcknowledgement.DECLARED, + bom_ref='ref-eq', + ) + a = LicenseExpression('foo', **kwargs) + b = LicenseExpression('foo', **kwargs) c = LicenseExpression('bar') self.assertEqual(a, b) self.assertNotEqual(a, c) self.assertNotEqual(a, 'foo') + def test_equal_different_acknowledgement(self) -> None: + a = LicenseExpression('MIT', acknowledgement=LicenseAcknowledgement.DECLARED) + b = LicenseExpression('MIT', acknowledgement=LicenseAcknowledgement.CONCLUDED) + self.assertNotEqual(a, b) + + def test_hash(self) -> None: + kwargs = dict( + acknowledgement=LicenseAcknowledgement.DECLARED, + bom_ref='ref-hash', + ) + a = LicenseExpression('foo', **kwargs) + b = LicenseExpression('foo', **kwargs) + self.assertEqual(hash(a), hash(b)) + + def test_hash_different_acknowledgement(self) -> None: + a = LicenseExpression('MIT', acknowledgement=LicenseAcknowledgement.DECLARED) + b = LicenseExpression('MIT', acknowledgement=LicenseAcknowledgement.CONCLUDED) + self.assertNotEqual(hash(a), hash(b)) + + +class TestModelLicenseEntity(TestCase): + + def test_create_with_organization(self) -> None: + org = OrganizationalEntity(name='Acme Inc') + holder = LicenseEntity(organization=org) + self.assertIs(org, holder.organization) + self.assertIsNone(holder.individual) + + def test_create_with_individual(self) -> None: + contact = OrganizationalContact(name='John Doe', email='john@example.com') + holder = LicenseEntity(individual=contact) + self.assertIsNone(holder.organization) + self.assertIs(contact, holder.individual) + + def test_throws_when_neither_provided(self) -> None: + with self.assertRaises(MutuallyExclusivePropertiesException): + LicenseEntity() + + def test_throws_when_both_provided(self) -> None: + with self.assertRaises(MutuallyExclusivePropertiesException): + LicenseEntity( + organization=OrganizationalEntity(name='Acme'), + individual=OrganizationalContact(name='John') + ) + + def test_setter_organization_clears_individual(self) -> None: + contact = OrganizationalContact(name='John') + holder = LicenseEntity(individual=contact) + org = OrganizationalEntity(name='Acme') + holder.organization = org + self.assertIs(org, holder.organization) + self.assertIsNone(holder.individual) + + def test_setter_individual_clears_organization(self) -> None: + org = OrganizationalEntity(name='Acme') + holder = LicenseEntity(organization=org) + contact = OrganizationalContact(name='John') + holder.individual = contact + self.assertIs(contact, holder.individual) + self.assertIsNone(holder.organization) + + def test_equal(self) -> None: + a = LicenseEntity(organization=OrganizationalEntity(name='Acme')) + b = LicenseEntity(organization=OrganizationalEntity(name='Acme')) + c = LicenseEntity(individual=OrganizationalContact(name='John')) + self.assertEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(a, 'foo') + + def test_hash(self) -> None: + a = LicenseEntity(organization=OrganizationalEntity(name='Acme')) + b = LicenseEntity(organization=OrganizationalEntity(name='Acme')) + self.assertEqual(hash(a), hash(b)) + + def test_hash_individual(self) -> None: + a = LicenseEntity(individual=OrganizationalContact(name='John', email='john@example.com')) + b = LicenseEntity(individual=OrganizationalContact(name='John', email='john@example.com')) + self.assertEqual(hash(a), hash(b)) + + def test_hash_different(self) -> None: + a = LicenseEntity(organization=OrganizationalEntity(name='Acme')) + b = LicenseEntity(individual=OrganizationalContact(name='John')) + self.assertNotEqual(hash(a), hash(b)) + + +class TestModelLicensing(TestCase): + + def test_create_minimal(self) -> None: + licensing = Licensing() + self.assertEqual(0, len(licensing.alt_ids)) + self.assertIsNone(licensing.licensor) + self.assertIsNone(licensing.licensee) + self.assertIsNone(licensing.purchaser) + self.assertIsNone(licensing.purchase_order) + self.assertEqual(0, len(licensing.license_types)) + self.assertIsNone(licensing.last_renewal) + self.assertIsNone(licensing.expiration) + + def test_create_complete(self) -> None: + licensor = LicenseEntity(organization=OrganizationalEntity(name='Acme Inc')) + licensee = LicenseEntity(organization=OrganizationalEntity(name='Example Co.')) + purchaser = LicenseEntity(individual=OrganizationalContact( + name='Samantha Wright', email='samantha.wright@example.com', phone='800-555-1212' + )) + last_renewal = datetime(2022, 4, 13, 20, 20, 39, tzinfo=timezone.utc) + expiration = datetime(2023, 4, 13, 20, 20, 39, tzinfo=timezone.utc) + + licensing = Licensing( + alt_ids=['acme', 'acme-license'], + licensor=licensor, + licensee=licensee, + purchaser=purchaser, + purchase_order='PO-12345', + license_types=[LicenseType.APPLIANCE], + last_renewal=last_renewal, + expiration=expiration, + ) + self.assertEqual(2, len(licensing.alt_ids)) + self.assertIn('acme', licensing.alt_ids) + self.assertIn('acme-license', licensing.alt_ids) + self.assertIs(licensor, licensing.licensor) + self.assertIs(licensee, licensing.licensee) + self.assertIs(purchaser, licensing.purchaser) + self.assertEqual('PO-12345', licensing.purchase_order) + self.assertIn(LicenseType.APPLIANCE, licensing.license_types) + self.assertEqual(last_renewal, licensing.last_renewal) + self.assertEqual(expiration, licensing.expiration) + + def test_setters(self) -> None: + licensing = Licensing() + licensor = LicenseEntity(organization=OrganizationalEntity(name='Acme Inc')) + licensee = LicenseEntity(individual=OrganizationalContact(name='Jane')) + purchaser = LicenseEntity(organization=OrganizationalEntity(name='BuyCo')) + last_renewal = datetime(2024, 1, 1, tzinfo=timezone.utc) + expiration = datetime(2025, 1, 1, tzinfo=timezone.utc) + + licensing.licensor = licensor + self.assertIs(licensor, licensing.licensor) + licensing.licensee = licensee + self.assertIs(licensee, licensing.licensee) + licensing.purchaser = purchaser + self.assertIs(purchaser, licensing.purchaser) + licensing.purchase_order = 'PO-SET' + self.assertEqual('PO-SET', licensing.purchase_order) + licensing.license_types = [LicenseType.SUBSCRIPTION, LicenseType.NAMED_USER] + self.assertIn(LicenseType.SUBSCRIPTION, licensing.license_types) + self.assertIn(LicenseType.NAMED_USER, licensing.license_types) + licensing.last_renewal = last_renewal + self.assertEqual(last_renewal, licensing.last_renewal) + licensing.expiration = expiration + self.assertEqual(expiration, licensing.expiration) + licensing.alt_ids = ['id-1', 'id-2'] + self.assertIn('id-1', licensing.alt_ids) + self.assertIn('id-2', licensing.alt_ids) + + def test_equal(self) -> None: + licensor = LicenseEntity(organization=OrganizationalEntity(name='Acme Inc')) + licensee = LicenseEntity(individual=OrganizationalContact(name='Jane')) + purchaser = LicenseEntity(organization=OrganizationalEntity(name='BuyCo')) + last_renewal = datetime(2022, 4, 13, 20, 20, 39, tzinfo=timezone.utc) + expiration = datetime(2023, 4, 13, 20, 20, 39, tzinfo=timezone.utc) + kwargs = dict( + alt_ids=['acme'], + licensor=licensor, + licensee=licensee, + purchaser=purchaser, + purchase_order='PO-1', + license_types=[LicenseType.PERPETUAL], + last_renewal=last_renewal, + expiration=expiration, + ) + a = Licensing(**kwargs) + b = Licensing(**kwargs) + c = Licensing(purchase_order='PO-2') + self.assertEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(a, 'foo') + + def test_equal_different_licensor(self) -> None: + a = Licensing(licensor=LicenseEntity(organization=OrganizationalEntity(name='Acme'))) + b = Licensing(licensor=LicenseEntity(organization=OrganizationalEntity(name='Other'))) + self.assertNotEqual(a, b) + + def test_equal_different_licensee(self) -> None: + a = Licensing(licensee=LicenseEntity(individual=OrganizationalContact(name='Alice'))) + b = Licensing(licensee=LicenseEntity(individual=OrganizationalContact(name='Bob'))) + self.assertNotEqual(a, b) + + def test_equal_different_purchaser(self) -> None: + a = Licensing(purchaser=LicenseEntity(organization=OrganizationalEntity(name='BuyCo'))) + b = Licensing(purchaser=LicenseEntity(organization=OrganizationalEntity(name='SellCo'))) + self.assertNotEqual(a, b) + + def test_equal_different_dates(self) -> None: + a = Licensing( + last_renewal=datetime(2022, 1, 1, tzinfo=timezone.utc), + expiration=datetime(2023, 1, 1, tzinfo=timezone.utc), + ) + b = Licensing( + last_renewal=datetime(2024, 1, 1, tzinfo=timezone.utc), + expiration=datetime(2025, 1, 1, tzinfo=timezone.utc), + ) + self.assertNotEqual(a, b) + + def test_hash(self) -> None: + licensor = LicenseEntity(organization=OrganizationalEntity(name='Acme Inc')) + licensee = LicenseEntity(individual=OrganizationalContact(name='Jane')) + purchaser = LicenseEntity(organization=OrganizationalEntity(name='BuyCo')) + last_renewal = datetime(2022, 4, 13, 20, 20, 39, tzinfo=timezone.utc) + expiration = datetime(2023, 4, 13, 20, 20, 39, tzinfo=timezone.utc) + kwargs = dict( + alt_ids=['acme'], + licensor=licensor, + licensee=licensee, + purchaser=purchaser, + purchase_order='PO-1', + license_types=[LicenseType.PERPETUAL], + last_renewal=last_renewal, + expiration=expiration, + ) + a = Licensing(**kwargs) + b = Licensing(**kwargs) + self.assertEqual(hash(a), hash(b)) + + def test_hash_different_licensor(self) -> None: + a = Licensing(licensor=LicenseEntity(organization=OrganizationalEntity(name='Acme'))) + b = Licensing(licensor=LicenseEntity(organization=OrganizationalEntity(name='Other'))) + self.assertNotEqual(hash(a), hash(b)) + + def test_repr(self) -> None: + licensing = Licensing(purchase_order='PO-REPR') + r = repr(licensing) + self.assertIn('Licensing', r) + self.assertIn('PO-REPR', r) + + +class TestModelDisjunctiveLicenseWithLicensing(TestCase): + + def test_create_with_licensing(self) -> None: + licensing = Licensing( + purchase_order='PO-123', + license_types=[LicenseType.SUBSCRIPTION], + licensor=LicenseEntity(organization=OrganizationalEntity(name='Acme Inc')), + licensee=LicenseEntity(individual=OrganizationalContact(name='Jane Doe')), + ) + dl = DisjunctiveLicense(name='Commercial', licensing=licensing) + self.assertIs(licensing, dl.licensing) + + def test_licensing_none_by_default(self) -> None: + dl = DisjunctiveLicense(id='MIT') + self.assertIsNone(dl.licensing) + + def test_setter(self) -> None: + dl = DisjunctiveLicense(id='MIT') + licensing = Licensing( + purchase_order='PO-999', + licensor=LicenseEntity(organization=OrganizationalEntity(name='Vendor')), + ) + dl.licensing = licensing + self.assertIs(licensing, dl.licensing) + dl.licensing = None + self.assertIsNone(dl.licensing) + + def test_equal_with_licensing(self) -> None: + licensing = Licensing( + purchase_order='PO-1', + licensor=LicenseEntity(organization=OrganizationalEntity(name='Acme')), + licensee=LicenseEntity(individual=OrganizationalContact(name='Bob')), + purchaser=LicenseEntity(organization=OrganizationalEntity(name='BuyCo')), + last_renewal=datetime(2022, 1, 1, tzinfo=timezone.utc), + expiration=datetime(2023, 1, 1, tzinfo=timezone.utc), + ) + a = DisjunctiveLicense(name='foo', licensing=licensing) + b = DisjunctiveLicense(name='foo', licensing=licensing) + c = DisjunctiveLicense(name='foo') + self.assertEqual(a, b) + self.assertNotEqual(a, c) + + def test_hash_with_licensing(self) -> None: + licensing = Licensing( + purchase_order='PO-1', + licensor=LicenseEntity(organization=OrganizationalEntity(name='Acme')), + ) + a = DisjunctiveLicense(name='foo', licensing=licensing, bom_ref='ref-wl') + b = DisjunctiveLicense(name='foo', licensing=licensing, bom_ref='ref-wl') + self.assertEqual(hash(a), hash(b)) + class TestModelLicense(TestCase): From 8df0b7af7419c2d4903dbae2bee8345da4b71609 Mon Sep 17 00:00:00 2001 From: Shaun Huynh Date: Tue, 17 Mar 2026 11:10:05 -0400 Subject: [PATCH 2/3] feat: extend model tests with new licensing. fixes URLs for license.py Signed-off-by: Shaun Huynh --- cyclonedx/model/license.py | 6 ++--- tests/_data/models.py | 50 +++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/cyclonedx/model/license.py b/cyclonedx/model/license.py index 0030c8c8e..348364ea0 100644 --- a/cyclonedx/model/license.py +++ b/cyclonedx/model/license.py @@ -80,7 +80,7 @@ class LicenseType(str, Enum): .. note:: See the CycloneDX Schema: - https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_license_licensing_licenseTypes + https://cyclonedx.org/docs/1.7/json/#metadata_tools_oneOf_i0_components_items_licenses_items_oneOf_i0_license_licensing_licenseTypes """ ACADEMIC = 'academic' @@ -114,7 +114,7 @@ class LicenseEntity: .. note:: See the CycloneDX Schema definition: - https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_license_licensing_licensor + https://cyclonedx.org/docs/1.7/json/#metadata_tools_oneOf_i0_components_items_licenses_items_oneOf_i0_license_licensing_licensor """ def __init__( @@ -203,7 +203,7 @@ class Licensing: .. note:: See the CycloneDX Schema definition: - https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_license_licensing + https://cyclonedx.org/docs/1.7/json/#metadata_tools_oneOf_i0_components_items_licenses_items_oneOf_i0_license_licensing """ def __init__( diff --git a/tests/_data/models.py b/tests/_data/models.py index 43d62570e..443e2dbaa 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -97,7 +97,15 @@ ImpactAnalysisState, ) from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource -from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression +from cyclonedx.model.license import ( + DisjunctiveLicense, + License, + LicenseAcknowledgement, + LicenseEntity, + LicenseExpression, + LicenseType, + Licensing, +) from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service @@ -1078,9 +1086,45 @@ def get_bom_with_licenses() -> Bom: Component(name='c-with-name', type=ComponentType.LIBRARY, bom_ref='C3', licenses=[ DisjunctiveLicense(name='some commercial license', - text=AttachedText(content='this is a license text')), + text=AttachedText(content='this is a license text'), + licensing=Licensing( + alt_ids=['LicenseRef-1', 'LicenseRef-commercial'], + licensor=LicenseEntity( + organization=OrganizationalEntity(name='Acme Inc') + ), + licensee=LicenseEntity( + organization=OrganizationalEntity(name='My Company') + ), + purchaser=LicenseEntity( + individual=OrganizationalContact(name='John Doe', + email='john.doe@example.com') + ), + purchase_order='PO-12345', + license_types=[LicenseType.PERPETUAL, + LicenseType.NAMED_USER], + last_renewal=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + expiration=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + )), DisjunctiveLicense(name='some additional', - text=AttachedText(content='this is additional license text')), + text=AttachedText(content='this is additional license text'), + licensing=Licensing( + alt_ids=['LicenseRef-2', 'LicenseRef-academic'], + licensor=LicenseEntity( + organization=OrganizationalEntity(name='Acme Inc') + ), + licensee=LicenseEntity( + organization=OrganizationalEntity(name='My Company') + ), + purchaser=LicenseEntity( + individual=OrganizationalContact(name='Jane Doe', + email='jane.doe@example.com') + ), + purchase_order='PO-54321', + license_types=[LicenseType.ACADEMIC, + LicenseType.CONCURRENT_USER], + last_renewal=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + expiration=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + )), ]), ], services=[ From 0ddf146d3af78c0b3acf61beb203c5c9d048bac3 Mon Sep 17 00:00:00 2001 From: Shaun Huynh Date: Tue, 17 Mar 2026 13:13:47 -0400 Subject: [PATCH 3/3] fix: update test snapshots for licensing Signed-off-by: Shaun Huynh --- .../get_bom_with_licenses-1.5.json.bin | 58 +++++++++++++++++++ .../get_bom_with_licenses-1.5.xml.bin | 58 +++++++++++++++++++ .../get_bom_with_licenses-1.6.json.bin | 58 +++++++++++++++++++ .../get_bom_with_licenses-1.6.xml.bin | 58 +++++++++++++++++++ .../get_bom_with_licenses-1.7.json.bin | 58 +++++++++++++++++++ .../get_bom_with_licenses-1.7.xml.bin | 58 +++++++++++++++++++ 6 files changed, 348 insertions(+) diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin index a8b28b10a..8283920ac 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin @@ -28,6 +28,35 @@ "licenses": [ { "license": { + "licensing": { + "altIds": [ + "LicenseRef-2", + "LicenseRef-academic" + ], + "expiration": "2026-01-01T00:00:00+00:00", + "lastRenewal": "2025-01-01T00:00:00+00:00", + "licenseTypes": [ + "academic", + "concurrent-user" + ], + "licensee": { + "organization": { + "name": "My Company" + } + }, + "licensor": { + "organization": { + "name": "Acme Inc" + } + }, + "purchaseOrder": "PO-54321", + "purchaser": { + "individual": { + "email": "jane.doe@example.com", + "name": "Jane Doe" + } + } + }, "name": "some additional", "text": { "content": "this is additional license text", @@ -37,6 +66,35 @@ }, { "license": { + "licensing": { + "altIds": [ + "LicenseRef-1", + "LicenseRef-commercial" + ], + "expiration": "2025-01-01T00:00:00+00:00", + "lastRenewal": "2024-01-01T00:00:00+00:00", + "licenseTypes": [ + "named-user", + "perpetual" + ], + "licensee": { + "organization": { + "name": "My Company" + } + }, + "licensor": { + "organization": { + "name": "Acme Inc" + } + }, + "purchaseOrder": "PO-12345", + "purchaser": { + "individual": { + "email": "john.doe@example.com", + "name": "John Doe" + } + } + }, "name": "some commercial license", "text": { "content": "this is a license text", diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin index fc2bedfd2..9761db653 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin @@ -38,10 +38,68 @@ some additional this is additional license text + + + LicenseRef-2 + LicenseRef-academic + + + + Acme Inc + + + + + My Company + + + + + Jane Doe + jane.doe@example.com + + + PO-54321 + + academic + concurrent-user + + 2025-01-01T00:00:00+00:00 + 2026-01-01T00:00:00+00:00 + some commercial license this is a license text + + + LicenseRef-1 + LicenseRef-commercial + + + + Acme Inc + + + + + My Company + + + + + John Doe + john.doe@example.com + + + PO-12345 + + named-user + perpetual + + 2024-01-01T00:00:00+00:00 + 2025-01-01T00:00:00+00:00 + diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin index 4e6ef33ff..f3cd38ac7 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin @@ -30,6 +30,35 @@ "licenses": [ { "license": { + "licensing": { + "altIds": [ + "LicenseRef-2", + "LicenseRef-academic" + ], + "expiration": "2026-01-01T00:00:00+00:00", + "lastRenewal": "2025-01-01T00:00:00+00:00", + "licenseTypes": [ + "academic", + "concurrent-user" + ], + "licensee": { + "organization": { + "name": "My Company" + } + }, + "licensor": { + "organization": { + "name": "Acme Inc" + } + }, + "purchaseOrder": "PO-54321", + "purchaser": { + "individual": { + "email": "jane.doe@example.com", + "name": "Jane Doe" + } + } + }, "name": "some additional", "text": { "content": "this is additional license text", @@ -39,6 +68,35 @@ }, { "license": { + "licensing": { + "altIds": [ + "LicenseRef-1", + "LicenseRef-commercial" + ], + "expiration": "2025-01-01T00:00:00+00:00", + "lastRenewal": "2024-01-01T00:00:00+00:00", + "licenseTypes": [ + "named-user", + "perpetual" + ], + "licensee": { + "organization": { + "name": "My Company" + } + }, + "licensor": { + "organization": { + "name": "Acme Inc" + } + }, + "purchaseOrder": "PO-12345", + "purchaser": { + "individual": { + "email": "john.doe@example.com", + "name": "John Doe" + } + } + }, "name": "some commercial license", "text": { "content": "this is a license text", diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin index 49b31f469..6df3c8bff 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin @@ -38,10 +38,68 @@ some additional this is additional license text + + + LicenseRef-2 + LicenseRef-academic + + + + Acme Inc + + + + + My Company + + + + + Jane Doe + jane.doe@example.com + + + PO-54321 + + academic + concurrent-user + + 2025-01-01T00:00:00+00:00 + 2026-01-01T00:00:00+00:00 + some commercial license this is a license text + + + LicenseRef-1 + LicenseRef-commercial + + + + Acme Inc + + + + + My Company + + + + + John Doe + john.doe@example.com + + + PO-12345 + + named-user + perpetual + + 2024-01-01T00:00:00+00:00 + 2025-01-01T00:00:00+00:00 + diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin index f095a4692..9283528d9 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin @@ -30,6 +30,35 @@ "licenses": [ { "license": { + "licensing": { + "altIds": [ + "LicenseRef-2", + "LicenseRef-academic" + ], + "expiration": "2026-01-01T00:00:00+00:00", + "lastRenewal": "2025-01-01T00:00:00+00:00", + "licenseTypes": [ + "academic", + "concurrent-user" + ], + "licensee": { + "organization": { + "name": "My Company" + } + }, + "licensor": { + "organization": { + "name": "Acme Inc" + } + }, + "purchaseOrder": "PO-54321", + "purchaser": { + "individual": { + "email": "jane.doe@example.com", + "name": "Jane Doe" + } + } + }, "name": "some additional", "text": { "content": "this is additional license text", @@ -39,6 +68,35 @@ }, { "license": { + "licensing": { + "altIds": [ + "LicenseRef-1", + "LicenseRef-commercial" + ], + "expiration": "2025-01-01T00:00:00+00:00", + "lastRenewal": "2024-01-01T00:00:00+00:00", + "licenseTypes": [ + "named-user", + "perpetual" + ], + "licensee": { + "organization": { + "name": "My Company" + } + }, + "licensor": { + "organization": { + "name": "Acme Inc" + } + }, + "purchaseOrder": "PO-12345", + "purchaser": { + "individual": { + "email": "john.doe@example.com", + "name": "John Doe" + } + } + }, "name": "some commercial license", "text": { "content": "this is a license text", diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin index b9e91e6db..86a9810e1 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin @@ -38,10 +38,68 @@ some additional this is additional license text + + + LicenseRef-2 + LicenseRef-academic + + + + Acme Inc + + + + + My Company + + + + + Jane Doe + jane.doe@example.com + + + PO-54321 + + academic + concurrent-user + + 2025-01-01T00:00:00+00:00 + 2026-01-01T00:00:00+00:00 + some commercial license this is a license text + + + LicenseRef-1 + LicenseRef-commercial + + + + Acme Inc + + + + + My Company + + + + + John Doe + john.doe@example.com + + + PO-12345 + + named-user + perpetual + + 2024-01-01T00:00:00+00:00 + 2025-01-01T00:00:00+00:00 +