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

tests: snapshots and complete deep comparison, instead of pseudo-compare #464

Merged
merged 22 commits into from
Oct 8, 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
4 changes: 3 additions & 1 deletion cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,9 @@ def validate(self) -> bool:
# 1. Make sure dependencies are all in this Bom.
all_bom_refs = set(map(lambda c: c.bom_ref, self._get_all_components())) | set(
map(lambda s: s.bom_ref, self.services))
all_dependency_bom_refs = set().union(*(d.dependencies_as_bom_refs() for d in self.dependencies))
all_dependency_bom_refs = set(chain((d.ref for d in self.dependencies),
chain.from_iterable(
d.dependencies_as_bom_refs() for d in self.dependencies)))

dependency_diff = all_dependency_bom_refs - all_bom_refs
if len(dependency_diff) > 0:
Expand Down
5 changes: 1 addition & 4 deletions cyclonedx/model/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from enum import Enum
from os.path import exists
from typing import Any, Iterable, Optional, Set, Union
from uuid import uuid4

# See https://github.com/package-url/packageurl-python/issues/65
import serializable
Expand Down Expand Up @@ -769,7 +768,7 @@ def __init__(self, *, name: str, type: ComponentType = ComponentType.LIBRARY,
if isinstance(bom_ref, BomRef):
self._bom_ref = bom_ref
else:
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else str(uuid4()))
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else None)
self.supplier = supplier
self.author = author
self.publisher = publisher
Expand Down Expand Up @@ -809,8 +808,6 @@ def __init__(self, *, name: str, type: ComponentType = ComponentType.LIBRARY,
if not licenses:
self.licenses = [LicenseChoice(expression=license_str)] # type: ignore

self.__dependencies: "SortedSet[BomRef]" = SortedSet()
jkowalleck marked this conversation as resolved.
Show resolved Hide resolved

@property
@serializable.xml_attribute()
def type(self) -> ComponentType:
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/model/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,4 @@ class Dependable(ABC):
@property
@abstractmethod
def bom_ref(self) -> BomRef:
pass
...
3 changes: 1 addition & 2 deletions cyclonedx/model/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.

from typing import Any, Iterable, Optional, Union
from uuid import uuid4

import serializable
from sortedcontainers import SortedSet
Expand Down Expand Up @@ -66,7 +65,7 @@ def __init__(self, *, name: str, bom_ref: Optional[Union[str, BomRef]] = None,
if isinstance(bom_ref, BomRef):
self._bom_ref = bom_ref
else:
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else str(uuid4()))
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else None)
self.provider = provider
self.group = group
self.name = name
Expand Down
3 changes: 1 addition & 2 deletions cyclonedx/model/vulnerability.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from decimal import Decimal
from enum import Enum
from typing import Any, Iterable, Optional, Tuple, Union
from uuid import uuid4

import serializable
from sortedcontainers import SortedSet
Expand Down Expand Up @@ -837,7 +836,7 @@ def __init__(self, *, bom_ref: Optional[Union[str, BomRef]] = None, id: Optional
if isinstance(bom_ref, BomRef):
self._bom_ref = bom_ref
else:
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else str(uuid4()))
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else None)
self.id = id
self.source = source
self.references = references or [] # type: ignore
Expand Down
3 changes: 3 additions & 0 deletions cyclonedx/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ class OutputFormat(Enum):
def __hash__(self) -> int:
return hash(self.name)

def __eq__(self, other: Any) -> bool:
return self is other


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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ keywords = [
# ATTENTION: keep `deps.lowest.r` file in sync
python = "^3.8"
packageurl-python = ">= 0.11"
py-serializable = "^0.14.0"
py-serializable = "^0.14.1"
sortedcontainers = "^2.4.0"
license-expression = "^30"
jsonschema = { version = "^4.18", extras=['format'], optional=true }
Expand Down
132 changes: 130 additions & 2 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,137 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.

from os import getenv, path
from os.path import join
from typing import TYPE_CHECKING, Any, Generator, Iterable, List, Optional, TypeVar, Union
from unittest import TestCase
from uuid import UUID

TESTDATA_DIRECTORY = path.join(path.dirname(__file__), '_data')
from sortedcontainers import SortedSet

RECREATE_SNAPSHOTS = bool(getenv('CDX_TEST_RECREATE_SNAPSHOTS'))
from cyclonedx.schema import OutputFormat, SchemaVersion

if TYPE_CHECKING:
from cyclonedx.model.bom import Bom
from cyclonedx.model.dependency import Dependency

_T = TypeVar('_T')

_TESTDATA_DIRECTORY = path.join(path.dirname(__file__), '_data')

SCHEMA_TESTDATA_DIRECTORY = path.join(_TESTDATA_DIRECTORY, 'schemaTestData')
OWN_DATA_DIRECTORY = path.join(_TESTDATA_DIRECTORY, 'own')
SNAPSHOTS_DIRECTORY = path.join(_TESTDATA_DIRECTORY, 'snapshots')

RECREATE_SNAPSHOTS = '1' == getenv('CDX_TEST_RECREATE_SNAPSHOTS')
if RECREATE_SNAPSHOTS:
print('!!! WILL RECREATE ALL SNAPSHOTS !!!')


class SnapshotMixin:

@staticmethod
def getSnapshotFile(snapshot_name: str) -> str:
return join(SNAPSHOTS_DIRECTORY, f'{snapshot_name}.bin')

@classmethod
def writeSnapshot(cls, snapshot_name: str, data: str) -> None:
with open(cls.getSnapshotFile(snapshot_name), 'w') as s:
s.write(data)

@classmethod
def readSnapshot(cls, snapshot_name: str) -> str:
with open(cls.getSnapshotFile(snapshot_name), 'r') as s:
return s.read()

def assertEqualSnapshot(self: Union[TestCase, 'SnapshotMixin'], actual: str, snapshot_name: str) -> None:
if RECREATE_SNAPSHOTS:
self.writeSnapshot(snapshot_name, actual)
_omd = self.maxDiff
_omd = self.maxDiff
self.maxDiff = None
try:
self.assertEqual(actual, self.readSnapshot(snapshot_name))
finally:
self.maxDiff = _omd


class DeepCompareMixin:
def assertDeepEqual(self: Union[TestCase, 'DeepCompareMixin'], first: Any, second: Any,
msg: Optional[str] = None) -> None:
"""costly compare, but very verbose"""
_omd = self.maxDiff
self.maxDiff = None
try:
self.maxDiff = None
dd1 = self.__deepDict(first)
dd2 = self.__deepDict(second)
self.assertDictEqual(dd1, dd2, msg)
finally:
self.maxDiff = _omd

def __deepDict(self, o: Any) -> Any:
if isinstance(o, tuple):
return tuple(self.__deepDict(i) for i in o)
if isinstance(o, list):
return list(self.__deepDict(i) for i in o)
if isinstance(o, dict):
return {k: self.__deepDict(v) for k, v in o.items()}
if isinstance(o, (set, SortedSet)):
# this method returns dict. `dict` is not hashable, so use `tuple` instead.
return tuple(self.__deepDict(i) for i in sorted(o, key=hash)) + ('%conv:%set',)
if hasattr(o, '__dict__'):
d = {a: self.__deepDict(v) for a, v in o.__dict__.items() if '__' not in a}
d['%conv'] = str(type(o))
return d
return o

def assertBomDeepEqual(self: Union[TestCase, 'DeepCompareMixin'], expected: 'Bom', actual: 'Bom',
msg: Optional[str] = None, *,
fuzzy_deps: bool = True) -> None:
# deps might have been upgraded on serialization, so they might differ
edeps = expected.dependencies
adeps = actual.dependencies
if fuzzy_deps:
expected.dependencies = []
actual.dependencies = []
try:
self.assertDeepEqual(expected, actual, msg)
if fuzzy_deps:
self._assertDependenciesFuzzyEqual(edeps, adeps)
finally:
expected.dependencies = edeps
actual.dependencies = adeps

def _assertDependenciesFuzzyEqual(self: TestCase, a: Iterable['Dependency'], b: Iterable['Dependency']) -> None:
delta = set(a) ^ set(b)
for d in delta:
# only actual relevant dependencies shall be taken into account.
self.assertEqual(0, len(d.dependencies), f'unexpected dependencies for {d.ref}')


def reorder(items: List[_T], indexes: List[int]) -> List[_T]:
"""
Return list of items in the order indicated by indexes.
"""
reordered_items = []
for i in range(len(items)):
reordered_items.append(items[indexes[i]])
return reordered_items


def uuid_generator(offset: int = 0, version: int = 4) -> Generator[UUID, None, None]:
v = offset
while True:
v += 1
yield UUID(int=v, version=version)


_SNAME_EXT = {
OutputFormat.JSON: 'json',
OutputFormat.XML: 'xml',
}


def mksname(purpose: Union[Any], sv: SchemaVersion, f: OutputFormat) -> str:
purpose = purpose if isinstance(purpose, str) else purpose.__name__
return f'{purpose}-{sv.to_version()}.{_SNAME_EXT[f]}'
18 changes: 18 additions & 0 deletions tests/_data/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# encoding: utf-8

# This file is part of CycloneDX Python Lib
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.
Loading