Skip to content

Commit 7e95a90

Browse files
committed
feat: finalize PEP639
Signed-off-by: Jan Kowalleck <[email protected]>
1 parent 78fbbc9 commit 7e95a90

File tree

17 files changed

+152
-77
lines changed

17 files changed

+152
-77
lines changed

cyclonedx_py/_internal/cli_common.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ def add_argument_pyproject(p: 'ArgumentParser') -> 'Action':
3030
return p.add_argument('--pyproject',
3131
metavar='<file>',
3232
help="Path to the root component's `pyproject.toml` file. "
33-
'This should point to a file compliant with PEP 621 '
34-
'(storing project metadata).',
33+
'This should point to a file compliant with PEP 621 (storing project metadata).',
3534
dest='pyproject_file',
3635
default=None)
3736

cyclonedx_py/_internal/environment.py

Lines changed: 16 additions & 30 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 as pep639_dist2licenses
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',
@@ -156,7 +147,7 @@ def __call__(self, *, # type:ignore[override]
156147
rc = None
157148
else:
158149
pyproject = pyproject_load(pyproject_file)
159-
root_c = pyproject2component(pyproject, ctype=mc_type, fpath=pyproject_file)
150+
root_c = pyproject2component(pyproject, ctype=mc_type, fpath=pyproject_file, gather_license_texts=False)
160151
root_c.bom_ref.value = 'root-component'
161152
root_d = tuple(pyproject2dependencies(pyproject))
162153
rc = (root_c, root_d)
@@ -189,25 +180,20 @@ def __add_components(self, bom: 'Bom',
189180
name=dist_name,
190181
version=dist_version,
191182
description=dist_meta['Summary'] if 'Summary' in dist_meta else None,
192-
licenses=licenses_fixup(metadata2licenses(dist_meta)),
193183
external_references=metadata2extrefs(dist_meta),
194184
# path of dist-package on disc? naaa... a package may have multiple files/folders on disc
195185
)
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
186+
187+
# region licenses
188+
lfac = LicenseFactory()
189+
component.licenses.update(pep639_dist2licenses(dist, lfac, self._gather_license_texts, self._logger))
190+
if len(component.licenses) == 0:
191+
# According to PEP 639 spec, if licenses are declared in the "new" style,
192+
# all other license declarations MUST be ignored.
193+
# https://peps.python.org/pep-0639/#converting-legacy-metadata
194+
component.licenses.update(metadata2licenses(dist_meta, lfac))
195+
licenses_fixup(component)
196+
# endregion licenses
211197

212198
del dist_meta, dist_name, dist_version
213199
self.__component_add_extref_and_purl(component, packagesource4dist(dist))

cyclonedx_py/_internal/pipenv.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ 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, gather_license_texts=False)
136136
rc.bom_ref.value = 'root-component'
137137

138138
return self._make_bom(rc,

cyclonedx_py/_internal/requirements.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ 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, gather_license_texts=False)
120120
rc.bom_ref.value = 'root-component'
121121

122122
if requirements_file == '-':

cyclonedx_py/_internal/utils/cdx.py

Lines changed: 19 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,20 @@ 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+
licenses = list(component.licenses)
108+
lexp = find_LicenseExpression(licenses)
109+
if lexp is None:
110+
return
111+
component.licenses = (lexp,)
112+
licenses.remove(lexp)
113+
if component.evidence is None:
114+
component.evidence = ComponentEvidence()
115+
component.evidence.licenses.update(licenses)
103116

104117

105118
_MAP_KNOWN_URL_LABELS: dict[str, ExternalReferenceType] = {

cyclonedx_py/_internal/utils/packaging.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from typing import TYPE_CHECKING
2121

2222
from cyclonedx.exception.model import InvalidUriException
23-
from cyclonedx.factory.license import LicenseFactory
2423
from cyclonedx.model import AttachedText, ExternalReference, ExternalReferenceType, XsUri
2524
from cyclonedx.model.license import DisjunctiveLicense, LicenseAcknowledgement
2625

@@ -30,6 +29,7 @@
3029
if TYPE_CHECKING: # pragma: no cover
3130
import sys
3231

32+
from cyclonedx.factory.license import LicenseFactory
3333
from cyclonedx.model.license import License
3434

3535
if sys.version_info >= (3, 10):
@@ -38,8 +38,7 @@
3838
from email.message import Message as PackageMetadata
3939

4040

41-
def metadata2licenses(metadata: 'PackageMetadata') -> Generator['License', None, None]:
42-
lfac = LicenseFactory()
41+
def metadata2licenses(metadata: 'PackageMetadata', lfac: 'LicenseFactory') -> Generator['License', None, None]:
4342
lack = LicenseAcknowledgement.DECLARED
4443
if 'Classifier' in metadata:
4544
# see spec: https://packaging.python.org/en/latest/specifications/core-metadata/#classifier-multiple-use

cyclonedx_py/_internal/utils/pep621.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,16 @@
2929
from typing import TYPE_CHECKING, Any
3030

3131
from cyclonedx.exception.model import InvalidUriException
32-
from cyclonedx.factory.license import LicenseFactory
3332
from cyclonedx.model import AttachedText, Encoding, ExternalReference, XsUri
3433
from cyclonedx.model.component import Component
3534
from cyclonedx.model.license import DisjunctiveLicense, LicenseAcknowledgement
3635
from packaging.requirements import Requirement
3736

38-
from .cdx import licenses_fixup, url_label_to_ert
37+
from .cdx import url_label_to_ert
3938
from .license_trove_classifier import is_license_trove, license_trove2spdx
4039

4140
if TYPE_CHECKING:
41+
from cyclonedx.factory.license import LicenseFactory
4242
from cyclonedx.model.component import ComponentType
4343
from cyclonedx.model.license import License
4444

@@ -52,7 +52,8 @@ def classifiers2licenses(classifiers: Iterable[str], lfac: 'LicenseFactory',
5252
license_acknowledgement=lack)
5353

5454

55-
def project2licenses(project: dict[str, Any], lfac: 'LicenseFactory', *,
55+
def project2licenses(project: dict[str, Any], lfac: 'LicenseFactory',
56+
gather_text: bool, *,
5657
fpath: str) -> Generator['License', None, None]:
5758
lack = LicenseAcknowledgement.DECLARED
5859
if classifiers := project.get('classifiers'):
@@ -68,10 +69,11 @@ def project2licenses(project: dict[str, Any], lfac: 'LicenseFactory', *,
6869
# per spec:
6970
# > These keys are mutually exclusive, so a tool MUST raise an error if the metadata specifies both keys.
7071
raise ValueError('`license.file` and `license.text` are mutually exclusive,')
71-
if 'file' in plicense:
72+
if gather_text and 'file' in plicense:
7273
# per spec:
7374
# > [...] a string value that is a relative file path [...].
7475
# > Tools MUST assume the file’s encoding is UTF-8.
76+
# anyway, we don't trust this and assume binary
7577
with open(join(dirname(fpath), plicense['file']), 'rb') as plicense_fileh:
7678
yield DisjunctiveLicense(name=f"declared license of '{project['name']}'",
7779
acknowledgement=lack,
@@ -80,7 +82,7 @@ def project2licenses(project: dict[str, Any], lfac: 'LicenseFactory', *,
8082
elif len(plicense_text := plicense.get('text', '')) > 0:
8183
license = lfac.make_from_string(plicense_text,
8284
license_acknowledgement=lack)
83-
if isinstance(license, DisjunctiveLicense) and license.id is None:
85+
if isinstance(license, DisjunctiveLicense) and license.id is None and gather_text:
8486
# per spec, `License` is either a SPDX ID/Expression, or a license text(not name!)
8587
yield DisjunctiveLicense(name=f"declared license of '{project['name']}'",
8688
acknowledgement=lack,
@@ -103,15 +105,15 @@ def project2extrefs(project: dict[str, Any]) -> Generator['ExternalReference', N
103105

104106

105107
def project2component(project: dict[str, Any], *,
106-
ctype: 'ComponentType', fpath: str) -> 'Component':
108+
ctype: 'ComponentType') -> 'Component':
107109
dynamic = project.get('dynamic', ())
108110
return Component(
109111
type=ctype,
110112
name=project['name'],
111113
version=project.get('version', None) if 'version' not in dynamic else None,
112114
description=project.get('description', None) if 'description' not in dynamic else None,
113-
licenses=licenses_fixup(project2licenses(project, LicenseFactory(), fpath=fpath)),
114115
external_references=project2extrefs(project),
116+
# licenses are not gathered here per default, they may be sourced otherwise
115117
# TODO add more properties according to spec
116118
)
117119

cyclonedx_py/_internal/utils/pep639.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@
2323

2424
from base64 import b64encode
2525
from collections.abc import Generator
26-
from os.path import join
27-
from typing import TYPE_CHECKING
26+
from glob import glob
27+
from os.path import dirname, join
28+
from typing import TYPE_CHECKING, Any
2829

29-
from cyclonedx.factory.license import LicenseFactory
3030
from cyclonedx.model import AttachedText, Encoding
3131
from cyclonedx.model.license import DisjunctiveLicense, LicenseAcknowledgement
3232

@@ -37,26 +37,53 @@
3737
from importlib.metadata import Distribution
3838
from logging import Logger
3939

40+
from cyclonedx.factory.license import LicenseFactory
4041
from cyclonedx.model.license import License
4142

43+
44+
def project2licenses(project: dict[str, Any], lfac: 'LicenseFactory',
45+
gather_texts: bool, *,
46+
fpath: str) -> Generator['License', None, None]:
47+
lack = LicenseAcknowledgement.DECLARED
48+
if isinstance(plicense := project.get('license'), str) \
49+
and len(plicense) > 0:
50+
# https://peps.python.org/pep-0639/#add-string-value-to-license-key
51+
yield lfac.make_from_string(plicense,
52+
license_acknowledgement=lack)
53+
if gather_texts and isinstance(plfiles := project.get('license-files'), list):
54+
# https://peps.python.org/pep-0639/#add-license-files-key
55+
plfiles_root = dirname(fpath)
56+
for plfile_glob in plfiles:
57+
for plfile in glob(plfile_glob, root_dir=plfiles_root):
58+
# per spec:
59+
# > Tools MUST assume that license file content is valid UTF-8 encoded text
60+
# anyway, we don't trust this and assume binary
61+
with open(join(plfiles_root, plfile), 'rb') as plicense_fileh:
62+
yield DisjunctiveLicense(name=f'declared license file: {plfile}',
63+
acknowledgement=lack,
64+
text=AttachedText(encoding=Encoding.BASE_64,
65+
content=b64encode(plicense_fileh.read()).decode()))
66+
# Silently skip any other types (including string/PEP 621)
67+
return None
68+
69+
4270
# per spec > license files are stored in the `.dist-info/licenses/` subdirectory of the produced wheel.
4371
# but in practice, other locations are used, too.
4472
_LICENSE_LOCATIONS = ('licenses', 'license_files', '')
4573

4674

4775
def dist2licenses(
48-
dist: 'Distribution',
49-
gather_text: bool,
76+
dist: 'Distribution', lfac: 'LicenseFactory',
77+
gather_texts: bool,
5078
logger: 'Logger'
5179
) -> Generator['License', None, None]:
52-
lfac = LicenseFactory()
5380
lack = LicenseAcknowledgement.DECLARED
5481
metadata = dist.metadata # see https://packaging.python.org/en/latest/specifications/core-metadata/
5582
if (lexp := metadata['License-Expression']) is not None:
5683
# see spec: https://peps.python.org/pep-0639/#add-license-expression-field
5784
yield lfac.make_from_string(lexp,
5885
license_acknowledgement=lack)
59-
if gather_text:
86+
if gather_texts:
6087
for mlfile in set(metadata.get_all('License-File', ())):
6188
# see spec: https://peps.python.org/pep-0639/#add-license-file-field
6289
# latest spec rev: https://discuss.python.org/t/pep-639-round-3-improving-license-clarity-with-better-package-metadata/53020 # noqa: E501

cyclonedx_py/_internal/utils/poetry.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737

3838
if TYPE_CHECKING:
3939
from cyclonedx.model.component import ComponentType
40-
from cyclonedx.model.license import License
4140

4241

4342
def poetry2extrefs(poetry: dict[str, Any]) -> Generator['ExternalReference', None, None]:
@@ -64,26 +63,27 @@ def poetry2extrefs(poetry: dict[str, Any]) -> Generator['ExternalReference', Non
6463

6564

6665
def poetry2component(poetry: dict[str, Any], *, ctype: 'ComponentType') -> 'Component':
67-
licenses: list['License'] = []
68-
lfac = LicenseFactory()
69-
lack = LicenseAcknowledgement.DECLARED
70-
if 'classifiers' in poetry:
71-
licenses.extend(classifiers2licenses(poetry['classifiers'], lfac, lack))
72-
if 'license' in poetry:
73-
# per spec(https://python-poetry.org/docs/pyproject#license):
74-
# the `license` is intended to be the name of a license, not the license text itself.
75-
licenses.append(lfac.make_from_string(poetry['license'],
76-
license_acknowledgement=lack))
77-
78-
return Component(
66+
component = Component(
7967
type=ctype,
8068
name=poetry['name'],
8169
version=poetry.get('version'),
8270
description=poetry.get('description'),
83-
licenses=licenses_fixup(licenses),
8471
external_references=poetry2extrefs(poetry),
8572
# TODO add more properties according to spec
8673
)
74+
# region licenses
75+
lfac = LicenseFactory()
76+
lack = LicenseAcknowledgement.DECLARED
77+
if 'classifiers' in poetry:
78+
component.licenses.update(classifiers2licenses(poetry['classifiers'], lfac, lack))
79+
if 'license' in poetry:
80+
# per spec(https://python-poetry.org/docs/pyproject#license):
81+
# the `license` is intended to be the name of a license, not the license text itself.
82+
component.licenses.add(lfac.make_from_string(poetry['license'],
83+
license_acknowledgement=lack))
84+
licenses_fixup(component)
85+
# endregion licenses
86+
return component
8787

8888

8989
def poetry2dependencies(poetry: dict[str, Any]) -> Generator['Requirement', None, None]:

0 commit comments

Comments
 (0)