Skip to content
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 cyclonedx_py/_internal/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def __call__(self, *, # type:ignore[override]
rc = None
else:
pyproject = pyproject_load(pyproject_file)
root_c = pyproject2component(pyproject, type=mc_type)
root_c = pyproject2component(pyproject, ctype=mc_type, fpath=pyproject_file)
root_c.bom_ref.value = 'root-component'
root_d = tuple(pyproject2dependencies(pyproject))
rc = (root_c, root_d)
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx_py/_internal/pipenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def __call__(self, *, # type:ignore[override]
if pyproject_file is None:
rc = None
else:
rc = pyproject_file2component(pyproject_file, type=mc_type)
rc = pyproject_file2component(pyproject_file, ctype=mc_type)
rc.bom_ref.value = 'root-component'

return self._make_bom(rc,
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx_py/_internal/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ def _make_bom(self, project: 'T_NameDict', locker: 'T_NameDict',

po_cfg = project['tool']['poetry']

bom.metadata.component = root_c = poetry2component(po_cfg, type=mc_type)
bom.metadata.component = root_c = poetry2component(po_cfg, ctype=mc_type)
root_c.bom_ref.value = root_c.name
root_c.properties.update(
Property(
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx_py/_internal/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def __call__(self, *, # type:ignore[override]
if pyproject_file is None:
rc = None
else:
rc = pyproject_file2component(pyproject_file, type=mc_type)
rc = pyproject_file2component(pyproject_file, ctype=mc_type)
rc.bom_ref.value = 'root-component'

if requirements_file == '-':
Expand Down
13 changes: 10 additions & 3 deletions cyclonedx_py/_internal/utils/packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@

from cyclonedx.exception.model import InvalidUriException
from cyclonedx.factory.license import LicenseFactory
from cyclonedx.model import ExternalReference, ExternalReferenceType, XsUri
from cyclonedx.model import AttachedText, ExternalReference, ExternalReferenceType, XsUri
from cyclonedx.model.license import DisjunctiveLicense

from .cdx import url_label_to_ert
from .pep621 import classifiers2licenses
Expand All @@ -42,9 +43,15 @@ def metadata2licenses(metadata: 'PackageMetadata') -> Generator['License', None,
# see https://packaging.python.org/en/latest/specifications/core-metadata/#classifier-multiple-use
classifiers: List[str] = metadata.get_all('Classifier') # type:ignore[assignment]
yield from classifiers2licenses(classifiers, lfac)
if 'License' in metadata:
if 'License' in metadata and len(mlicense := metadata['License']) > 0:
# see https://packaging.python.org/en/latest/specifications/core-metadata/#license
yield lfac.make_from_string(metadata['License'])
license = lfac.make_from_string(mlicense)
if isinstance(license, DisjunctiveLicense) and license.id is None:
# per spec, `License` is either a SPDX ID/Expression, or a license text(not name!)
yield DisjunctiveLicense(name=f"declared license of '{metadata['Name']}'",
text=AttachedText(content=mlicense))
else:
yield license


def metadata2extrefs(metadata: 'PackageMetadata') -> Generator['ExternalReference', None, None]:
Expand Down
49 changes: 35 additions & 14 deletions cyclonedx_py/_internal/utils/pep621.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,23 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.


"""
Functionality related to PEP 621.

See https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
See https://peps.python.org/pep-0621/
"""

from base64 import b64encode
from itertools import chain
from os.path import dirname, join
from typing import TYPE_CHECKING, Any, Dict, Generator, Iterable, Iterator

from cyclonedx.exception.model import InvalidUriException
from cyclonedx.factory.license import LicenseFactory
from cyclonedx.model import ExternalReference, XsUri
from cyclonedx.model import AttachedText, Encoding, ExternalReference, XsUri
from cyclonedx.model.component import Component
from cyclonedx.model.license import DisjunctiveLicense
from packaging.requirements import Requirement

from .cdx import licenses_fixup, url_label_to_ert
Expand All @@ -50,18 +52,37 @@ def classifiers2licenses(classifiers: Iterable[str], lfac: 'LicenseFactory') ->
classifiers)))


def project2licenses(project: Dict[str, Any], lfac: 'LicenseFactory') -> Generator['License', None, None]:
if 'classifiers' in project:
def project2licenses(project: Dict[str, Any], lfac: 'LicenseFactory', *,
fpath: str) -> Generator['License', None, None]:
if classifiers := project.get('classifiers'):
# https://packaging.python.org/en/latest/specifications/pyproject-toml/#classifiers
# https://peps.python.org/pep-0621/#classifiers
# https://packaging.python.org/en/latest/specifications/core-metadata/#classifier-multiple-use
yield from classifiers2licenses(project['classifiers'], lfac)
license = project.get('license')
# https://packaging.python.org/en/latest/specifications/pyproject-toml/#license
# https://peps.python.org/pep-0621/#license
# https://packaging.python.org/en/latest/specifications/core-metadata/#license
if isinstance(license, dict) and 'text' in license:
yield lfac.make_from_string(license['text'])
yield from classifiers2licenses(classifiers, lfac)
if plicense := project.get('license'):
# https://packaging.python.org/en/latest/specifications/pyproject-toml/#license
# https://peps.python.org/pep-0621/#license
# https://packaging.python.org/en/latest/specifications/core-metadata/#license
if 'file' in plicense and 'text' in plicense:
# per spec:
# > These keys are mutually exclusive, so a tool MUST raise an error if the metadata specifies both keys.
raise ValueError('`license.file` and `license.text` are mutually exclusive,')
if 'file' in plicense:
# per spec:
# > [...] a string value that is a relative file path [...].
# > Tools MUST assume the file’s encoding is UTF-8.
with open(join(dirname(fpath), plicense['file']), 'rb') as plicense_fileh:
yield DisjunctiveLicense(name=f"declared license of '{project['name']}'",
text=AttachedText(encoding=Encoding.BASE_64,
content=b64encode(plicense_fileh.read()).decode()))
elif len(plicense_text := plicense.get('text', '')) > 0:
license = lfac.make_from_string(plicense_text)
if isinstance(license, DisjunctiveLicense) and license.id is None:
# per spec, `License` is either a SPDX ID/Expression, or a license text(not name!)
yield DisjunctiveLicense(name=f"declared license of '{project['name']}'",
text=AttachedText(content=plicense_text))
else:
yield license


def project2extrefs(project: Dict[str, Any]) -> Generator['ExternalReference', None, None]:
Expand All @@ -77,14 +98,14 @@ def project2extrefs(project: Dict[str, Any]) -> Generator['ExternalReference', N


def project2component(project: Dict[str, Any], *,
type: 'ComponentType') -> 'Component':
ctype: 'ComponentType', fpath: str) -> 'Component':
dynamic = project.get('dynamic', ())
return Component(
type=type,
type=ctype,
name=project['name'],
version=project.get('version', None) if 'version' not in dynamic else None,
description=project.get('description', None) if 'description' not in dynamic else None,
licenses=licenses_fixup(project2licenses(project, LicenseFactory())),
licenses=licenses_fixup(project2licenses(project, LicenseFactory(), fpath=fpath)),
external_references=project2extrefs(project),
# TODO add more properties according to spec
)
Expand Down
7 changes: 4 additions & 3 deletions cyclonedx_py/_internal/utils/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,18 @@ def poetry2extrefs(poetry: Dict[str, Any]) -> Generator['ExternalReference', Non
pass


def poetry2component(poetry: Dict[str, Any], *, type: 'ComponentType') -> 'Component':
def poetry2component(poetry: Dict[str, Any], *, ctype: 'ComponentType') -> 'Component':
licenses: List['License'] = []
lfac = LicenseFactory()
if 'classifiers' in poetry:
licenses.extend(classifiers2licenses(poetry['classifiers'], lfac))
if 'license' in poetry:
# per spec(https://python-poetry.org/docs/pyproject#license):
# the `license` is intended to be the name of a license, not the license text itself.
licenses.append(lfac.make_from_string(poetry['license']))
del lfac

return Component(
type=type,
type=ctype,
name=poetry['name'],
version=poetry.get('version'),
description=poetry.get('description'),
Expand Down
14 changes: 7 additions & 7 deletions cyclonedx_py/_internal/utils/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@


def pyproject2component(data: Dict[str, Any], *,
type: 'ComponentType') -> 'Component':
ctype: 'ComponentType', fpath: str) -> 'Component':
tool = data.get('tool', {})
if 'poetry' in tool:
return poetry2component(tool['poetry'], type=type)
if 'project' in data:
return project2component(data['project'], type=type)
if poetry := tool.get('poetry'):
return poetry2component(poetry, ctype=ctype)
if project := data.get('project'):
return project2component(project, ctype=ctype, fpath=fpath)
raise ValueError('Unable to build component from pyproject')


Expand All @@ -33,10 +33,10 @@ def pyproject_load(pyproject_file: str) -> Dict[str, Any]:


def pyproject_file2component(pyproject_file: str, *,
type: 'ComponentType') -> 'Component':
ctype: 'ComponentType') -> 'Component':
return pyproject2component(
pyproject_load(pyproject_file),
type=type
ctype=ctype, fpath=pyproject_file
)


Expand Down
1 change: 0 additions & 1 deletion tests/_data/infiles/.gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
*.bin binary
*.txt.bin binary diff=text

5 changes: 4 additions & 1 deletion tests/_data/infiles/_helpers/local_pckages/a/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
build via `python -m build`
build via :
```shell
python -m build
```
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "package-a"
version = "23.42"
description = "some package A"
license = {text = "Apache-2.0"}
license = { text = "some license text" } # intentional not a SPDX ID/Expression
authors = []
requires-python = ">=3.8"

Expand Down
5 changes: 4 additions & 1 deletion tests/_data/infiles/_helpers/local_pckages/b/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
build via `python -m build`
build via
```shell
python -m build
```
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
name = "package-b"
version = "23.42"
description = "some package B"
license = {text = "Apache-2.0"}
license = { text = "Apache-2.0" } # intentional same as a classifier
authors = []
requires-python = ">=3.8"
classifiers = [
"License :: OSI Approved :: Apache Software License"
]

[tool.setuptools]
py-modules = ["module_b"]
Expand Down
5 changes: 4 additions & 1 deletion tests/_data/infiles/_helpers/local_pckages/c/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
build via `python -m build`
build via
```shell
python -m build
```
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion tests/_data/infiles/_helpers/local_pckages/c/module_c.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@


"""
module B
module C
"""
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
name = "package-c"
version = "23.42"
description = "some package C"
license = {text = "Apache-2.0"}
license = { text = "Apache-2.0 OR MIT" } # intentional a SPDX Expression
authors = []
requires-python = ">=3.8"
classifiers = [
"License :: OSI Approved :: Apache Software License",
"License :: OSI Approved :: MIT License"
]

[tool.setuptools]
py-modules = ["module_c"]
Expand Down
24 changes: 24 additions & 0 deletions tests/_data/infiles/environment/with-license-file/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
initialize this testbed.
"""

from os import name as os_name
from os.path import dirname, join
from venv import EnvBuilder

__all__ = ['main']

this_dir = dirname(__file__)
env_dir = join(this_dir, '.venv')


def main() -> None:
EnvBuilder(
system_site_packages=False,
symlinks=os_name != 'nt',
with_pip=False,
).create(env_dir)


if __name__ == '__main__':
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
# https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata
name = "with-license-file"
version = "0.1.0"
description = "with licenses from file, instead of SPDX ID/Expression"
# see https://packaging.python.org/en/latest/specifications/pyproject-toml/#license
license = { file = "testing/someLicenseFile.txt.bin" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
This is the license text of this component.
It is expected to be available in a SBOM.
57 changes: 57 additions & 0 deletions tests/_data/infiles/environment/with-license-text/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
initialize this testbed.
"""

from os import name as os_name
from os.path import abspath, dirname, join
from subprocess import CompletedProcess, run # nosec:B404
from sys import executable
from typing import Any
from venv import EnvBuilder

__all__ = ['main']

this_dir = dirname(__file__)
env_dir = join(this_dir, '.venv')
constraint_file = join(this_dir, 'pinning.txt')

localpackages_dir = abspath(join(dirname(__file__), '..', '..', '_helpers', 'local_pckages'))


def pip_run(*args: str, **kwargs: Any) -> CompletedProcess:
# pip is not API, but a CLI -- call it like that!
call = (
executable, '-m', 'pip',
'--python', env_dir,
*args
)
print('+ ', *call)
res = run(call, **kwargs, cwd=this_dir, shell=False) # nosec:B603
if res.returncode != 0:
raise RuntimeError('process failed')
return res


def pip_install(*args: str) -> None:
pip_run(
'install', '--require-virtualenv', '--no-input', '--progress-bar=off', '--no-color',
*args
)


def main() -> None:
EnvBuilder(
system_site_packages=False,
symlinks=os_name != 'nt',
with_pip=False,
).create(env_dir)

pip_install(
join(localpackages_dir, 'a', 'dist', 'package_a-23.42-py3-none-any.whl'),
join(localpackages_dir, 'b', 'dist', 'package_b-23.42-py3-none-any.whl'),
join(localpackages_dir, 'c', 'dist', 'package_c-23.42-py3-none-any.whl'),
)


if __name__ == '__main__':
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
# https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata
name = "with-license-text"
version = "0.1.0"
description = "with licenses as text, instead of SPDX ID/Expression"
# see https://packaging.python.org/en/latest/specifications/pyproject-toml/#license
license = { text = "This is the license text of this component.\nIt is expected to be available in a SBOM." }
7 changes: 3 additions & 4 deletions tests/_data/snapshots/environment/plain_local_1.1.xml.bin

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading