Skip to content

Commit 5ff4494

Browse files
authored
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
2 parents 92aea8d + fd4d537 commit 5ff4494

File tree

9 files changed

+448
-1
lines changed

9 files changed

+448
-1
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,6 @@ html/
2828
/.mypy_cache
2929

3030
# Exlude built docs
31-
docs/_build
31+
docs/_build/
32+
docs/autoapi/
33+

cyclonedx/exception/factory.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# encoding: utf-8
2+
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
#
17+
18+
"""
19+
Exceptions relating to specific conditions that occur when factoring a model.
20+
"""
21+
22+
from . import CycloneDxException
23+
24+
25+
class CycloneDxFactoryException(CycloneDxException):
26+
"""
27+
Base exception that covers all exceptions that may be thrown during model factoring..
28+
"""
29+
pass
30+
31+
32+
class LicenseChoiceFactoryException(CycloneDxFactoryException):
33+
pass
34+
35+
36+
class InvalidSpdxLicenseException(LicenseChoiceFactoryException):
37+
pass
38+
39+
40+
class LicenseFactoryException(CycloneDxFactoryException):
41+
pass
42+
43+
44+
class InvalidLicenseExpressionException(LicenseFactoryException):
45+
pass

cyclonedx/factory/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# encoding: utf-8
2+
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.

cyclonedx/factory/license.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# encoding: utf-8
2+
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
from typing import Optional
19+
20+
from ..exception.factory import InvalidLicenseExpressionException, InvalidSpdxLicenseException
21+
from ..model import AttachedText, License, LicenseChoice, XsUri
22+
from ..spdx import fixup_id as spdx_fixup, is_compound_expression as is_spdx_compound_expression
23+
24+
25+
class LicenseFactory:
26+
"""Factory for :class:`cyclonedx.model.License`."""
27+
28+
def make_from_string(self, name_or_spdx: str, *,
29+
license_text: Optional[AttachedText] = None,
30+
license_url: Optional[XsUri] = None) -> License:
31+
"""Make a :class:`cyclonedx.model.License` from a string."""
32+
try:
33+
return self.make_with_id(name_or_spdx, license_text=license_text, license_url=license_url)
34+
except InvalidSpdxLicenseException:
35+
return self.make_with_name(name_or_spdx, license_text=license_text, license_url=license_url)
36+
37+
def make_with_id(self, spdx_id: str, *,
38+
license_text: Optional[AttachedText] = None,
39+
license_url: Optional[XsUri] = None) -> License:
40+
"""Make a :class:`cyclonedx.model.License` from an SPDX-ID.
41+
42+
:raises InvalidSpdxLicenseException: if `spdx_id` was not known/supported SPDX-ID
43+
"""
44+
spdx_license_id = spdx_fixup(spdx_id)
45+
if spdx_license_id is None:
46+
raise InvalidSpdxLicenseException(spdx_id)
47+
return License(spdx_license_id=spdx_license_id, license_text=license_text, license_url=license_url)
48+
49+
def make_with_name(self, name: str, *,
50+
license_text: Optional[AttachedText] = None,
51+
license_url: Optional[XsUri] = None) -> License:
52+
"""Make a :class:`cyclonedx.model.License` with a name."""
53+
return License(license_name=name, license_text=license_text, license_url=license_url)
54+
55+
56+
class LicenseChoiceFactory:
57+
"""Factory for :class:`cyclonedx.model.LicenseChoice`."""
58+
59+
def __init__(self, *, license_factory: LicenseFactory) -> None:
60+
self.license_factory = license_factory
61+
62+
def make_from_string(self, expression_or_name_or_spdx: str) -> LicenseChoice:
63+
"""Make a :class:`cyclonedx.model.LicenseChoice` from a string."""
64+
try:
65+
return self.make_with_compound_expression(expression_or_name_or_spdx)
66+
except InvalidLicenseExpressionException:
67+
return self.make_with_license(expression_or_name_or_spdx)
68+
69+
def make_with_compound_expression(self, compound_expression: str) -> LicenseChoice:
70+
"""Make a :class:`cyclonedx.model.LicenseChoice` with a compound expression.
71+
72+
Utilizes :func:`cyclonedx.spdx.is_compound_expression`.
73+
74+
:raises InvalidLicenseExpressionException: if `expression` is not known/supported license expression
75+
"""
76+
if is_spdx_compound_expression(compound_expression):
77+
return LicenseChoice(license_expression=compound_expression)
78+
raise InvalidLicenseExpressionException(compound_expression)
79+
80+
def make_with_license(self, name_or_spdx: str, *,
81+
license_text: Optional[AttachedText] = None,
82+
license_url: Optional[XsUri] = None) -> LicenseChoice:
83+
"""Make a :class:`cyclonedx.model.LicenseChoice` with a license (name or SPDX-ID)."""
84+
return LicenseChoice(license_=self.license_factory.make_from_string(
85+
name_or_spdx, license_text=license_text, license_url=license_url))

cyclonedx/spdx.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# encoding: utf-8
2+
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
17+
__all__ = ['is_supported_id', 'fixup_id', 'is_compound_expression']
18+
19+
from json import load as json_load
20+
from os.path import dirname, join as path_join
21+
from typing import Dict, Optional, Set
22+
23+
# region init
24+
# python's internal module loader will assure that this init-part runs only once.
25+
26+
# !!! this requires to ship the actual schema data with the package.
27+
with open(path_join(dirname(__file__), 'schema', 'spdx.schema.json')) as schema:
28+
__IDS: Set[str] = set(json_load(schema).get('enum', []))
29+
assert len(__IDS) > 0, 'known SPDX-IDs should be non-empty set'
30+
31+
__IDS_LOWER_MAP: Dict[str, str] = dict((id_.lower(), id_) for id_ in __IDS)
32+
33+
34+
# endregion
35+
36+
def is_supported_id(value: str) -> bool:
37+
"""Validate a SPDX-ID according to current spec."""
38+
return value in __IDS
39+
40+
41+
def fixup_id(value: str) -> Optional[str]:
42+
"""Fixup a SPDX-ID.
43+
44+
:returns: repaired value string, or `None` if fixup was unable to help.
45+
"""
46+
return __IDS_LOWER_MAP.get(value.lower())
47+
48+
49+
def is_compound_expression(value: str) -> bool:
50+
"""Validate compound expression.
51+
52+
.. note::
53+
Uses a best-effort detection of SPDX compound expression according to `SPDX license expression spec`_.
54+
55+
.. _SPDX license expression spec: https://spdx.github.io/spdx-spec/v2.3/SPDX-license-expressions/
56+
"""
57+
# shortest known valid expression: (A or B) - 8 characters long
58+
return len(value) >= 8 \
59+
and value.startswith('(') \
60+
and value.endswith(')')

poetry.lock

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

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ sortedcontainers = "^2.4.0"
5151

5252
[tool.poetry.dev-dependencies]
5353
tox = "^3.25.0"
54+
ddt = "^1.6.0"
5455
coverage = "^6.2"
5556
mypy = ">= 0.920, <= 0.961"
5657
autopep8 = "^1.6.0"

tests/test_factory_license.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# encoding: utf-8
2+
3+
# This file is part of CycloneDX Python Lib
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
# SPDX-License-Identifier: Apache-2.0
18+
# Copyright (c) OWASP Foundation. All Rights Reserved.
19+
20+
import unittest
21+
import unittest.mock
22+
23+
from cyclonedx.exception.factory import InvalidLicenseExpressionException, InvalidSpdxLicenseException
24+
from cyclonedx.factory.license import LicenseChoiceFactory, LicenseFactory
25+
from cyclonedx.model import AttachedText, License, LicenseChoice, XsUri
26+
27+
28+
class TestFactoryLicense(unittest.TestCase):
29+
30+
def test_make_from_string_with_id(self) -> None:
31+
text = unittest.mock.NonCallableMock(spec=AttachedText)
32+
url = unittest.mock.NonCallableMock(spec=XsUri)
33+
expected = License(spdx_license_id='bar', license_text=text, license_url=url)
34+
factory = LicenseFactory()
35+
36+
with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value='bar'):
37+
actual = factory.make_from_string('foo', license_text=text, license_url=url)
38+
39+
self.assertEqual(expected, actual)
40+
41+
def test_make_from_string_with_name(self) -> None:
42+
text = unittest.mock.NonCallableMock(spec=AttachedText)
43+
url = unittest.mock.NonCallableMock(spec=XsUri)
44+
expected = License(license_name='foo', license_text=text, license_url=url)
45+
factory = LicenseFactory()
46+
47+
with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value=None):
48+
actual = factory.make_from_string('foo', license_text=text, license_url=url)
49+
50+
self.assertEqual(expected, actual)
51+
52+
def test_make_with_id(self) -> None:
53+
text = unittest.mock.NonCallableMock(spec=AttachedText)
54+
url = unittest.mock.NonCallableMock(spec=XsUri)
55+
expected = License(spdx_license_id='bar', license_text=text, license_url=url)
56+
factory = LicenseFactory()
57+
58+
with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value='bar'):
59+
actual = factory.make_with_id('foo', license_text=text, license_url=url)
60+
61+
self.assertEqual(expected, actual)
62+
63+
def test_make_with_id_raises(self) -> None:
64+
factory = LicenseFactory()
65+
with self.assertRaises(InvalidSpdxLicenseException, msg='foo'):
66+
with unittest.mock.patch('cyclonedx.factory.license.spdx_fixup', return_value=None):
67+
factory.make_with_id('foo')
68+
69+
def test_make_with_name(self) -> None:
70+
text = unittest.mock.NonCallableMock(spec=AttachedText)
71+
url = unittest.mock.NonCallableMock(spec=XsUri)
72+
expected = License(license_name='foo', license_text=text, license_url=url)
73+
factory = LicenseFactory()
74+
75+
actual = factory.make_with_name('foo', license_text=text, license_url=url)
76+
77+
self.assertEqual(expected, actual)
78+
79+
80+
class TestFactoryLicenseChoice(unittest.TestCase):
81+
82+
def test_make_from_string_with_compound_expression(self) -> None:
83+
expected = LicenseChoice(license_expression='foo')
84+
factory = LicenseChoiceFactory(license_factory=unittest.mock.MagicMock(spec=LicenseFactory))
85+
86+
with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=True):
87+
actual = factory.make_from_string('foo')
88+
89+
self.assertEqual(expected, actual)
90+
91+
def test_make_from_string_with_license(self) -> None:
92+
license_ = unittest.mock.NonCallableMock(spec=License)
93+
expected = LicenseChoice(license_=license_)
94+
license_factory = unittest.mock.MagicMock(spec=LicenseFactory)
95+
license_factory.make_from_string.return_value = license_
96+
factory = LicenseChoiceFactory(license_factory=license_factory)
97+
98+
with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=False):
99+
actual = factory.make_from_string('foo')
100+
101+
self.assertEqual(expected, actual)
102+
self.assertIs(license_, actual.license)
103+
license_factory.make_from_string.assert_called_once_with('foo', license_text=None, license_url=None)
104+
105+
def test_make_with_compound_expression(self) -> None:
106+
expected = LicenseChoice(license_expression='foo')
107+
factory = LicenseChoiceFactory(license_factory=unittest.mock.MagicMock(spec=LicenseFactory))
108+
109+
with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=True):
110+
actual = factory.make_with_compound_expression('foo')
111+
112+
self.assertEqual(expected, actual)
113+
114+
def test_make_with_compound_expression_raises(self) -> None:
115+
factory = LicenseChoiceFactory(license_factory=unittest.mock.MagicMock(spec=LicenseFactory))
116+
with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=False):
117+
with self.assertRaises(InvalidLicenseExpressionException, msg='foo'):
118+
factory.make_with_compound_expression('foo')
119+
120+
def test_make_with_license(self) -> None:
121+
text = unittest.mock.NonCallableMock(spec=AttachedText)
122+
url = unittest.mock.NonCallableMock(spec=XsUri)
123+
license_ = unittest.mock.NonCallableMock(spec=License)
124+
expected = LicenseChoice(license_=license_)
125+
license_factory = unittest.mock.MagicMock(spec=LicenseFactory)
126+
license_factory.make_from_string.return_value = license_
127+
factory = LicenseChoiceFactory(license_factory=license_factory)
128+
129+
with unittest.mock.patch('cyclonedx.factory.license.is_spdx_compound_expression', return_value=False):
130+
actual = factory.make_with_license('foo', license_text=text, license_url=url)
131+
132+
self.assertEqual(expected, actual)
133+
self.assertIs(license_, actual.license)
134+
license_factory.make_from_string.assert_called_once_with('foo', license_text=text, license_url=url)

0 commit comments

Comments
 (0)