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: environment licenses as proper SPDX #576

Merged
merged 9 commits into from
Sep 6, 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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Get it all applied via:

```shell
poetry run isort .
poetry run flake8 cyclonedx/ tests/
poetry run flake8 cyclonedx_py/ tests/
```

## Documentation
Expand Down
23 changes: 23 additions & 0 deletions cyclonedx_py/_internal/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 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.

"""
This package is internal - it is not public API.
All in here may have breaking change without notice.
"""
170 changes: 170 additions & 0 deletions cyclonedx_py/_internal/license_trove_classifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# 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.

"""
This module is internal - it is not public API.
All in here may have breaking change without notice.
"""

__all__ = [
'PREFIX_LICENSE', 'PREFIX_OSI_APPROVED',
'tidy', 'to_spdx',
]


from typing import Optional

PREFIX_LICENSE = 'License :: '
PREFIX_OSI_APPROVED = 'OSI Approved :: '


"""
Map of trove classifiers to SPDX license ID or SPDX license expression.

Some could be mapped to SPDX expressions, in case the version was not clear - like `(EFL-1.0 OR EFL-2.0)`.
! But this was not done yet, for uncertainties of [PEP639](https://peps.python.org/pep-0639)

classifiers: https://packaging.python.org/specifications/core-metadata/#metadata-classifier
- of list A: https://pypi.org/pypi?%3Aaction=list_classifiers
- of lList B: https://pypi.org/classifiers/

SPDX license IDs: https://spdx.org/licenses/

See also: https://peps.python.org/pep-0639/#mapping-license-classifiers-to-spdx-identifiers
"""
__TO_SPDX_MAP = {
# region not OSI Approved
'License :: Aladdin Free Public License (AFPL)': 'Aladdin',
'License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication': 'CC0-1.0',
'License :: CeCILL-B Free Software License Agreement (CECILL-B)': ' CECILL-B',
'License :: CeCILL-C Free Software License Agreement (CECILL-C)': 'CECILL-C',
# 'License :: Eiffel Forum License (EFL)': which one?
# - EFL-1.0
# - EFL-2.0
# 'License :: Free For Educational Use': unknown to SPDX
# 'License :: Free For Home Use': unknown to SPDX
# 'License :: Free To Use But Restricted': unknown to SPDX
# 'License :: Free for non-commercial use': unknown to SPDX
# 'License :: Freely Distributable': unknown to SPDX
# 'License :: Freeware': unknown to SPDX
'License :: GUST Font License 1.0': '',
# 'License :: GUST Font License 2006-09-30': unknown to SPDX
# 'License :: Netscape Public License (NPL)': which version?
# - NPL-1.0
# - NPL-1.1
'License :: Nokia Open Source License (NOKOS)': 'Nokia',
# 'License :: Other/Proprietary License': unknown to SPDX
# 'License :: Public Domain': unknown to SPDX
# 'License :: Repoze Public License': unknown to SPDX
# endregion not OSI Approved
# region OSI Approved
# !! reminder: the following are OSI approved, sp map only to the SPDX that ar marked as so
# !! see the ideas and cases of https://peps.python.org/pep-0639/#mapping-license-classifiers-to-spdx-identifiers
# 'License :: OSI Approved :: Academic Free License (AFL)': which one?
# - AFL-1.1
# - AFL-3.0
# 'License :: OSI Approved :: Apache Software License': which one?
# - Apache-1.1
# - Apache-2.0
# 'License :: OSI Approved :: Apple Public Source License': which version?
# - APSL-1.0
# - APSL-2.0
# 'License :: OSI Approved :: Artistic License': which version?
'License :: OSI Approved :: Attribution Assurance License': 'AAL',
# 'License :: OSI Approved :: BSD License': which exactly?
'License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0)': ' BSL-1.0',
'License :: OSI Approved :: CEA CNRS Inria Logiciel Libre License, version 2.1 (CeCILL-2.1)': ' CECILL-2.1',
'License :: OSI Approved :: Common Development and Distribution License 1.0 (CDDL-1.0)': 'CDDL-1.0',
'License :: OSI Approved :: Common Public License': ' CPL-1.0',
'License :: OSI Approved :: Eclipse Public License 1.0 (EPL-1.0)': 'EPL-1.0',
'License :: OSI Approved :: Eclipse Public License 2.0 (EPL-2.0)': 'EPL-1.0',
# 'License :: OSI Approved :: Eiffel Forum License': which version?
'License :: OSI Approved :: European Union Public Licence 1.0 (EUPL 1.0)': 'EUPL-1.0',
'License :: OSI Approved :: European Union Public Licence 1.1 (EUPL 1.1)': 'EUPL-1.1',
'License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)': 'EUPL-1.2',
'License :: OSI Approved :: GNU Affero General Public License v3': 'AGPL-3.0-only',
'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)': 'AGPL-3.0-or-later',
# 'License :: OSI Approved :: GNU Free Documentation License (FDL)': which version?
# 'License :: OSI Approved :: GNU General Public License (GPL)': which version?
'License :: OSI Approved :: GNU General Public License v2 (GPLv2)': 'GPL-2.0-only',
'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)': 'GPL-2.0-or-later',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)': 'GPL-3.0-only',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)': 'GPL-3.0-or-later',
# 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)': which one?
# - LGPL-2.0-only
# - LGPL-2.1-only
# 'License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)': which one?
# - LGPL-2.0-or-later
# - LGPL-2.1-or-later
'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)': 'LGPL-3.0-only',
'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)': 'LGPL-3.0-or-later',
# 'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)': which version?
'License :: OSI Approved :: Historical Permission Notice and Disclaimer (HPND)': 'HPND',
'License :: OSI Approved :: IBM Public License': 'IPL-1.0',
'License :: OSI Approved :: ISC License (ISCL)': 'ISC',
'License :: OSI Approved :: Intel Open Source License': 'Intel',
# 'License :: OSI Approved :: Jabber Open Source License': unknown to SPDX
'License :: OSI Approved :: MIT License': 'MIT',
'License :: OSI Approved :: MIT No Attribution License (MIT-0)': 'MIT-0',
# 'License :: OSI Approved :: MITRE Collaborative Virtual Workspace License (CVW)': unknown to SPDX
'License :: OSI Approved :: MirOS License (MirOS)': 'MirOS',
'License :: OSI Approved :: Motosoto License': 'Motosoto',
'License :: OSI Approved :: Mozilla Public License 1.0 (MPL)': 'MPL-1.0',
'License :: OSI Approved :: Mozilla Public License 1.1 (MPL 1.1)': 'MPL-1.1',
# 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)': which one? MPL-XXX
# - MPL-2.0
# - MPL-2.0-no-copyleft-exception
'License :: OSI Approved :: Mulan Permissive Software License v2 (MulanPSL-2.0)': 'MulanPSL-1.0',
'License :: OSI Approved :: Nethack General Public License': 'NGPL',
'License :: OSI Approved :: Nokia Open Source License': 'Nokia',
'License :: OSI Approved :: Open Group Test Suite License': 'OGTSL',
'License :: OSI Approved :: Open Software License 3.0 (OSL-3.0)': 'OSL-3.0',
'License :: OSI Approved :: PostgreSQL License': 'PostgreSQL',
'License :: OSI Approved :: Python License (CNRI Python License)': 'CNRI-Python',
'License :: OSI Approved :: Python Software Foundation License': 'Python-2.0',
'License :: OSI Approved :: Qt Public License (QPL)': 'QPL-1.0',
'License :: OSI Approved :: Ricoh Source Code Public License': 'RSCPL',
# 'License :: OSI Approved :: SIL Open Font License 1.1 (OFL-1.1)': which one? OFL-XXX
# - OFL-1.1
# - OFL-1.1-no-RFN OFL-1.1-RFN
'License :: OSI Approved :: Sleepycat License': 'Sleepycat',
'License :: OSI Approved :: Sun Industry Standards Source License (SISSL)': 'SISSL',
'License :: OSI Approved :: Sun Public License': 'SPL-1.0',
'License :: OSI Approved :: The Unlicense (Unlicense)': 'Unlicense',
'License :: OSI Approved :: Universal Permissive License (UPL)': 'UPL-1.0',
'License :: OSI Approved :: University of Illinois/NCSA Open Source License': 'NCSA',
'License :: OSI Approved :: Vovida Software License 1.0': 'VSL-1.0',
'License :: OSI Approved :: W3C License': 'W3C',
'License :: OSI Approved :: X.Net License': 'Xnet',
# 'License :: OSI Approved :: Zope Public License': which one? ZPL-XXX
# - ZPL-2.0
# - ZPL-2.1
'License :: OSI Approved :: zlib/libpng License': 'Zlib',
# endregion OSI Approved
}


def to_spdx(classifier: str) -> Optional[str]:
"""return the SPDX id or expression for a given license trove classifier"""
return __TO_SPDX_MAP.get(classifier)


def tidy(classifier: str) -> str:
"""strip license trove classifier prefixes"""
return classifier.replace(PREFIX_LICENSE, '').replace(PREFIX_OSI_APPROVED, '')
30 changes: 15 additions & 15 deletions cyclonedx_py/parser/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,15 @@
else:
from importlib_metadata import metadata, PackageMetadata as _MetadataReturn

from cyclonedx.model import License, LicenseChoice
from cyclonedx.factory.license import LicenseChoiceFactory, LicenseFactory
from cyclonedx.model.component import Component
from cyclonedx.parser import BaseParser

from .._internal.license_trove_classifier import (
PREFIX_LICENSE as _LTC_PREFIX,
tidy as _ltc_tidy,
to_spdx as _ltc_to_spdx,
)
from ._debug import DebugMessageCallback, quiet


Expand All @@ -70,6 +75,8 @@ def __init__(
debug_message('late import pkg_resources')
import pkg_resources

lcfac = LicenseChoiceFactory(license_factory=LicenseFactory())

debug_message('processing pkg_resources.working_set')
i: Distribution
for i in iter(pkg_resources.working_set):
Expand All @@ -81,12 +88,12 @@ def __init__(
i_metadata = self._get_metadata_for_package(i.project_name)
debug_message('processing i_metadata')
if 'Author' in i_metadata:
debug_message('processing i_metadata Author: {!r}', i_metadata['Author'])
c.author = i_metadata['Author']
if 'License' in i_metadata and i_metadata['License'] and i_metadata['License'] != 'UNKNOWN':
# Values might be ala `MIT` (SPDX id), `Apache-2.0 license` (arbitrary string), ...
# Therefore, just go with a named license.
debug_message('processing i_metadata License: {!r}', i_metadata['License'])
try:
c.licenses.add(LicenseChoice(license=License(name=i_metadata['License'])))
c.licenses.add(lcfac.make_from_string(i_metadata['License']))
except CycloneDxModelException as error:
# @todo traceback and details to the output?
debug_message('Warning: suppressed {!r}', error)
Expand All @@ -95,18 +102,11 @@ def __init__(
debug_message('processing classifiers')
for classifier in i_metadata.get_all("Classifier", []):
debug_message('processing classifier: {!r}', classifier)
classifier = str(classifier)
# Trove classifiers - https://packaging.python.org/specifications/core-metadata/#metadata-classifier
# Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
if classifier.startswith('License :: OSI Approved :: '):
license_name = classifier.replace('License :: OSI Approved :: ', '').strip()
elif classifier.startswith('License :: '):
license_name = classifier.replace('License :: ', '').strip()
else:
license_name = ''
if license_name:
classifier = str(classifier).strip()
if classifier.startswith(_LTC_PREFIX):
license_string = _ltc_to_spdx(classifier) or _ltc_tidy(classifier)
try:
c.licenses.add(LicenseChoice(license=License(name=license_name)))
c.licenses.add(lcfac.make_from_string(license_string))
except CycloneDxModelException as error:
# @todo traceback and details to the output?
debug_message('Warning: suppressed {!r}', error)
Expand Down
10 changes: 4 additions & 6 deletions tests/test_parser_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,8 @@ def test_simple(self) -> None:
self.assertIsNotNone(c_tox)
self.assertNotEqual(c_tox.purl.to_string(), c_tox.bom_ref.value)
self.assertIsNotNone(c_tox.licenses)
self.assertEqual(len(c_tox.licenses), 2)
self.assertEqual({LicenseChoice(license=License(name="MIT License")),
LicenseChoice(license=License(name="MIT"))}, c_tox.licenses)
self.assertEqual(len(c_tox.licenses), 1)
self.assertEqual({LicenseChoice(license=License(id="MIT"))}, c_tox.licenses)

def test_simple_use_purl_bom_ref(self) -> None:
"""
Expand All @@ -60,6 +59,5 @@ def test_simple_use_purl_bom_ref(self) -> None:
self.assertIsNotNone(c_tox)
self.assertEqual(c_tox.purl.to_string(), c_tox.bom_ref.value)
self.assertIsNotNone(c_tox.licenses)
self.assertEqual(len(c_tox.licenses), 2)
self.assertEqual({LicenseChoice(license=License(name="MIT License")),
LicenseChoice(license=License(name="MIT"))}, c_tox.licenses)
self.assertEqual(len(c_tox.licenses), 1)
self.assertEqual({LicenseChoice(license=License(id="MIT"))}, c_tox.licenses)