Skip to content

Commit

Permalink
fix: re-aligned Pythonic Model to fix #365 for XML, which breaks JSON…
Browse files Browse the repository at this point in the history
… serialization

BREAKING CHANGE: Models changed to resolve #365

Signed-off-by: Paul Horton <[email protected]>
  • Loading branch information
madpah committed Apr 3, 2023
1 parent ac91080 commit 0fa6635
Show file tree
Hide file tree
Showing 23 changed files with 167 additions and 152 deletions.
4 changes: 2 additions & 2 deletions cyclonedx/factory/license.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,5 @@ def make_with_license(self, name_or_spdx: str, *,
license_text: Optional[AttachedText] = None,
license_url: Optional[XsUri] = None) -> LicenseChoice:
"""Make a :class:`cyclonedx.model.LicenseChoice` with a license (name or SPDX-ID)."""
return LicenseChoice(license=self.license_factory.make_from_string(
name_or_spdx, license_text=license_text, license_url=license_url))
return LicenseChoice(licenses=[self.license_factory.make_from_string(
name_or_spdx, license_text=license_text, license_url=license_url)])
37 changes: 19 additions & 18 deletions cyclonedx/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import warnings
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Iterable, Optional, Tuple, TypeVar
from typing import Any, Iterable, List, Optional, Tuple, TypeVar

import serializable
from sortedcontainers import SortedSet
Expand Down Expand Up @@ -695,35 +695,36 @@ class LicenseChoice:
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_licenseChoiceType
"""

def __init__(self, *, license: Optional[License] = None, expression: Optional[str] = None) -> None:
if not license and not expression:
def __init__(self, *, licenses: Optional[List[License]] = None, expression: Optional[str] = None) -> None:
if not licenses and not expression:
raise NoPropertiesProvidedException(
'One of `license` or `expression` must be supplied - neither supplied'
'One of `licenses` or `expression` must be supplied - neither supplied'
)
if license and expression:
if licenses and expression:
warnings.warn(
'Both `license` and `expression` have been supplied - `license` will take precedence',
'Both `licenses` and `expression` have been supplied - `license` will take precedence',
RuntimeWarning
)
self.license = license
if not license:
self.licenses = licenses
if not licenses:
self.expression = expression
else:
self.expression = None

@property
def license(self) -> Optional[License]:
@property # type: ignore[misc]
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'license')
def licenses(self) -> Optional[List[License]]:
"""
License definition
Returns:
`License` or `None`
"""
return self._license
return self._licenses

@license.setter
def license(self, license: Optional[License]) -> None:
self._license = license
@licenses.setter
def licenses(self, licenses: Optional[List[License]] = None) -> None:
self._licenses = licenses

@property
def expression(self) -> Optional[str]:
Expand All @@ -748,15 +749,15 @@ def __eq__(self, other: object) -> bool:

def __lt__(self, other: Any) -> bool:
if isinstance(other, LicenseChoice):
return ComparableTuple((self.license, self.expression)) < ComparableTuple(
(other.license, other.expression))
return ComparableTuple((self.licenses or [], self.expression)) < ComparableTuple(
(other.licenses or [], other.expression))
return NotImplemented

def __hash__(self) -> int:
return hash((self.license, self.expression))
return hash((tuple(self.licenses) if self.licenses else (), self.expression))

def __repr__(self) -> str:
return f'<LicenseChoice license={self.license}, expression={self.expression}>'
return f'<LicenseChoice licenses={self.licenses}, expression={self.expression}>'


@serializable.serializable_class
Expand Down
14 changes: 7 additions & 7 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def __init__(self, *, tools: Optional[Iterable[Tool]] = None,
authors: Optional[Iterable[OrganizationalContact]] = None, component: Optional[Component] = None,
manufacture: Optional[OrganizationalEntity] = None,
supplier: Optional[OrganizationalEntity] = None,
licenses: Optional[Iterable[LicenseChoice]] = None,
licenses: Optional[LicenseChoice] = None,
properties: Optional[Iterable[Property]] = None,
timestamp: Optional[datetime] = None) -> None:
self.timestamp = timestamp or get_now_utc()
Expand All @@ -77,7 +77,7 @@ def __init__(self, *, tools: Optional[Iterable[Tool]] = None,
self.component = component
self.manufacture = manufacture
self.supplier = supplier
self.licenses = licenses or [] # type: ignore
self.licenses = licenses
self.properties = properties or [] # type: ignore

if not tools:
Expand Down Expand Up @@ -195,9 +195,9 @@ def supplier(self, supplier: Optional[OrganizationalEntity]) -> None:
@property # type: ignore[misc]
@serializable.view(SchemaVersion1Dot3)
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'licenses')
# @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'licenses')
@serializable.xml_sequence(7)
def licenses(self) -> "SortedSet[LicenseChoice]":
def licenses(self) -> Optional[LicenseChoice]:
"""
A optional list of statements about how this BOM is licensed.
Expand All @@ -207,8 +207,8 @@ def licenses(self) -> "SortedSet[LicenseChoice]":
return self._licenses

@licenses.setter
def licenses(self, licenses: Iterable[LicenseChoice]) -> None:
self._licenses = SortedSet(licenses)
def licenses(self, licenses: Optional[LicenseChoice]) -> None:
self._licenses = licenses

@property # type: ignore[misc]
@serializable.view(SchemaVersion1Dot3)
Expand Down Expand Up @@ -239,7 +239,7 @@ def __eq__(self, other: object) -> bool:

def __hash__(self) -> int:
return hash((
tuple(self.authors), self.component, tuple(self.licenses), self.manufacture, tuple(self.properties),
tuple(self.authors), self.component, self.licenses, self.manufacture, tuple(self.properties),
self.supplier, self.timestamp, tuple(self.tools)
))

Expand Down
14 changes: 7 additions & 7 deletions cyclonedx/model/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import warnings
from enum import Enum
from os.path import exists
from typing import Any, Iterable, Optional, Set, Union
from typing import Any, Iterable, List, Optional, Set, Union
from uuid import uuid4

# See https://github.com/package-url/packageurl-python/issues/65
Expand Down Expand Up @@ -756,7 +756,7 @@ def __init__(self, *, name: str, type: ComponentType = ComponentType.LIBRARY,
supplier: Optional[OrganizationalEntity] = None, author: Optional[str] = None,
publisher: Optional[str] = None, group: Optional[str] = None, version: Optional[str] = None,
description: Optional[str] = None, scope: Optional[ComponentScope] = None,
hashes: Optional[Iterable[HashType]] = None, licenses: Optional[Iterable[LicenseChoice]] = None,
hashes: Optional[Iterable[HashType]] = None, licenses: Optional[LicenseChoice] = None,
copyright: Optional[str] = None, purl: Optional[PackageURL] = None,
external_references: Optional[Iterable[ExternalReference]] = None,
properties: Optional[Iterable[Property]] = None, release_notes: Optional[ReleaseNotes] = None,
Expand All @@ -781,7 +781,7 @@ def __init__(self, *, name: str, type: ComponentType = ComponentType.LIBRARY,
self.description = description
self.scope = scope
self.hashes = hashes or [] # type: ignore
self.licenses = licenses or [] # type: ignore
self.licenses = licenses
self.copyright = copyright
self.cpe = cpe
self.purl = purl
Expand Down Expand Up @@ -1034,7 +1034,7 @@ def hashes(self, hashes: Iterable[HashType]) -> None:
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'licenses')
@serializable.xml_sequence(10)
def licenses(self) -> "SortedSet[LicenseChoice]":
def licenses(self) -> Optional[LicenseChoice]:
"""
A optional list of statements about how this Component is licensed.
Expand All @@ -1044,8 +1044,8 @@ def licenses(self) -> "SortedSet[LicenseChoice]":
return self._licenses

@licenses.setter
def licenses(self, licenses: Iterable[LicenseChoice]) -> None:
self._licenses = SortedSet(licenses)
def licenses(self, licenses: Optional[LicenseChoice]) -> None:
self._licenses = licenses

@property # type: ignore[misc]
@serializable.xml_sequence(11)
Expand Down Expand Up @@ -1267,7 +1267,7 @@ def __lt__(self, other: Any) -> bool:
def __hash__(self) -> int:
return hash((
self.type, self.mime_type, self.supplier, self.author, self.publisher, self.group, self.name,
self.version, self.description, self.scope, tuple(self.hashes), tuple(self.licenses), self.copyright,
self.version, self.description, self.scope, tuple(self.hashes), self.licenses, self.copyright,
self.cpe, self.purl, self.swid, self.pedigree, tuple(self.external_references), tuple(self.properties),
tuple(self.components), self.evidence, self.release_notes, self.modified
))
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/model/vulnerability.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ class VulnerabilitySeverity(str, Enum):
UNKNOWN = 'unknown'

@staticmethod
def get_from_cvss_scores(scores: Union[Tuple[float], float, None]) -> 'VulnerabilitySeverity':
def get_from_cvss_scores(scores: Union[Tuple[float, ...], float, None]) -> 'VulnerabilitySeverity':
"""
Derives the Severity of a Vulnerability from it's declared CVSS scores.
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/serialization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def serialize(cls, o: object) -> str:
raise ValueError(f'Attempt to serialize a non-UUID: {o.__class__}')

@classmethod
def deserialize(cls, o: object) -> PackageURL:
def deserialize(cls, o: object) -> UUID:
try:
return UUID(str(o))
except ValueError:
Expand Down
23 changes: 11 additions & 12 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,28 @@
from unittest import TestCase
from uuid import uuid4

from lxml import etree
from lxml.etree import DocumentInvalid
from xmldiff import main
from xmldiff.actions import MoveNode
from lxml import etree # type: ignore
from lxml.etree import DocumentInvalid # type: ignore
from xmldiff import main # type: ignore
from xmldiff.actions import MoveNode # type: ignore

from cyclonedx.output import SchemaVersion
from cyclonedx.schema import SchemaVersion

if sys.version_info >= (3, 7):
from jsonschema import ValidationError, validate as json_validate
from jsonschema import ValidationError, validate as json_validate # type: ignore

if sys.version_info >= (3, 8):
from importlib.metadata import PackageNotFoundError, version
from importlib.metadata import version as meta_version
else:
from importlib_metadata import PackageNotFoundError, version
from importlib_metadata import version as meta_version

from . import CDX_SCHEMA_DIRECTORY

cyclonedx_lib_name: str = 'cyclonedx-python-lib'
cyclonedx_lib_version: str = 'DEV'
try:
cyclonedx_lib_version: str = version(cyclonedx_lib_name)
except PackageNotFoundError:
pass
cyclonedx_lib_version: str = str(meta_version(cyclonedx_lib_name)) # type: ignore[no-untyped-call]
except Exception:
cyclonedx_lib_version = 'DEV'
single_uuid: str = 'urn:uuid:{}'.format(uuid4())


Expand Down
57 changes: 32 additions & 25 deletions tests/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from cyclonedx.model import (
AttachedText,
Copyright,
DataClassification,
DataFlow,
Encoding,
Expand All @@ -51,23 +52,24 @@
ComponentEvidence,
ComponentScope,
ComponentType,
Copyright,
Patch,
PatchClassification,
Pedigree,
Swid,
)
from cyclonedx.model.dependency import Dependency
from cyclonedx.model.impact_analysis import (
ImpactAnalysisAffectedStatus,
ImpactAnalysisJustification,
ImpactAnalysisResponse,
ImpactAnalysisState,
)
from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource
from cyclonedx.model.release_note import ReleaseNotes
from cyclonedx.model.service import Service
from cyclonedx.model.vulnerability import (
BomTarget,
BomTargetVersionRange,
ImpactAnalysisAffectedStatus,
ImpactAnalysisJustification,
ImpactAnalysisResponse,
ImpactAnalysisState,
Vulnerability,
VulnerabilityAdvisory,
VulnerabilityAnalysis,
Expand Down Expand Up @@ -201,7 +203,7 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom:
),
affects=[
BomTarget(
ref=component.purl.to_string() if component.purl else None,
ref=component.bom_ref.value,
versions=[BomTargetVersionRange(
range='49.0.0 - 54.0.0', status=ImpactAnalysisAffectedStatus.AFFECTED
)]
Expand All @@ -218,16 +220,20 @@ def get_bom_with_component_toml_1() -> Bom:

def get_bom_just_complete_metadata() -> Bom:
bom = Bom()
bom.metadata.authors = [get_org_contact_1(), get_org_contact_2()]
bom.metadata.authors.add(get_org_contact_1())
bom.metadata.authors.add(get_org_contact_2())
bom.metadata.component = get_component_setuptools_complete()
bom.metadata.manufacture = get_org_entity_1()
bom.metadata.supplier = get_org_entity_2()
bom.metadata.licenses = [LicenseChoice(license=License(
id='Apache-2.0', text=AttachedText(
content='VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE=', encoding=Encoding.BASE_64
), url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.txt')
)), LicenseChoice(license=License(name='OSI_APACHE'))]
bom.metadata.properties = get_properties_1()
bom.metadata.licenses = LicenseChoice(licenses=[
License(id='Apache-2.0',
text=AttachedText(content='VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE=',
encoding=Encoding.BASE_64),
url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.txt')
),
License(name='OSI_APACHE')
])
bom.metadata.properties.update(get_properties_1())
return bom


Expand Down Expand Up @@ -374,23 +380,24 @@ def get_bom_for_issue_328_components() -> Bom:
see https://github.com/CycloneDX/cyclonedx-python-lib/issues/328
"""

comp_root = Component(type=ComponentType.APPLICATION,
name='my-project', version='1', bom_ref='my-project')
comp_root = Component(type=ComponentType.APPLICATION, name='my-project', version='1', bom_ref='my-project')
comp_a = Component(name='A', version='0.1', bom_ref='component-A')
comp_b = Component(name='B', version='1.0', bom_ref='component-B')
comp_c = Component(name='C', version='1.0', bom_ref='component-C')

# Make a tree of components A -> B -> C
comp_a.components = [comp_b]
comp_b.components = [comp_c]
# Declare dependencies the same way: A -> B -> C
comp_a.dependencies = [comp_b.bom_ref]
comp_b.dependencies = [comp_c.bom_ref]
comp_a.components.add(comp_b)
comp_b.components.add(comp_c)

bom = Bom()
bom.metadata.component = comp_root
comp_root.dependencies = [comp_a.bom_ref]
bom.components = [comp_a]

# Declare dependencies the same way: A -> B -> C
bom.register_dependency(target=comp_a, depends_on=[comp_b])
bom.register_dependency(target=comp_b, depends_on=[comp_c])
bom.register_dependency(target=comp_root, depends_on=[comp_a])

bom.components.add(comp_a)
return bom


Expand All @@ -408,7 +415,7 @@ def get_component_setuptools_complete(include_pedigree: bool = True) -> Componen
component.external_references.add(
get_external_reference_1()
)
component.properties = get_properties_1()
component.properties.update(get_properties_1())
component.components.update([
get_component_setuptools_simple(),
get_component_toml_with_hashes_with_references()
Expand All @@ -427,7 +434,7 @@ def get_component_setuptools_simple(
purl=PackageURL(
type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz'
),
licenses=[LicenseChoice(expression='MIT License')],
licenses=LicenseChoice(expression='MIT License'),
author='Test Author'
)

Expand All @@ -438,7 +445,7 @@ def get_component_setuptools_simple_no_version(bom_ref: Optional[str] = None) ->
purl=PackageURL(
type='pypi', name='setuptools', qualifiers='extension=tar.gz'
),
licenses=[LicenseChoice(expression='MIT License')],
licenses=LicenseChoice(expression='MIT License'),
author='Test Author'
)

Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/xml/1.3/bom_with_full_metadata.xml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@
<text content-type="text/plain" encoding="base64">VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE=</text>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
<license>
<name>OSI_APACHE</name>
</license>
</licenses>
<properties>
<property name="key1">val1</property>
Expand Down
Loading

0 comments on commit 0fa6635

Please sign in to comment.