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
60 changes: 58 additions & 2 deletions cyclonedx_py/_internal/cli_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.


from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional

from cyclonedx.model.component import ComponentType
from cyclonedx.model.component import Component, ComponentType

from .utils.args import argparse_type4enum
from .utils.pyproject import pyproject_file2component

if TYPE_CHECKING: # pragma: no cover
from argparse import Action, ArgumentParser
from logging import Logger


def add_argument_pyproject(p: 'ArgumentParser') -> 'Action':
Expand All @@ -37,6 +39,24 @@ def add_argument_pyproject(p: 'ArgumentParser') -> 'Action':
default=None)


def add_argument_mc_name(p: 'ArgumentParser') -> 'Action':
return p.add_argument('--mc-name',
metavar='<name>',
help='Name of the main component. '
'Overrides `pyproject.toml` metadata when provided.',
dest='mc_name',
default=None)


def add_argument_mc_version(p: 'ArgumentParser') -> 'Action':
return p.add_argument('--mc-version',
metavar='<version>',
help='Version of the main component. '
'Overrides `pyproject.toml` metadata when provided.',
dest='mc_version',
default=None)


def add_argument_mc_type(p: 'ArgumentParser') -> 'Action':
choices = [ComponentType.APPLICATION,
ComponentType.FIRMWARE,
Expand All @@ -50,3 +70,39 @@ def add_argument_mc_type(p: 'ArgumentParser') -> 'Action':
choices=choices,
type=argparse_type4enum(ComponentType),
default=ComponentType.APPLICATION.value)


def resolve_main_component(*, # noqa: PLR0913
mc_name: Optional[str],
mc_version: Optional[str],
mc_type: 'ComponentType',
pyproject_file: Optional[str] = None,
gather_license_texts: bool,
logger: 'Logger') -> Optional[Component]:
"""Resolve main component metadata from CLI args and/or pyproject.toml.

Precedence:
1. If pyproject_file is provided, load and use it as base
2. CLI args (mc_name, mc_version) override pyproject values if provided
3. If neither pyproject nor mc_name is provided, return None
"""
component: Optional[Component] = None

if pyproject_file is not None:
component = pyproject_file2component(pyproject_file, ctype=mc_type,
gather_license_texts=gather_license_texts, logger=logger)
elif mc_name is None:
return None
else:
# Create minimal component from CLI args only
component = Component(type=mc_type, name=mc_name, version=mc_version)

# Apply CLI overrides
if mc_name is not None:
component.name = mc_name
if mc_version is not None:
component.version = mc_version
component.type = mc_type
component.bom_ref.value = 'root-component'
return component

32 changes: 21 additions & 11 deletions cyclonedx_py/_internal/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@
from packaging.requirements import Requirement

from . import BomBuilder, PropertyName, PurlTypePypi
from .cli_common import add_argument_mc_type, add_argument_pyproject
from .cli_common import add_argument_mc_name, add_argument_mc_type, add_argument_mc_version, add_argument_pyproject, resolve_main_component
from .utils.cdx import licenses_fixup, make_bom
from .utils.packaging import metadata2extrefs, metadata2licenses, normalize_packagename
from .utils.pep610 import PackageSourceArchive, PackageSourceVcs, packagesource2extref, packagesource4dist
from .utils.pep639 import dist2licenses_from_files as pep639_dist2licenses_from_files
from .utils.pyproject import pyproject2component, pyproject2dependencies, pyproject_load
from .utils.pyproject import pyproject2dependencies

if TYPE_CHECKING: # pragma: no cover
from logging import Logger
Expand Down Expand Up @@ -121,6 +121,8 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser':
dest='gather_license_texts',
help='Enable license text gathering.')
add_argument_pyproject(p)
add_argument_mc_name(p)
add_argument_mc_version(p)
add_argument_mc_type(p)
# TODO possible additional switch:
# `--exclude <package>` Exclude specified package from the output (multi use)
Expand All @@ -145,19 +147,27 @@ def __call__(self, *, # type:ignore[override]
import_site: bool,
python: Optional[str],
pyproject_file: Optional[str],
mc_name: Optional[str],
mc_version: Optional[str],
mc_type: 'ComponentType',
**__: Any) -> 'Bom':
if pyproject_file is None:
rc = None
else:
root_c = resolve_main_component(
mc_name=mc_name,
mc_version=mc_version,
mc_type=mc_type,
pyproject_file=pyproject_file,
gather_license_texts=self._gather_license_texts,
logger=self._logger,
)
# `pyproject` is still needed here for dependency extraction.
# The main-component resolver only needs the file path; dependencies need parsed TOML data.
if pyproject_file is not None:
from .utils.pyproject import pyproject_load
pyproject = pyproject_load(pyproject_file)
root_c = pyproject2component(pyproject, ctype=mc_type,
fpath=pyproject_file,
gather_license_texts=self._gather_license_texts,
logger=self._logger)
root_c.bom_ref.value = 'root-component'
root_d = tuple(pyproject2dependencies(pyproject))
rc = (root_c, root_d)
else:
root_d = tuple()
rc = (root_c, root_d) if root_c is not None else None

path: list[str]
if python:
Expand Down
21 changes: 13 additions & 8 deletions cyclonedx_py/_internal/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@
from pip_requirements_parser import RequirementsFile # type:ignore[import-untyped]

from . import BomBuilder, PropertyName, PurlTypePypi
from .cli_common import add_argument_mc_type, add_argument_pyproject
from .cli_common import add_argument_mc_name, add_argument_mc_type, add_argument_mc_version, add_argument_pyproject, resolve_main_component
from .utils.cdx import make_bom
from .utils.io import io2file
from .utils.packaging import normalize_packagename
from .utils.pyproject import pyproject_file2component
from .utils.secret import redact_auth_from_url

if TYPE_CHECKING: # pragma: no cover
Expand Down Expand Up @@ -90,6 +89,8 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser':
dest='extra_index_urls',
default=[])
add_argument_pyproject(p)
add_argument_mc_name(p)
add_argument_mc_version(p)
add_argument_mc_type(p)
p.add_argument('requirements_file',
metavar='<requirements-file>',
Expand All @@ -111,14 +112,18 @@ def __init__(self, *,
def __call__(self, *, # type:ignore[override]
requirements_file: str,
pyproject_file: Optional[str],
mc_name: Optional[str],
mc_version: Optional[str],
mc_type: 'ComponentType',
**__: Any) -> 'Bom':
if pyproject_file is None:
rc = None
else:
rc = pyproject_file2component(pyproject_file, ctype=mc_type,
gather_license_texts=False, logger=self._logger)
rc.bom_ref.value = 'root-component'
rc = resolve_main_component(
mc_name=mc_name,
mc_version=mc_version,
mc_type=mc_type,
pyproject_file=pyproject_file,
gather_license_texts=False,
logger=self._logger,
)

if requirements_file == '-':
from sys import stdin # late bind, to allow patching
Expand Down
37 changes: 37 additions & 0 deletions tests/integration/test_cli_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import random
from collections.abc import Generator
from glob import glob
from json import loads as json_loads
from os import name as os_name
from os.path import basename, dirname, join
from subprocess import run # nosec:B404
Expand Down Expand Up @@ -175,6 +176,42 @@ def test_with_current_python(self) -> None:
make_comparable(sbom2, of)
)

def test_without_pyproject_has_no_component(self) -> None:
sv = SchemaVersion.V1_6
of = OutputFormat.JSON
res, out, err = run_cli(
'environment',
'-vvv',
'--sv', sv.to_version(),
'--of', of.name,
'--output-reproducible',
'-o=-',
)
self.assertEqual(0, res, err)

component = json_loads(out)['metadata'].get('component')
self.assertIsNone(component)

def test_with_explicit_mc_metadata(self) -> None:
sv = SchemaVersion.V1_6
of = OutputFormat.JSON
res, out, err = run_cli(
'environment',
'-vvv',
'--sv', sv.to_version(),
'--of', of.name,
'--output-reproducible',
'-o=-',
'--mc-name', 'my application',
'--mc-version', '1.0',
)
self.assertEqual(0, res, err)

component = json_loads(out)['metadata']['component']
self.assertEqual('my application', component['name'])
self.assertEqual('1.0', component['version'])
self.assertEqual('application', component['type'])

@named_data(*test_data)
def test_plain_as_expected(self, projectdir: str, sv: SchemaVersion, of: OutputFormat) -> None:
res, out, err = run_cli(
Expand Down
41 changes: 41 additions & 0 deletions tests/integration/test_cli_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import random
from collections.abc import Generator
from glob import glob
from json import loads as json_loads
from os.path import basename, join, splitext
from typing import Any
from unittest import TestCase
Expand All @@ -45,6 +46,9 @@ def test_data_file_filter(s: str) -> Generator[Any, None, None]:
return ((n, d, sv, of) for n, d, sv, of in test_data if s in n)


test_data_file_filter.__test__ = False


if os.name == 'nt':
def test_data_os_filter(data: Any) -> bool:
return True
Expand All @@ -54,6 +58,9 @@ def test_data_os_filter(data: tuple[Any, str, Any, Any]) -> bool:
return '.cp125' not in data[1]


test_data_os_filter.__test__ = False


@ddt
class TestCliRequirements(TestCase, SnapshotMixin):

Expand Down Expand Up @@ -117,6 +124,40 @@ def test_with_stream_as_expected(self, infile: str, sv: SchemaVersion, of: Outpu
self.assertEqual(0, res, err)
self.assertEqualSnapshot(out, 'stream', infile, sv, of)

def test_without_pyproject_has_no_component(self) -> None:
_, infile, _, _ = random.choice(test_data) # nosec B311
res, out, err = run_cli(
'requirements',
'-vvv',
'--sv', SchemaVersion.V1_6.to_version(),
'--of', OutputFormat.JSON.name,
'--output-reproducible',
'-o=-',
infile)
self.assertEqual(0, res, err)

component = json_loads(out)['metadata'].get('component')
self.assertIsNone(component)

def test_with_explicit_mc_metadata(self) -> None:
_, infile, _, _ = random.choice(test_data) # nosec B311
res, out, err = run_cli(
'requirements',
'-vvv',
'--sv', SchemaVersion.V1_6.to_version(),
'--of', OutputFormat.JSON.name,
'--output-reproducible',
'-o=-',
'--mc-name', 'my application',
'--mc-version', '1.0',
infile)
self.assertEqual(0, res, err)

component = json_loads(out)['metadata']['component']
self.assertEqual('my application', component['name'])
self.assertEqual('1.0', component['version'])
self.assertEqual('application', component['type'])

@named_data(*test_data_file_filter('frozen'))
def test_with_index_auth(self, infile: str, sv: SchemaVersion, of: OutputFormat) -> None:
res, out, err = run_cli(
Expand Down
Loading