Skip to content
Draft
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
1 change: 1 addition & 0 deletions cyclonedx_py/_internal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ def __init__(self, *,
self._output_reproducible = output_reproducible
self._should_validate = should_validate
self._bbc = _bbc(**self._clean_kwargs(kwargs),
spec_version=spec_version,
logger=self._logger.getChild(_bbc.__name__))

def _shorten_purls(self, bom: 'Bom') -> bool:
Expand Down
59 changes: 59 additions & 0 deletions cyclonedx_py/_internal/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from json import JSONDecodeError, loads as json_loads
from os import getcwd, name as os_name
from os.path import exists, isdir, join
from platform import python_version
from subprocess import run # nosec
from sys import executable, path as sys_path
from textwrap import dedent
Expand All @@ -30,6 +31,7 @@
from cyclonedx.factory.license import LicenseFactory
from cyclonedx.model import Property
from cyclonedx.model.component import Component, ComponentType
from cyclonedx.schema import SchemaVersion
from packageurl import PackageURL
from packaging.requirements import Requirement

Expand All @@ -51,6 +53,12 @@
T_AllComponents = dict[str, tuple['Component', Iterable[Requirement]]]


# The Python runtime is added as an external component, which requires CycloneDX >= 1.7
# and a `cyclonedx-python-lib` that supports `Component.is_external` (>= 11.12).
_SV_PYTHON_RUNTIME: Optional[SchemaVersion] = getattr(SchemaVersion, 'V1_7', None)
_LIB_SUPPORTS_IS_EXTERNAL = hasattr(Component, 'is_external')


class EnvironmentBB(BomBuilder):

@staticmethod
Expand Down Expand Up @@ -145,9 +153,14 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser':
def __init__(self, *,
logger: 'Logger',
gather_license_texts: bool,
spec_version: Optional[SchemaVersion] = None,
**__: Any) -> None:
self._logger = logger
self._gather_license_texts = gather_license_texts
self._add_python_runtime = _LIB_SUPPORTS_IS_EXTERNAL \
and spec_version is not None \
and _SV_PYTHON_RUNTIME is not None \
and spec_version >= _SV_PYTHON_RUNTIME

def __call__(self, *, # type:ignore[override]
import_site: bool,
Expand All @@ -169,21 +182,48 @@ def __call__(self, *, # type:ignore[override]
rc = (root_c, root_d)

path: list[str]
py_version: Optional[str] = None
if python:
path = self.__path4python(python, import_site, honor_py_env_vars)
if self._add_python_runtime:
py_version = self.__version4python(python, import_site, honor_py_env_vars)
elif not import_site or not honor_py_env_vars:
# the already-running interpreter imported site and honored PYTHON* vars at startup;
# probe a fresh one with the requested flags
path = self.__path4python(executable, import_site, honor_py_env_vars)
if self._add_python_runtime:
py_version = self.__version4python(executable, import_site, honor_py_env_vars)
else:
path = sys_path.copy()
if self._add_python_runtime:
py_version = python_version()
if path[0] in ('', getcwd()):
path.pop(0)

bom = make_bom()
self.__add_components(bom, rc, path=path)
if py_version is not None:
self.__add_python_runtime_component(bom, py_version)
return bom

def __add_python_runtime_component(self, bom: 'Bom', py_version: str) -> None:
# the runtime is expected to be provided by the deployment environment, it is not bundled.
# the implementation (CPython, PyPy, etc.) is intentionally not part of the SBOM.
runtime_c = Component(
type=ComponentType.PLATFORM,
bom_ref='python-runtime',
name='python',
version=py_version,
is_external=True,
)
self._logger.info('add component for Python runtime %r', py_version)
self._logger.debug('add component: %r', runtime_c)
bom.components.add(runtime_c)
if bom.metadata.component is None:
bom.register_dependency(runtime_c)
else:
bom.register_dependency(bom.metadata.component, (runtime_c,))

def __add_components(self, bom: 'Bom',
rc: Optional[tuple['Component', Iterable['Requirement']]],
**kwargs: Any) -> None:
Expand Down Expand Up @@ -306,6 +346,25 @@ def __py_interpreter(value: str) -> str:
raise ValueError(f'Failed to find python in directory: {value}')
return value

def __version4python(self, python: str, import_site: bool, honor_py_env_vars: bool) -> str:
cmd = [self.__py_interpreter(python),
'-c', 'import platform,sys;sys.stdout.write(platform.python_version())']
if not import_site:
cmd.insert(1, '-S')
if not honor_py_env_vars:
cmd.insert(1, '-E')

self._logger.debug('fetch `version` from python interpreter cmd: %r', cmd)
res = run(cmd, capture_output=True, encoding='utf8', shell=False) # nosec
version = res.stdout.strip()
if res.returncode != 0 or len(version) == 0:
raise RuntimeError('Fail fetching `version` from Python interpreter.\n'
f'returncode: {res.returncode}\n'
f'stdout: {res.stdout}\n'
f'stderr: {res.stderr}\n')
self._logger.debug('got `version` from Python interpreter: %r', version)
return version

def __path4python(self, python: str, import_site: bool, honor_py_env_vars: bool) -> list[str]:
cmd = [self.__py_interpreter(python),
'-c', 'import json,sys;json.dump(sys.path,sys.stdout)']
Expand Down
6 changes: 6 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ For Python (virtual) environment
By analyzing the actually installed packages, this will produce the most accurate and complete CycloneDX BOM.
The generated CycloneDX SBOM will include metadata, licenses, dependency graph, and more.

When CycloneDX 1.7 or later is requested (``--spec-version 1.7``),
the Python runtime of the analyzed environment is added as a component
of type ``platform`` with ``isExternal=true``, named ``python`` and versioned like ``3.13.5``.
If a root component is known (``--pyproject``), it depends on this runtime component.
The specific implementation (CPython, PyPy, etc.) is intentionally not part of the SBOM.

The full documentation can be issued by running with ``environment --help``:

.. code-block:: shell-session
Expand Down
14 changes: 14 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ def make_xml_comparable(bom: str) -> str:
' <version>\\1</version>\n'
' <externalReferences><!-- stripped --></externalReferences>',
bom)
bom = re_sub( # replace the version of the Python runtime the tests happen to run with
'(<component type="platform" bom-ref="python-runtime" isExternal="true">\n'
' *<name>python</name>\n'
' *<version>).*?(</version>)',
'\\1pythonVersion-testing\\2',
bom)
if sys.version_info < (3, 13):
# py3.13 finally fixed a bug in the XML writer: https://github.com/python/cpython/issues/81555
# no longer escape double-quotes(") in text/non-attribute.
Expand Down Expand Up @@ -214,6 +220,14 @@ def make_json_comparable(bom: str) -> str:
' "name": "cyclonedx-python-lib",\n'
' "vendor": "CycloneDX",\n',
bom)
bom = re_sub( # replace the version of the Python runtime the tests happen to run with
'("bom-ref": "python-runtime",\n'
' *"isExternal": true,\n'
' *"name": "python",\n'
' *"type": "platform",\n'
' *"version": )".*?"',
'\\1"pythonVersion-testing"',
bom)
return bom


Expand Down

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

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

13 changes: 13 additions & 0 deletions tests/_data/snapshots/environment/plain_local_1.7.json.bin

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

9 changes: 8 additions & 1 deletion tests/_data/snapshots/environment/plain_local_1.7.xml.bin

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

15 changes: 15 additions & 0 deletions tests/_data/snapshots/environment/plain_no-deps_1.7.json.bin

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

11 changes: 10 additions & 1 deletion tests/_data/snapshots/environment/plain_no-deps_1.7.xml.bin

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

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

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

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

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

11 changes: 11 additions & 0 deletions tests/_data/snapshots/environment/plain_via-pdm_1.7.json.bin

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

Loading