From 8703ee4b0a26d02587514674487e21d3eea8f053 Mon Sep 17 00:00:00 2001 From: Abhijit Patil Date: Fri, 16 Oct 2026 15:24:24 +0530 Subject: [PATCH] feat: support explicit main component metadata without pyproject.toml Add CLI support for setting the root/main component metadata directly when pyproject.toml is not available. add --mc-name and --mc-version support keep --mc-type and --pyproject behavior intact prefer CLI overrides when explicitly provided support requirements and environment subcommands add unit tests for precedence and edge cases Signed-off-by: Abhijit Patil --- cyclonedx_py/_internal/cli_common.py | 60 ++++++++++++++++- cyclonedx_py/_internal/environment.py | 32 ++++++--- cyclonedx_py/_internal/requirements.py | 21 +++--- tests/integration/test_cli_environment.py | 37 ++++++++++ tests/integration/test_cli_requirements.py | 41 ++++++++++++ tests/unit/test_cli.py | 78 ++++++++++++++++++++++ 6 files changed, 248 insertions(+), 21 deletions(-) diff --git a/cyclonedx_py/_internal/cli_common.py b/cyclonedx_py/_internal/cli_common.py index cd6b943f8..3af0ea55f 100644 --- a/cyclonedx_py/_internal/cli_common.py +++ b/cyclonedx_py/_internal/cli_common.py @@ -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': @@ -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='', + 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='', + 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, @@ -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 + diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index b0eb7f9e9..f4578e7f5 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -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 @@ -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 ` Exclude specified package from the output (multi use) @@ -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: diff --git a/cyclonedx_py/_internal/requirements.py b/cyclonedx_py/_internal/requirements.py index 43b243bec..f936e4b1b 100644 --- a/cyclonedx_py/_internal/requirements.py +++ b/cyclonedx_py/_internal/requirements.py @@ -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 @@ -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='', @@ -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 diff --git a/tests/integration/test_cli_environment.py b/tests/integration/test_cli_environment.py index a657feb16..0d23e6b29 100644 --- a/tests/integration/test_cli_environment.py +++ b/tests/integration/test_cli_environment.py @@ -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 @@ -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( diff --git a/tests/integration/test_cli_requirements.py b/tests/integration/test_cli_requirements.py index f47b38d7a..ab66c38c1 100644 --- a/tests/integration/test_cli_requirements.py +++ b/tests/integration/test_cli_requirements.py @@ -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 @@ -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 @@ -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): @@ -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( diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index be5afae75..803cfe0a4 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -25,6 +25,7 @@ from os import environ from os.path import join from random import random +from tempfile import NamedTemporaryFile from typing import Any, Optional from unittest import TestCase from unittest.mock import Mock, patch @@ -37,6 +38,7 @@ from cyclonedx_py._internal import BomBuilder from cyclonedx_py._internal.cli import ENV_SOURCE_DATE_EPOCH, Command +from cyclonedx_py._internal.cli_common import resolve_main_component from tests import SnapshotMixin @@ -230,3 +232,79 @@ def __make_fresh_logger(cls, logstream: StringIO, level: int = logging.NOTSET) - lh.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) logger.addHandler(lh) return logger + + @named_data( + ('with_pyproject_and_version_only', None, '1.0', ComponentType.APPLICATION, 'pyproject-app', '1.0'), + ('only_name', 'my app', None, ComponentType.APPLICATION, 'my app', '9.9'), + ('name_and_version', 'my app', '1.0', ComponentType.LIBRARY, 'my app', '1.0'), + ) + def test_resolve_main_component_explicit_overrides(self, mc_name: Optional[str], mc_version: Optional[str], + mc_type: ComponentType, expected_name: str, + expected_version: Optional[str]) -> None: + with NamedTemporaryFile('w', encoding='utf8', suffix='.toml', delete=False) as fh: + fh.write('[project]\nname = "pyproject-app"\nversion = "9.9"\n') + pyproject_path = fh.name + + try: + component = resolve_main_component( + mc_name=mc_name, + mc_version=mc_version, + mc_type=mc_type, + pyproject_file=pyproject_path, + gather_license_texts=False, + logger=self.__make_fresh_logger(StringIO()), + ) + self.assertIsNotNone(component) + self.assertEqual(expected_name, component.name) + self.assertEqual(expected_version, component.version) + self.assertEqual(mc_type, component.type) + finally: + import os + os.unlink(pyproject_path) + + def test_resolve_main_component_without_pyproject_uses_cli_name(self) -> None: + component = resolve_main_component( + mc_name='direct-name', + mc_version=None, + mc_type=ComponentType.APPLICATION, + pyproject_file=None, + gather_license_texts=False, + logger=self.__make_fresh_logger(StringIO()), + ) + self.assertIsNotNone(component) + self.assertEqual('direct-name', component.name) + self.assertIsNone(component.version) + self.assertEqual(ComponentType.APPLICATION, component.type) + + def test_resolve_main_component_without_name_and_with_version_returns_none(self) -> None: + component = resolve_main_component( + mc_name=None, + mc_version='1.0', + mc_type=ComponentType.APPLICATION, + pyproject_file=None, + gather_license_texts=False, + logger=self.__make_fresh_logger(StringIO()), + ) + self.assertIsNone(component) + + def test_resolve_main_component_with_pyproject_and_conflicting_cli_values(self) -> None: + with NamedTemporaryFile('w', encoding='utf8', suffix='.toml', delete=False) as fh: + fh.write('[project]\nname = "pyproject-app"\nversion = "9.9"\n') + pyproject_path = fh.name + + try: + component = resolve_main_component( + mc_name='cli-app', + mc_version='2.0', + mc_type=ComponentType.LIBRARY, + pyproject_file=pyproject_path, + gather_license_texts=False, + logger=self.__make_fresh_logger(StringIO()), + ) + self.assertIsNotNone(component) + self.assertEqual('cli-app', component.name) + self.assertEqual('2.0', component.version) + self.assertEqual(ComponentType.LIBRARY, component.type) + finally: + import os + os.unlink(pyproject_path)