Skip to content

Commit 6b81028

Browse files
jkowallecksemantic-release
andauthored
feat!: finalize PEP639, auto-enable it, remove CLI switch environment --PEP-639 (#928)
## BREAKING Changes - Handling of PEP 639 changed, as the specification changed during finalization. - Handling of PEP 639 is always enabled, as Python Packing specification 2.4 [recognizes it](https://packaging.python.org/en/latest/specifications/license-expression/). - CLI parameter `environment --PEP-639` was removed. ## Changed - License texts from files are always Base64-encoded. In the past, we did best-effort text decoding; this was dropped for interoperability. This is considered a non-breaking change, as no data was removed, it was just transformed according to CycloneDX specification. - License text gathering was streamlined across PEP639 and PEP621. ## Added - Finalized implementation of PEP 639. ---- - fixes #843 - fixes #915 - fixes #931 ---- TODO/ DONE - [x] implement - [x] add tests --------- Signed-off-by: Jan Kowalleck <jan.kowalleck@gmail.com> Signed-off-by: semantic-release <semantic-release@bot.local> Co-authored-by: semantic-release <semantic-release@bot.local>
1 parent 5c783cd commit 6b81028

File tree

311 files changed

+7307
-14360
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

311 files changed

+7307
-14360
lines changed

.github/workflows/release.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ on:
66
release_force:
77
# see https://python-semantic-release.readthedocs.io/en/latest/github-action.html#command-line-options
88
description: |
9-
Force release be one of: [major | minor | patch]
9+
Force release be one of: [major | minor | patch | prerelease]
1010
Leave empty for auto-detect based on commit messages.
1111
type: choice
1212
options:
13-
- "" # auto - no force
14-
- major # force major
15-
- minor # force minor
16-
- patch # force patch
13+
- "" # auto - no force
14+
- major # force major
15+
- minor # force minor
16+
- patch # force patch
17+
- prerelease # force prerelease
1718
default: ""
1819
required: false
1920
prerelease_token:

cyclonedx_py/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
# !! version is managed by `semantic_release`
1919
# do not use typing here, or else `semantic_release` might have issues finding the variable
20-
__version__ = "6.1.3" # noqa:Q000
20+
__version__ = "7.0.1-alpha.2" # noqa:Q000
2121

2222
# There is no stable/public API.
2323
# However, you might call the stable CLI instead, like so:

cyclonedx_py/_internal/cli_common.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929
def add_argument_pyproject(p: 'ArgumentParser') -> 'Action':
3030
return p.add_argument('--pyproject',
3131
metavar='<file>',
32-
help="Path to the root component's `pyproject.toml` file. "
32+
help="Path to the root component's `pyproject.toml` file.\n"
3333
'This should point to a file compliant with PEP 621 '
34-
'(storing project metadata).',
34+
'(Storing project metadata in pyproject.toml). '
35+
'Supports PEP 639 (Improving License Clarity with Better Package Metadata). ',
3536
dest='pyproject_file',
3637
default=None)
3738

cyclonedx_py/_internal/environment.py

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,18 @@
2727
from textwrap import dedent
2828
from typing import TYPE_CHECKING, Any, Optional
2929

30+
from cyclonedx.factory.license import LicenseFactory
3031
from cyclonedx.model import Property
31-
from cyclonedx.model.component import ( # type:ignore[attr-defined] # ComponentEvidence was moved, but is still importable - ignore/wont-fix for backwards compatibility # noqa:E501
32-
Component,
33-
ComponentEvidence,
34-
ComponentType,
35-
)
32+
from cyclonedx.model.component import Component, ComponentType
3633
from packageurl import PackageURL
3734
from packaging.requirements import Requirement
3835

3936
from . import BomBuilder, PropertyName, PurlTypePypi
4037
from .cli_common import add_argument_mc_type, add_argument_pyproject
41-
from .utils.cdx import find_LicenseExpression, licenses_fixup, make_bom
38+
from .utils.cdx import licenses_fixup, make_bom
4239
from .utils.packaging import metadata2extrefs, metadata2licenses, normalize_packagename
4340
from .utils.pep610 import PackageSourceArchive, PackageSourceVcs, packagesource2extref, packagesource4dist
44-
from .utils.pep639 import dist2licenses as dist2licenses_pep639
41+
from .utils.pep639 import dist2licenses_from_files as pep639_dist2licenses_from_files
4542
from .utils.pyproject import pyproject2component, pyproject2dependencies, pyproject_load
4643

4744
if TYPE_CHECKING: # pragma: no cover
@@ -114,12 +111,6 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser':
114111
• Build an SBOM from uv environment:
115112
$ %(prog)s "$(uv python find)"
116113
""")
117-
p.add_argument('--PEP-639',
118-
action='store_true',
119-
dest='pep639',
120-
help='Enable license gathering according to PEP 639 '
121-
'(improving license clarity with better package metadata).\n'
122-
'The behavior may change during the draft development of the PEP.')
123114
p.add_argument('--gather-license-texts',
124115
action='store_true',
125116
dest='gather_license_texts',
@@ -140,11 +131,9 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser':
140131

141132
def __init__(self, *,
142133
logger: 'Logger',
143-
pep639: bool,
144134
gather_license_texts: bool,
145135
**__: Any) -> None:
146136
self._logger = logger
147-
self._pep639 = pep639
148137
self._gather_license_texts = gather_license_texts
149138

150139
def __call__(self, *, # type:ignore[override]
@@ -156,7 +145,10 @@ def __call__(self, *, # type:ignore[override]
156145
rc = None
157146
else:
158147
pyproject = pyproject_load(pyproject_file)
159-
root_c = pyproject2component(pyproject, ctype=mc_type, fpath=pyproject_file)
148+
root_c = pyproject2component(pyproject, ctype=mc_type,
149+
fpath=pyproject_file,
150+
gather_license_texts=self._gather_license_texts,
151+
logger=self._logger)
160152
root_c.bom_ref.value = 'root-component'
161153
root_d = tuple(pyproject2dependencies(pyproject))
162154
rc = (root_c, root_d)
@@ -189,25 +181,17 @@ def __add_components(self, bom: 'Bom',
189181
name=dist_name,
190182
version=dist_version,
191183
description=dist_meta['Summary'] if 'Summary' in dist_meta else None,
192-
licenses=licenses_fixup(metadata2licenses(dist_meta)),
193184
external_references=metadata2extrefs(dist_meta),
194185
# path of dist-package on disc? naaa... a package may have multiple files/folders on disc
195186
)
196-
if self._pep639:
197-
pep639_licenses = list(dist2licenses_pep639(dist, self._gather_license_texts, self._logger))
198-
pep639_lexp = find_LicenseExpression(pep639_licenses)
199-
if pep639_lexp is not None:
200-
component.licenses = (pep639_lexp,)
201-
pep639_licenses.remove(pep639_lexp)
202-
if len(pep639_licenses) > 0:
203-
if find_LicenseExpression(component.licenses) is None:
204-
component.licenses.update(pep639_licenses)
205-
else:
206-
# hack for preventing expressions AND named licenses.
207-
# see https://github.com/CycloneDX/cyclonedx-python/issues/826
208-
# see https://github.com/CycloneDX/specification/issues/454
209-
component.evidence = ComponentEvidence(licenses=pep639_licenses)
210-
del pep639_lexp, pep639_licenses
187+
188+
# region licenses
189+
component.licenses.update(metadata2licenses(dist_meta, LicenseFactory(),
190+
gather_texts=self._gather_license_texts))
191+
if self._gather_license_texts:
192+
component.licenses.update(pep639_dist2licenses_from_files(dist, logger=self._logger))
193+
licenses_fixup(component)
194+
# endregion licenses
211195

212196
del dist_meta, dist_name, dist_version
213197
self.__component_add_extref_and_purl(component, packagesource4dist(dist))

cyclonedx_py/_internal/pipenv.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ def __call__(self, *, # type:ignore[override]
132132
if pyproject_file is None:
133133
rc = None
134134
else:
135-
rc = pyproject_file2component(pyproject_file, ctype=mc_type)
135+
rc = pyproject_file2component(pyproject_file, ctype=mc_type,
136+
gather_license_texts=False, logger=self._logger)
136137
rc.bom_ref.value = 'root-component'
137138

138139
return self._make_bom(rc,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# This file is part of CycloneDX Python
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+
"""this package contains polyfills and alike, so that all pyhton-versions support the same feature set."""
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# This file is part of CycloneDX Python
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+
__all__ = ['glob']
19+
20+
import sys
21+
from glob import glob as _glob
22+
23+
if sys.version_info >= (3, 10):
24+
glob = _glob
25+
else:
26+
from os.path import join, sep
27+
from typing import Optional
28+
29+
def glob(pathname: str, *, root_dir: Optional[str] = None, recursive: bool = False) -> list[str]:
30+
if root_dir is not None:
31+
pathname = join(root_dir, pathname)
32+
files = _glob(pathname, recursive=recursive)
33+
if root_dir is not None:
34+
if not root_dir.endswith(sep):
35+
root_dir += sep
36+
files = [f.removeprefix(root_dir) for f in files]
37+
return files
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# This file is part of CycloneDX Python
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+
__all__ = ['PackageMetadata']
19+
20+
from typing import TYPE_CHECKING
21+
22+
if TYPE_CHECKING: # pragma: nocover
23+
import sys
24+
25+
if sys.version_info >= (3, 10):
26+
from importlib.metadata import PackageMetadata
27+
else:
28+
from email.message import Message as PackageMetadata

cyclonedx_py/_internal/requirements.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ def __call__(self, *, # type:ignore[override]
116116
if pyproject_file is None:
117117
rc = None
118118
else:
119-
rc = pyproject_file2component(pyproject_file, ctype=mc_type)
119+
rc = pyproject_file2component(pyproject_file, ctype=mc_type,
120+
gather_license_texts=False, logger=self._logger)
120121
rc.bom_ref.value = 'root-component'
121122

122123
if requirements_file == '-':

cyclonedx_py/_internal/utils/cdx.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
from cyclonedx.builder.this import this_component as lib_component
2828
from cyclonedx.model import ExternalReference, ExternalReferenceType, XsUri
2929
from cyclonedx.model.bom import Bom
30-
from cyclonedx.model.component import Component, ComponentType
30+
from cyclonedx.model.component import ( # type:ignore[attr-defined] # ComponentEvidence was moved, but is still importable - ignore/wont-fix for backwards compatibility # noqa:E501
31+
Component,
32+
ComponentEvidence,
33+
ComponentType,
34+
)
3135
from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression
3236

3337
from ... import __version__ as _THIS_VERSION # noqa:N812
@@ -95,11 +99,24 @@ def find_LicenseExpression(licenses: Iterable['License']) -> Optional[LicenseExp
9599
return None
96100

97101

98-
def licenses_fixup(licenses: Iterable['License']) -> Iterable['License']:
99-
licenses = set(licenses)
100-
if (lexp := find_LicenseExpression(licenses)) is not None:
101-
return (lexp,)
102-
return licenses
102+
def licenses_fixup(component: 'Component') -> None:
103+
"""
104+
Per CycloneDX spec, there must be EITHER one license expression OR multiple license id/name.
105+
If there is an expression, it is used and everything else is moved to evidences, so it is not lost.
106+
"""
107+
# hack for preventing expressions AND named licenses.
108+
# see https://github.com/CycloneDX/cyclonedx-python/issues/826
109+
# see https://github.com/CycloneDX/specification/issues/454
110+
licenses = list(component.licenses)
111+
lexp = find_LicenseExpression(licenses)
112+
if lexp is None:
113+
return
114+
component.licenses = (lexp,)
115+
licenses.remove(lexp)
116+
if len(licenses) > 0:
117+
if component.evidence is None:
118+
component.evidence = ComponentEvidence()
119+
component.evidence.licenses.update(licenses)
103120

104121

105122
_MAP_KNOWN_URL_LABELS: dict[str, ExternalReferenceType] = {

0 commit comments

Comments
 (0)