Skip to content

Commit 3bcd9e9

Browse files
authored
feat: options for beautiful output (#458)
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]>
1 parent 16843b2 commit 3bcd9e9

17 files changed

+502
-73
lines changed

cyclonedx/output/__init__.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import os
2323
from abc import ABC, abstractmethod
2424
from importlib import import_module
25-
from typing import Iterable, Optional, Type, Union
25+
from typing import Any, Dict, Iterable, Optional, Type, Union
2626

2727
from ..model.bom import Bom
2828
from ..model.component import Component
@@ -72,10 +72,14 @@ def generate(self, force_regeneration: bool = False) -> None:
7272
...
7373

7474
@abstractmethod
75-
def output_as_string(self) -> str:
75+
def output_as_string(self, *,
76+
indent: Optional[Union[int, str]] = None,
77+
**kwargs: Dict[str, Any]) -> str:
7678
...
7779

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

8993

9094
def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,

cyclonedx/output/json.py

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
# SPDX-License-Identifier: Apache-2.0
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919

20-
import json
2120
from abc import abstractmethod
22-
from typing import Dict, Optional, Type
21+
from json import dumps as json_dumps, loads as json_loads
22+
from typing import Any, Dict, Optional, Type, Union
2323

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

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

4545
@property
4646
def schema_version(self) -> SchemaVersion:
@@ -51,7 +51,9 @@ def output_format(self) -> OutputFormat:
5151
return OutputFormat.JSON
5252

5353
def generate(self, force_regeneration: bool = False) -> None:
54-
# New Way
54+
if self.generated and not force_regeneration:
55+
return
56+
5557
schema_uri: Optional[str] = self._get_schema_uri()
5658
if not schema_uri:
5759
raise FormatNotSupportedException(
@@ -63,26 +65,20 @@ def generate(self, force_regeneration: bool = False) -> None:
6365
'specVersion': self.schema_version.to_version()
6466
}
6567
_view = SCHEMA_VERSIONS.get(self.schema_version_enum)
66-
if self.generated and force_regeneration:
67-
self.get_bom().validate()
68-
bom_json = json.loads(self.get_bom().as_json(view_=_view)) # type: ignore
69-
bom_json.update(_json_core)
70-
self._json_output = json.dumps(bom_json)
71-
self.generated = True
72-
return
73-
elif self.generated:
74-
return
75-
else:
76-
self.get_bom().validate()
77-
bom_json = json.loads(self.get_bom().as_json(view_=_view)) # type: ignore
78-
bom_json.update(_json_core)
79-
self._json_output = json.dumps(bom_json)
80-
self.generated = True
81-
return
82-
83-
def output_as_string(self) -> str:
68+
self.get_bom().validate()
69+
bom_json: Dict[str, Any] = json_loads(
70+
self.get_bom().as_json( # type:ignore[attr-defined]
71+
view_=_view))
72+
bom_json.update(_json_core)
73+
self._bom_json = bom_json
74+
self.generated = True
75+
76+
def output_as_string(self, *,
77+
indent: Optional[Union[int, str]] = None,
78+
**kwargs: Dict[str, Any]) -> str:
8479
self.generate()
85-
return self._json_output
80+
return json_dumps(self._bom_json,
81+
indent=indent)
8682

8783
@abstractmethod
8884
def _get_schema_uri(self) -> Optional[str]:

cyclonedx/output/xml.py

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919

2020

21-
from typing import Dict, Optional, Type
22-
from xml.etree import ElementTree
21+
from typing import Any, Dict, Optional, Type, Union
22+
from xml.dom.minidom import parseString as dom_parseString
23+
from xml.etree.ElementTree import Element as XmlElement, tostring as xml_dumps
2324

24-
from ..exception.output import BomGenerationErrorException
2525
from ..model.bom import Bom
2626
from ..schema import OutputFormat, SchemaVersion
2727
from ..schema.schema import (
@@ -37,11 +37,9 @@
3737

3838

3939
class Xml(BaseSchemaVersion, BaseOutput):
40-
XML_VERSION_DECLARATION: str = '<?xml version="1.0" encoding="UTF-8"?>'
41-
4240
def __init__(self, bom: Bom) -> None:
4341
super().__init__(bom=bom)
44-
self._root_bom_element: Optional[ElementTree.Element] = None
42+
self._bom_xml: str = ''
4543

4644
@property
4745
def schema_version(self) -> SchemaVersion:
@@ -52,40 +50,48 @@ def output_format(self) -> OutputFormat:
5250
return OutputFormat.XML
5351

5452
def generate(self, force_regeneration: bool = False) -> None:
55-
# New way
56-
_view = SCHEMA_VERSIONS[self.schema_version_enum]
57-
if self.generated and force_regeneration:
58-
self.get_bom().validate()
59-
self._root_bom_element = self.get_bom().as_xml( # type: ignore
60-
view_=_view, as_string=False, xmlns=self.get_target_namespace()
61-
)
62-
self.generated = True
63-
return
64-
elif self.generated:
65-
return
66-
else:
67-
self.get_bom().validate()
68-
self._root_bom_element = self.get_bom().as_xml( # type: ignore
69-
view_=_view, as_string=False, xmlns=self.get_target_namespace()
70-
)
71-
self.generated = True
53+
if self.generated and not force_regeneration:
7254
return
7355

74-
def output_as_string(self) -> str:
56+
_view = SCHEMA_VERSIONS[self.schema_version_enum]
57+
self.get_bom().validate()
58+
xmlns = self.get_target_namespace()
59+
self._bom_xml = '<?xml version="1.0" ?>\n' + xml_dumps(
60+
self.get_bom().as_xml( # type:ignore[attr-defined]
61+
_view, as_string=False, xmlns=xmlns),
62+
method='xml', default_namespace=xmlns, encoding='unicode',
63+
# `xml-declaration` is inconsistent/bugged in py38, especially on Windows it will print a non-UTF8 codepage.
64+
# Furthermore, it might add an encoding of "utf-8" which is redundant default value of XML.
65+
# -> so we write the declaration manually, as long as py38 is supported.
66+
xml_declaration=False)
67+
68+
self.generated = True
69+
70+
@staticmethod
71+
def __make_indent(v: Optional[Union[int, str]]) -> str:
72+
if isinstance(v, int):
73+
return ' ' * v
74+
if isinstance(v, str):
75+
return v
76+
return ''
77+
78+
def output_as_string(self, *,
79+
indent: Optional[Union[int, str]] = None,
80+
**kwargs: Dict[str, Any]) -> str:
7581
self.generate()
76-
if self.generated and self._root_bom_element is not None:
77-
return str(Xml.XML_VERSION_DECLARATION + ElementTree.tostring(self._root_bom_element, encoding='unicode'))
78-
79-
raise BomGenerationErrorException('There was no Root XML Element after BOM generation.')
82+
return self._bom_xml if indent is None else dom_parseString(self._bom_xml).toprettyxml(
83+
indent=self.__make_indent(indent)
84+
# do not set `encoding` - this would convert result to binary, not string
85+
)
8086

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

8490

8591
class XmlV1Dot0(Xml, SchemaVersion1Dot0):
8692

87-
def _create_bom_element(self) -> ElementTree.Element:
88-
return ElementTree.Element('bom', {'xmlns': self.get_target_namespace(), 'version': '1'})
93+
def _create_bom_element(self) -> XmlElement:
94+
return XmlElement('bom', {'xmlns': self.get_target_namespace(), 'version': '1'})
8995

9096

9197
class XmlV1Dot1(Xml, SchemaVersion1Dot1):

examples/complex.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
# endregion build the BOM
5353

5454

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

66+
print('', '=' * 30, '', sep='\n')
67+
6668
my_outputter = get_outputter(bom, OutputFormat.XML, SchemaVersion.V1_4)
67-
serialized_xml = my_outputter.output_as_string()
69+
serialized_xml = my_outputter.output_as_string(indent=2)
6870
print(serialized_xml)
6971
try:
7072
validation_errors = get_validator(my_outputter.output_format,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ keywords = [
6060
# ATTENTION: keep `deps.lowest.r` file in sync
6161
python = "^3.8"
6262
packageurl-python = ">= 0.11"
63-
py-serializable = "^0.11.1"
63+
py-serializable = "^0.13.0"
6464
sortedcontainers = "^2.4.0"
6565
license-expression = "^30"
6666
jsonschema = { version = "^4.18", extras=['format'], optional=true, python="^3.8" }

tests/_data/own/.gitattributes

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
xml/*/*.xml linguist-generated
2-
json/*/*.josn linguist-generated
1+
* binary
2+
xml/*/*.xml linguist-generated diff=xml
3+
json/*/*.json linguist-generated diff=json

tests/_data/own/json/1.4/indented_4spaces.json

Lines changed: 71 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/_data/own/json/1.4/indented_None.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/_data/own/json/1.4/indented_tab.json

Lines changed: 71 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)