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: options for beautiful output #458

Merged
merged 4 commits into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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