Skip to content

Commit

Permalink
feat: options for beautiful output (#458)
Browse files Browse the repository at this point in the history
add indention to outputters. this may come at a cost!


Breaking Changes
------------------
* abstract Method `output.BaseOutput.output_as_string()` got new optional kwarg `indent`
* abstract Method `output.BaseOutput.output_as_string()` accepts arbitrary kwargs

Changed
----------
* XML output uses a default namespace, which makes results smaller.


Added
------------------
* All outputters' method `output_as_string()` got new optional kwarg `indent`
* All outputters' method `output_as_string()` accepts arbitrary kwargs
* All outputters' method `output_to_file()` got new optional kwarg `indent`
* All outputters' method `output_to_file()` accepts arbitrary kwargs

-----

- [x] implementation
- [x] tests (snapshot binary compare; structural equal compare) 

-----

enables CycloneDX/cyclonedx-python#424
fixes #437
fixes #438
supersedes #449

---------

Signed-off-by: Jan Kowalleck <[email protected]>
  • Loading branch information
jkowalleck authored Oct 4, 2023
1 parent 16843b2 commit 3bcd9e9
Show file tree
Hide file tree
Showing 17 changed files with 502 additions and 73 deletions.
12 changes: 8 additions & 4 deletions cyclonedx/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import os
from abc import ABC, abstractmethod
from importlib import import_module
from typing import Iterable, Optional, Type, Union
from typing import Any, Dict, Iterable, Optional, Type, Union

from ..model.bom import Bom
from ..model.component import Component
Expand Down Expand Up @@ -72,10 +72,14 @@ def generate(self, force_regeneration: bool = False) -> None:
...

@abstractmethod
def output_as_string(self) -> str:
def output_as_string(self, *,
indent: Optional[Union[int, str]] = None,
**kwargs: Dict[str, Any]) -> str:
...

def output_to_file(self, filename: str, allow_overwrite: bool = False) -> None:
def output_to_file(self, filename: str, allow_overwrite: bool = False, *,
indent: Optional[Union[int, str]] = None,
**kwargs: Dict[str, Any]) -> None:
# Check directory writable
output_filename = os.path.realpath(filename)
output_directory = os.path.dirname(output_filename)
Expand All @@ -84,7 +88,7 @@ def output_to_file(self, filename: str, allow_overwrite: bool = False) -> None:
if os.path.exists(output_filename) and not allow_overwrite:
raise FileExistsError(output_filename)
with open(output_filename, mode='wb') as f_out:
f_out.write(self.output_as_string().encode('utf-8'))
f_out.write(self.output_as_string(indent=indent).encode('utf-8'))


def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,
Expand Down
42 changes: 19 additions & 23 deletions cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.

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

from ..exception.output import FormatNotSupportedException
from ..model.bom import Bom
Expand All @@ -40,7 +40,7 @@ class Json(BaseOutput, BaseSchemaVersion):

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

@property
def schema_version(self) -> SchemaVersion:
Expand All @@ -51,7 +51,9 @@ def output_format(self) -> OutputFormat:
return OutputFormat.JSON

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

schema_uri: Optional[str] = self._get_schema_uri()
if not schema_uri:
raise FormatNotSupportedException(
Expand All @@ -63,26 +65,20 @@ def generate(self, force_regeneration: bool = False) -> None:
'specVersion': self.schema_version.to_version()
}
_view = SCHEMA_VERSIONS.get(self.schema_version_enum)
if self.generated and force_regeneration:
self.get_bom().validate()
bom_json = json.loads(self.get_bom().as_json(view_=_view)) # type: ignore
bom_json.update(_json_core)
self._json_output = json.dumps(bom_json)
self.generated = True
return
elif self.generated:
return
else:
self.get_bom().validate()
bom_json = json.loads(self.get_bom().as_json(view_=_view)) # type: ignore
bom_json.update(_json_core)
self._json_output = json.dumps(bom_json)
self.generated = True
return

def output_as_string(self) -> str:
self.get_bom().validate()
bom_json: Dict[str, Any] = json_loads(
self.get_bom().as_json( # type:ignore[attr-defined]
view_=_view))
bom_json.update(_json_core)
self._bom_json = bom_json
self.generated = True

def output_as_string(self, *,
indent: Optional[Union[int, str]] = None,
**kwargs: Dict[str, Any]) -> str:
self.generate()
return self._json_output
return json_dumps(self._bom_json,
indent=indent)

@abstractmethod
def _get_schema_uri(self) -> Optional[str]:
Expand Down
66 changes: 36 additions & 30 deletions cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.


from typing import Dict, Optional, Type
from xml.etree import ElementTree
from typing import Any, Dict, 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 ..exception.output import BomGenerationErrorException
from ..model.bom import Bom
from ..schema import OutputFormat, SchemaVersion
from ..schema.schema import (
Expand All @@ -37,11 +37,9 @@


class Xml(BaseSchemaVersion, BaseOutput):
XML_VERSION_DECLARATION: str = '<?xml version="1.0" encoding="UTF-8"?>'

def __init__(self, bom: Bom) -> None:
super().__init__(bom=bom)
self._root_bom_element: Optional[ElementTree.Element] = None
self._bom_xml: str = ''

@property
def schema_version(self) -> SchemaVersion:
Expand All @@ -52,40 +50,48 @@ def output_format(self) -> OutputFormat:
return OutputFormat.XML

def generate(self, force_regeneration: bool = False) -> None:
# New way
_view = SCHEMA_VERSIONS[self.schema_version_enum]
if self.generated and force_regeneration:
self.get_bom().validate()
self._root_bom_element = self.get_bom().as_xml( # type: ignore
view_=_view, as_string=False, xmlns=self.get_target_namespace()
)
self.generated = True
return
elif self.generated:
return
else:
self.get_bom().validate()
self._root_bom_element = self.get_bom().as_xml( # type: ignore
view_=_view, as_string=False, xmlns=self.get_target_namespace()
)
self.generated = True
if self.generated and not force_regeneration:
return

def output_as_string(self) -> str:
_view = SCHEMA_VERSIONS[self.schema_version_enum]
self.get_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]
_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.
# Furthermore, it might add an encoding of "utf-8" which is redundant default value of XML.
# -> so we write the declaration manually, as long as py38 is supported.
xml_declaration=False)

self.generated = True

@staticmethod
def __make_indent(v: Optional[Union[int, str]]) -> str:
if isinstance(v, int):
return ' ' * v
if isinstance(v, str):
return v
return ''

def output_as_string(self, *,
indent: Optional[Union[int, str]] = None,
**kwargs: Dict[str, Any]) -> str:
self.generate()
if self.generated and self._root_bom_element is not None:
return str(Xml.XML_VERSION_DECLARATION + ElementTree.tostring(self._root_bom_element, encoding='unicode'))

raise BomGenerationErrorException('There was no Root XML Element after BOM generation.')
return self._bom_xml if indent is None else dom_parseString(self._bom_xml).toprettyxml(
indent=self.__make_indent(indent)
# do not set `encoding` - this would convert result to binary, not string
)

def get_target_namespace(self) -> str:
return f'http://cyclonedx.org/schema/bom/{self.get_schema_version()}'


class XmlV1Dot0(Xml, SchemaVersion1Dot0):

def _create_bom_element(self) -> ElementTree.Element:
return ElementTree.Element('bom', {'xmlns': self.get_target_namespace(), 'version': '1'})
def _create_bom_element(self) -> XmlElement:
return XmlElement('bom', {'xmlns': self.get_target_namespace(), 'version': '1'})


class XmlV1Dot1(Xml, SchemaVersion1Dot1):
Expand Down
6 changes: 4 additions & 2 deletions examples/complex.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
# endregion build the BOM


serialized_json = JsonV1Dot4(bom).output_as_string()
serialized_json = JsonV1Dot4(bom).output_as_string(indent=2)
print(serialized_json)
try:
validation_errors = JsonStrictValidator(SchemaVersion.V1_4).validate_str(serialized_json)
Expand All @@ -63,8 +63,10 @@
except MissingOptionalDependencyException as error:
print('JSON-validation was skipped due to', error)

print('', '=' * 30, '', sep='\n')

my_outputter = get_outputter(bom, OutputFormat.XML, SchemaVersion.V1_4)
serialized_xml = my_outputter.output_as_string()
serialized_xml = my_outputter.output_as_string(indent=2)
print(serialized_xml)
try:
validation_errors = get_validator(my_outputter.output_format,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ keywords = [
# ATTENTION: keep `deps.lowest.r` file in sync
python = "^3.8"
packageurl-python = ">= 0.11"
py-serializable = "^0.11.1"
py-serializable = "^0.13.0"
sortedcontainers = "^2.4.0"
license-expression = "^30"
jsonschema = { version = "^4.18", extras=['format'], optional=true, python="^3.8" }
Expand Down
5 changes: 3 additions & 2 deletions tests/_data/own/.gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
xml/*/*.xml linguist-generated
json/*/*.josn linguist-generated
* binary
xml/*/*.xml linguist-generated diff=xml
json/*/*.json linguist-generated diff=json
71 changes: 71 additions & 0 deletions tests/_data/own/json/1.4/indented_4spaces.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tests/_data/own/json/1.4/indented_None.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 71 additions & 0 deletions tests/_data/own/json/1.4/indented_tab.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 3bcd9e9

Please sign in to comment.