-
-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #305 from CycloneDX/license-factories
feat: add license factories to more easily support creation of `License` or `LicenseChoice` from SPDX license strings #304
- Loading branch information
Showing
9 changed files
with
448 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,4 +28,6 @@ html/ | |
/.mypy_cache | ||
|
||
# Exlude built docs | ||
docs/_build | ||
docs/_build/ | ||
docs/autoapi/ | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(')') |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.