Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: typing, typehints, & overload #463

Merged
merged 16 commits into from
Oct 6, 2023
2 changes: 1 addition & 1 deletion .mypy.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[mypy]

files = cyclonedx/
files = cyclonedx/, examples/
mypy_path = $MYPY_CONFIG_FILE_DIR/typings

show_error_codes = True
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from .service import Service
from .vulnerability import Vulnerability

if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from packageurl import PackageURL


Expand Down
48 changes: 37 additions & 11 deletions cyclonedx/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,27 @@
import os
from abc import ABC, abstractmethod
from importlib import import_module
from typing import Any, Iterable, Optional, Type, Union
from typing import TYPE_CHECKING, Any, Iterable, Literal, Optional, Type, Union, overload

from ..model.bom import Bom
from ..model.component import Component
from ..schema import OutputFormat, SchemaVersion

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom
from ..model.component import Component
from .json import Json as JsonOutputter
from .xml import Xml as XmlOutputter

LATEST_SUPPORTED_SCHEMA_VERSION = SchemaVersion.V1_4


class BaseOutput(ABC):

def __init__(self, bom: Bom, **kwargs: int) -> None:
def __init__(self, bom: 'Bom', **kwargs: int) -> None:
super().__init__(**kwargs)
self._bom = bom
self._generated: bool = False

def _chained_components(self, container: Union[Bom, Component]) -> Iterable[Component]:
def _chained_components(self, container: Union['Bom', 'Component']) -> Iterable['Component']:
for component in container.components:
yield component
yield from self._chained_components(component)
Expand All @@ -59,10 +63,10 @@ def generated(self) -> bool:
def generated(self, generated: bool) -> None:
self._generated = generated

def get_bom(self) -> Bom:
def get_bom(self) -> 'Bom':
return self._bom

def set_bom(self, bom: Bom) -> None:
def set_bom(self, bom: 'Bom') -> None:
self._bom = bom

@abstractmethod
Expand All @@ -89,17 +93,39 @@ def output_to_file(self, filename: str, allow_overwrite: bool = False, *,
f_out.write(self.output_as_string(indent=indent).encode('utf-8'))


def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,
@overload
def get_instance(bom: 'Bom', output_format: Literal[OutputFormat.JSON],
schema_version: SchemaVersion = ...) -> 'JsonOutputter':
...


@overload
def get_instance(bom: 'Bom', output_format: Literal[OutputFormat.XML] = ...,
schema_version: SchemaVersion = ...) -> 'XmlOutputter':
...


@overload
def get_instance(bom: 'Bom', output_format: OutputFormat = ...,
schema_version: SchemaVersion = ...
) -> Union['XmlOutputter', 'JsonOutputter']:
...


def get_instance(bom: 'Bom', output_format: OutputFormat = OutputFormat.XML,
schema_version: SchemaVersion = LATEST_SUPPORTED_SCHEMA_VERSION) -> BaseOutput:
"""
Helper method to quickly get the correct output class/formatter.

Pass in your BOM and optionally an output format and schema version (defaults to XML and latest schema version).


Raises error when no instance could be built.

:param bom: Bom
:param output_format: OutputFormat
:param schema_version: SchemaVersion
:return:
:return: BaseOutput
"""
# all exceptions are undocumented, as they are pure functional, and should be prevented by correct typing...
if not isinstance(output_format, OutputFormat):
Expand All @@ -108,9 +134,9 @@ def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,
raise TypeError(f"unexpected schema_version: {schema_version!r}")
try:
module = import_module(f'.{output_format.name.lower()}', __package__)
except ImportError as error: # pragma: no cover
except ImportError as error:
raise ValueError(f'Unknown output_format: {output_format.name}') from error
klass: Optional[Type[BaseOutput]] = module.BY_SCHEMA_VERSION.get(schema_version, None)
if klass is None: # pragma: no cover
if klass is None:
raise ValueError(f'Unknown {output_format.name}/schema_version: {schema_version.name}')
return klass(bom=bom)
35 changes: 19 additions & 16 deletions cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@

from abc import abstractmethod
from json import dumps as json_dumps, loads as json_loads
from typing import Any, Dict, Optional, Type, Union
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Type, Union

from ..exception.output import FormatNotSupportedException
from ..model.bom import Bom
from ..schema import OutputFormat, SchemaVersion
from ..schema.schema import (
SCHEMA_VERSIONS,
Expand All @@ -33,10 +32,13 @@
)
from . import BaseOutput

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom


class Json(BaseOutput, BaseSchemaVersion):

def __init__(self, bom: Bom) -> None:
def __init__(self, bom: 'Bom') -> None:
super().__init__(bom=bom)
self._bom_json: Dict[str, Any] = dict()

Expand All @@ -45,7 +47,7 @@ def schema_version(self) -> SchemaVersion:
return self.schema_version_enum

@property
def output_format(self) -> OutputFormat:
def output_format(self) -> Literal[OutputFormat.JSON]:
return OutputFormat.JSON

def generate(self, force_regeneration: bool = False) -> None:
Expand All @@ -63,9 +65,10 @@ def generate(self, force_regeneration: bool = False) -> None:
'specVersion': self.schema_version.to_version()
}
_view = SCHEMA_VERSIONS.get(self.schema_version_enum)
self.get_bom().validate()
bom = self.get_bom()
bom.validate()
bom_json: Dict[str, Any] = json_loads(
self.get_bom().as_json( # type:ignore[attr-defined]
bom.as_json( # type:ignore[attr-defined]
view_=_view))
bom_json.update(_json_core)
self._bom_json = bom_json
Expand All @@ -85,38 +88,38 @@ def _get_schema_uri(self) -> Optional[str]:

class JsonV1Dot0(Json, SchemaVersion1Dot0):

def _get_schema_uri(self) -> Optional[str]:
def _get_schema_uri(self) -> None:
return None


class JsonV1Dot1(Json, SchemaVersion1Dot1):

def _get_schema_uri(self) -> Optional[str]:
def _get_schema_uri(self) -> None:
return None


class JsonV1Dot2(Json, SchemaVersion1Dot2):

def _get_schema_uri(self) -> Optional[str]:
def _get_schema_uri(self) -> str:
return 'http://cyclonedx.org/schema/bom-1.2b.schema.json'


class JsonV1Dot3(Json, SchemaVersion1Dot3):

def _get_schema_uri(self) -> Optional[str]:
def _get_schema_uri(self) -> str:
return 'http://cyclonedx.org/schema/bom-1.3a.schema.json'


class JsonV1Dot4(Json, SchemaVersion1Dot4):

def _get_schema_uri(self) -> Optional[str]:
def _get_schema_uri(self) -> str:
return 'http://cyclonedx.org/schema/bom-1.4.schema.json'


BY_SCHEMA_VERSION: Dict[SchemaVersion, Type[Json]] = {
SchemaVersion.V1_4: JsonV1Dot4, # type:ignore[type-abstract]
SchemaVersion.V1_3: JsonV1Dot3, # type:ignore[type-abstract]
SchemaVersion.V1_2: JsonV1Dot2, # type:ignore[type-abstract]
SchemaVersion.V1_1: JsonV1Dot1, # type:ignore[type-abstract]
SchemaVersion.V1_0: JsonV1Dot0, # type:ignore[type-abstract]
SchemaVersion.V1_4: JsonV1Dot4,
SchemaVersion.V1_3: JsonV1Dot3,
SchemaVersion.V1_2: JsonV1Dot2,
SchemaVersion.V1_1: JsonV1Dot1,
SchemaVersion.V1_0: JsonV1Dot0,
}
25 changes: 14 additions & 11 deletions cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.


from typing import Any, Dict, Optional, Type, Union
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Type, Union
from xml.dom.minidom import parseString as dom_parseString
from xml.etree.ElementTree import Element as XmlElement, tostring as xml_dumps

from ..model.bom import Bom
from ..schema import OutputFormat, SchemaVersion
from ..schema.schema import (
SCHEMA_VERSIONS,
Expand All @@ -33,9 +32,12 @@
)
from . import BaseOutput

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom


class Xml(BaseSchemaVersion, BaseOutput):
def __init__(self, bom: Bom) -> None:
def __init__(self, bom: 'Bom') -> None:
super().__init__(bom=bom)
self._bom_xml: str = ''

Expand All @@ -44,18 +46,19 @@ def schema_version(self) -> SchemaVersion:
return self.schema_version_enum

@property
def output_format(self) -> OutputFormat:
def output_format(self) -> Literal[OutputFormat.XML]:
return OutputFormat.XML

def generate(self, force_regeneration: bool = False) -> None:
if self.generated and not force_regeneration:
return

_view = SCHEMA_VERSIONS[self.schema_version_enum]
self.get_bom().validate()
bom = self.get_bom()
bom.validate()
xmlns = self.get_target_namespace()
self._bom_xml = '<?xml version="1.0" ?>\n' + xml_dumps(
self.get_bom().as_xml( # type:ignore[attr-defined]
bom.as_xml( # type:ignore[attr-defined]
_view, as_string=False, xmlns=xmlns),
method='xml', default_namespace=xmlns, encoding='unicode',
# `xml-declaration` is inconsistent/bugged in py38, especially on Windows it will print a non-UTF8 codepage.
Expand Down Expand Up @@ -109,9 +112,9 @@ class XmlV1Dot4(Xml, SchemaVersion1Dot4):


BY_SCHEMA_VERSION: Dict[SchemaVersion, Type[Xml]] = {
SchemaVersion.V1_4: XmlV1Dot4, # type:ignore[type-abstract]
SchemaVersion.V1_3: XmlV1Dot3, # type:ignore[type-abstract]
SchemaVersion.V1_2: XmlV1Dot2, # type:ignore[type-abstract]
SchemaVersion.V1_1: XmlV1Dot1, # type:ignore[type-abstract]
SchemaVersion.V1_0: XmlV1Dot0, # type:ignore[type-abstract]
SchemaVersion.V1_4: XmlV1Dot4,
SchemaVersion.V1_3: XmlV1Dot3,
SchemaVersion.V1_2: XmlV1Dot2,
SchemaVersion.V1_1: XmlV1Dot1,
SchemaVersion.V1_0: XmlV1Dot0,
}
79 changes: 46 additions & 33 deletions cyclonedx/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,28 @@
# SPDX-License-Identifier: Apache-2.0

from enum import Enum, auto, unique
from typing import Any, Type, TypeVar


@unique
class OutputFormat(Enum):
"""Output formats.

Do not rely on the actual/literal values, just use enum cases.
Cases are hashable.

Do not rely on the actual/literal values, just use enum cases, like so:
my_of = OutputFormat.XML
"""

JSON = auto()
XML = auto()

def __hash__(self) -> int:
return hash(self.name)


_SV = TypeVar('_SV', bound='SchemaVersion')


@unique
class SchemaVersion(Enum):
Expand All @@ -33,52 +44,54 @@ class SchemaVersion(Enum):
Cases are hashable.
Cases are comparable(!=,>=,>,==,<,<=)

Do not rely on the actual/literal values, just use enum cases.
Do not rely on the actual/literal values, just use enum cases, like so:
my_sv = SchemaVersion.V1_3
"""

V1_4 = (1, 4)
V1_3 = (1, 3)
V1_2 = (1, 2)
V1_1 = (1, 1)
V1_0 = (1, 0)

@classmethod
def from_version(cls, version: str) -> 'SchemaVersion':
"""Return instance from a version string - e.g. `1.4`"""
def from_version(cls: Type[_SV], version: str) -> _SV:
"""Return instance based of a version string - e.g. `1.4`"""
return cls(tuple(map(int, version.split('.')))[:2])

def to_version(self) -> str:
"""Return as a version string - e.g. `1.4`"""
return '.'.join(map(str, self.value))

def __ne__(self, other: object) -> bool:
return self.value != other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __lt__(self, other: object) -> bool:
return self.value < other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __le__(self, other: object) -> bool:
return self.value <= other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __eq__(self, other: object) -> bool:
return self.value == other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __ge__(self, other: object) -> bool:
return self.value >= other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __gt__(self, other: object) -> bool:
return self.value > other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]
def __ne__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return self.value != other.value
return NotImplemented # pragma: no cover

def __lt__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return self.value < other.value
return NotImplemented # pragma: no cover

def __le__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return self.value <= other.value
return NotImplemented # pragma: no cover

def __eq__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return self.value == other.value
return NotImplemented # pragma: no cover

def __ge__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return self.value >= other.value
return NotImplemented # pragma: no cover

def __gt__(self, other: Any) -> bool:
if isinstance(other, self.__class__):
return self.value > other.value
return NotImplemented # pragma: no cover

def __hash__(self) -> int:
return hash(self.name)
Loading