Skip to content

Commit

Permalink
Merge pull request #305 from CycloneDX/license-factories
Browse files Browse the repository at this point in the history
feat: add license factories to more easily support creation of `License` or `LicenseChoice` from SPDX license strings #304
  • Loading branch information
madpah authored Sep 15, 2022
2 parents 92aea8d + fd4d537 commit 5ff4494
Show file tree
Hide file tree
Showing 9 changed files with 448 additions and 1 deletion.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ html/
/.mypy_cache

# Exlude built docs
docs/_build
docs/_build/
docs/autoapi/

45 changes: 45 additions & 0 deletions cyclonedx/exception/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# encoding: utf-8

# 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
#

"""
Exceptions relating to specific conditions that occur when factoring a model.
"""

from . import CycloneDxException


class CycloneDxFactoryException(CycloneDxException):
"""
Base exception that covers all exceptions that may be thrown during model factoring..
"""
pass


class LicenseChoiceFactoryException(CycloneDxFactoryException):
pass


class InvalidSpdxLicenseException(LicenseChoiceFactoryException):
pass


class LicenseFactoryException(CycloneDxFactoryException):
pass


class InvalidLicenseExpressionException(LicenseFactoryException):
pass
16 changes: 16 additions & 0 deletions cyclonedx/factory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# encoding: utf-8

# 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.
85 changes: 85 additions & 0 deletions cyclonedx/factory/license.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# encoding: utf-8

# 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.

from typing import Optional

from ..exception.factory import InvalidLicenseExpressionException, InvalidSpdxLicenseException
from ..model import AttachedText, License, LicenseChoice, XsUri
from ..spdx import fixup_id as spdx_fixup, is_compound_expression as is_spdx_compound_expression


class LicenseFactory:
"""Factory for :class:`cyclonedx.model.License`."""

def make_from_string(self, name_or_spdx: str, *,
license_text: Optional[AttachedText] = None,
license_url: Optional[XsUri] = None) -> License:
"""Make a :class:`cyclonedx.model.License` from a string."""
try:
return self.make_with_id(name_or_spdx, license_text=license_text, license_url=license_url)
except InvalidSpdxLicenseException:
return self.make_with_name(name_or_spdx, license_text=license_text, license_url=license_url)

def make_with_id(self, spdx_id: str, *,
license_text: Optional[AttachedText] = None,
license_url: Optional[XsUri] = None) -> License:
"""Make a :class:`cyclonedx.model.License` from an SPDX-ID.
:raises InvalidSpdxLicenseException: if `spdx_id` was not known/supported SPDX-ID
"""
spdx_license_id = spdx_fixup(spdx_id)
if spdx_license_id is None:
raise InvalidSpdxLicenseException(spdx_id)
return License(spdx_license_id=spdx_license_id, license_text=license_text, license_url=license_url)

def make_with_name(self, name: str, *,
license_text: Optional[AttachedText] = None,
license_url: Optional[XsUri] = None) -> License:
"""Make a :class:`cyclonedx.model.License` with a name."""
return License(license_name=name, license_text=license_text, license_url=license_url)


class LicenseChoiceFactory:
"""Factory for :class:`cyclonedx.model.LicenseChoice`."""

def __init__(self, *, license_factory: LicenseFactory) -> None:
self.license_factory = license_factory

def make_from_string(self, expression_or_name_or_spdx: str) -> LicenseChoice:
"""Make a :class:`cyclonedx.model.LicenseChoice` from a string."""
try:
return self.make_with_compound_expression(expression_or_name_or_spdx)
except InvalidLicenseExpressionException:
return self.make_with_license(expression_or_name_or_spdx)

def make_with_compound_expression(self, compound_expression: str) -> LicenseChoice:
"""Make a :class:`cyclonedx.model.LicenseChoice` with a compound expression.
Utilizes :func:`cyclonedx.spdx.is_compound_expression`.
:raises InvalidLicenseExpressionException: if `expression` is not known/supported license expression
"""
if is_spdx_compound_expression(compound_expression):
return LicenseChoice(license_expression=compound_expression)
raise InvalidLicenseExpressionException(compound_expression)

def make_with_license(self, name_or_spdx: str, *,
license_text: Optional[AttachedText] = None,
license_url: Optional[XsUri] = None) -> LicenseChoice:
"""Make a :class:`cyclonedx.model.LicenseChoice` with a license (name or SPDX-ID)."""
return LicenseChoice(license_=self.license_factory.make_from_string(
name_or_spdx, license_text=license_text, license_url=license_url))
60 changes: 60 additions & 0 deletions cyclonedx/spdx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# encoding: utf-8

# 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

__all__ = ['is_supported_id', 'fixup_id', 'is_compound_expression']

from json import load as json_load
from os.path import dirname, join as path_join
from typing import Dict, Optional, Set

# region init
# python's internal module loader will assure that this init-part runs only once.

# !!! this requires to ship the actual schema data with the package.
with open(path_join(dirname(__file__), 'schema', 'spdx.schema.json')) as schema:
__IDS: Set[str] = set(json_load(schema).get('enum', []))
assert len(__IDS) > 0, 'known SPDX-IDs should be non-empty set'

__IDS_LOWER_MAP: Dict[str, str] = dict((id_.lower(), id_) for id_ in __IDS)


# endregion

def is_supported_id(value: str) -> bool:
"""Validate a SPDX-ID according to current spec."""
return value in __IDS


def fixup_id(value: str) -> Optional[str]:
"""Fixup a SPDX-ID.
:returns: repaired value string, or `None` if fixup was unable to help.
"""
return __IDS_LOWER_MAP.get(value.lower())


def is_compound_expression(value: str) -> bool:
"""Validate compound expression.
.. note::
Uses a best-effort detection of SPDX compound expression according to `SPDX license expression spec`_.
.. _SPDX license expression spec: https://spdx.github.io/spdx-spec/v2.3/SPDX-license-expressions/
"""
# shortest known valid expression: (A or B) - 8 characters long
return len(value) >= 8 \
and value.startswith('(') \
and value.endswith(')')
9 changes: 9 additions & 0 deletions poetry.lock

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

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ sortedcontainers = "^2.4.0"

[tool.poetry.dev-dependencies]
tox = "^3.25.0"
ddt = "^1.6.0"
coverage = "^6.2"
mypy = ">= 0.920, <= 0.961"
autopep8 = "^1.6.0"
Expand Down
134 changes: 134 additions & 0 deletions tests/test_factory_license.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# 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.

import unittest
import unittest.mock

from cyclonedx.exception.factory import InvalidLicenseExpressionException, InvalidSpdxLicenseException
from cyclonedx.factory.license import LicenseChoiceFactory, LicenseFactory
from cyclonedx.model import AttachedText, License, LicenseChoice, XsUri


class TestFactoryLicense(unittest.TestCase):

def test_make_from_string_with_id(self) -> None:
text = unittest.mock.NonCallableMock(spec=AttachedText)
url = unittest.mock.NonCallableMock(spec=XsUri)
expected = License(spdx_license_id='bar', license_text=text, license_url=url)
factory = LicenseFactory()

with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value='bar'):
actual = factory.make_from_string('foo', license_text=text, license_url=url)

self.assertEqual(expected, actual)

def test_make_from_string_with_name(self) -> None:
text = unittest.mock.NonCallableMock(spec=AttachedText)
url = unittest.mock.NonCallableMock(spec=XsUri)
expected = License(license_name='foo', license_text=text, license_url=url)
factory = LicenseFactory()

with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value=None):
actual = factory.make_from_string('foo', license_text=text, license_url=url)

self.assertEqual(expected, actual)

def test_make_with_id(self) -> None:
text = unittest.mock.NonCallableMock(spec=AttachedText)
url = unittest.mock.NonCallableMock(spec=XsUri)
expected = License(spdx_license_id='bar', license_text=text, license_url=url)
factory = LicenseFactory()

with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value='bar'):
actual = factory.make_with_id('foo', license_text=text, license_url=url)

self.assertEqual(expected, actual)

def test_make_with_id_raises(self) -> None:
factory = LicenseFactory()
with self.assertRaises(InvalidSpdxLicenseException, msg='foo'):
with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value=None):
factory.make_with_id('foo')

def test_make_with_name(self) -> None:
text = unittest.mock.NonCallableMock(spec=AttachedText)
url = unittest.mock.NonCallableMock(spec=XsUri)
expected = License(license_name='foo', license_text=text, license_url=url)
factory = LicenseFactory()

actual = factory.make_with_name('foo', license_text=text, license_url=url)

self.assertEqual(expected, actual)


class TestFactoryLicenseChoice(unittest.TestCase):

def test_make_from_string_with_compound_expression(self) -> None:
expected = LicenseChoice(license_expression='foo')
factory = LicenseChoiceFactory(license_factory=unittest.mock.MagicMock(spec=LicenseFactory))

with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=True):
actual = factory.make_from_string('foo')

self.assertEqual(expected, actual)

def test_make_from_string_with_license(self) -> None:
license_ = unittest.mock.NonCallableMock(spec=License)
expected = LicenseChoice(license_=license_)
license_factory = unittest.mock.MagicMock(spec=LicenseFactory)
license_factory.make_from_string.return_value = license_
factory = LicenseChoiceFactory(license_factory=license_factory)

with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=False):
actual = factory.make_from_string('foo')

self.assertEqual(expected, actual)
self.assertIs(license_, actual.license)
license_factory.make_from_string.assert_called_once_with('foo', license_text=None, license_url=None)

def test_make_with_compound_expression(self) -> None:
expected = LicenseChoice(license_expression='foo')
factory = LicenseChoiceFactory(license_factory=unittest.mock.MagicMock(spec=LicenseFactory))

with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=True):
actual = factory.make_with_compound_expression('foo')

self.assertEqual(expected, actual)

def test_make_with_compound_expression_raises(self) -> None:
factory = LicenseChoiceFactory(license_factory=unittest.mock.MagicMock(spec=LicenseFactory))
with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=False):
with self.assertRaises(InvalidLicenseExpressionException, msg='foo'):
factory.make_with_compound_expression('foo')

def test_make_with_license(self) -> None:
text = unittest.mock.NonCallableMock(spec=AttachedText)
url = unittest.mock.NonCallableMock(spec=XsUri)
license_ = unittest.mock.NonCallableMock(spec=License)
expected = LicenseChoice(license_=license_)
license_factory = unittest.mock.MagicMock(spec=LicenseFactory)
license_factory.make_from_string.return_value = license_
factory = LicenseChoiceFactory(license_factory=license_factory)

with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=False):
actual = factory.make_with_license('foo', license_text=text, license_url=url)

self.assertEqual(expected, actual)
self.assertIs(license_, actual.license)
license_factory.make_from_string.assert_called_once_with('foo', license_text=text, license_url=url)
Loading

0 comments on commit 5ff4494

Please sign in to comment.