From dd6e70788f6fa02db40ff43d93b19f92d29a7214 Mon Sep 17 00:00:00 2001 From: Alexander Gschrei Date: Wed, 30 Sep 2026 12:13:54 +0200 Subject: [PATCH] fix(cli): allow passing in group name and refering to list by id --- README.md | 19 +++++++- tests/test_cli.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++ vilocify/cli.py | 57 +++++++++++++++++----- 3 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 tests/test_cli.py diff --git a/README.md b/README.md index 0f3e163..61b8875 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,27 @@ The Vilocify SDK needs Python 3.12 or newer. ### Example Following example imports a monitoring list from a CycloneDX SBOM. -Note that the monitoring list is identified by its name. -If a monitoring list with the same name already exists, it gets overwritten with the contents from the SBOM file. +Note that the monitoring list is identified by its display name and comment (which defaults to an empty string). +If a monitoring list with the same name and comment already exists, its components get replaced with the contents from the SBOM file. ```bash vilocify monitoringlist import --name "My Project v1.2.3" --from-cyclonedx my_project_v1.2.3.sbom.json ``` +To update an existing monitoring list by UUID, use `--id` instead of `--name`. Its name and comment are preserved: +```bash +poetry run vilocify monitoringlist import --id "" --from-cyclonedx my_project_v1.2.3.sbom.json --yes +``` + +Use `--group` to specify the **organization group's display name**, not its UUID. The API token determines the organization. +This sets the group on both new and existing monitoring lists. If omitted, existing lists keep their group and new lists use +the organization's default group; specify it if your instance returns an "Organization group can't be blank" error: +```bash +poetry run vilocify monitoringlist import --name "My Project v1.2.3" --group "Engineering" --from-cyclonedx my_project_v1.2.3.sbom.json --yes +``` + +Supply exactly one of `--id` or `--name`. `--comment` is only supported with `--name`. +`--yes` skips the confirmation prompt for creating requests for unknown components. + ## SDK usage The SDK is built on Vilocify's API. We recommend you also take a quick look at the raw API docs at https://portal.vilocify.com/documentation. diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..53732ff --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: 2026 Siemens AG +# SPDX-License-Identifier: MIT + +import json +from pathlib import Path + +import pytest +import requests_mock as rm +from click.testing import CliRunner + +from vilocify import api_config +from vilocify.cli import cli + +ML_ID = "00000000-1111-2222-3333-444444444444" +CONTENT_TYPE = {"Content-Type": "application/vnd.api+json"} +USAGE_ERROR_EXIT_CODE = 2 + + +@pytest.fixture +def sbom(tmp_path: Path) -> str: + path = tmp_path / "sbom.json" + path.write_text(json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": []})) + return str(path) + + +@pytest.mark.parametrize("group", [None, "Engineering"]) +@pytest.mark.parametrize("selector", [["--id", ML_ID], ["--name", "Existing", "--comment", "Keep this comment"]]) +def test_import_existing_list(requests_mock: rm.Mocker, sbom: str, selector: list[str], group: str | None): + url = f"{api_config.base_url}/monitoringLists" + data = { + "id": ML_ID, + "type": "monitoringLists", + "attributes": {"name": "Existing", "comment": "Keep this comment", "group": "Original"}, + } + requests_mock.get(f"{url}/{ML_ID}", headers=CONTENT_TYPE, json={"data": data}) + requests_mock.get(url, headers=CONTENT_TYPE, json={"data": [data], "links": {"next": None}}) + requests_mock.get( + f"{url}/{ML_ID}/relationships/components", + headers=CONTENT_TYPE, + json={"data": [], "links": {"next": None}}, + ) + requests_mock.patch(f"{url}/{ML_ID}", headers=CONTENT_TYPE, status_code=204) + args = ["monitoringlist", "import", *selector, "--from-cyclonedx", sbom, "--yes"] + if group is not None: + args.extend(["--group", group]) + + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + lookup = requests_mock.request_history[0] + if selector[0] == "--id": + assert lookup.url.split("?")[0] == f"{url}/{ML_ID}" + else: + assert lookup.qs["filter[name][eq]"] == ["existing"] + assert lookup.qs["filter[comment][eq]"] == ["keep this comment"] + update = requests_mock.request_history[-1].json()["data"] + assert update["attributes"] == { + "name": "Existing", + "comment": "Keep this comment", + "group": group or "Original", + } + assert update["relationships"]["components"]["data"] == [] + assert all(request.method != "POST" for request in requests_mock.request_history) + + +@pytest.mark.parametrize("group", [None, "Engineering"]) +def test_import_creates_list(requests_mock: rm.Mocker, sbom: str, group: str | None): + url = f"{api_config.base_url}/monitoringLists" + requests_mock.get(url, headers=CONTENT_TYPE, json={"data": [], "links": {"next": None}}) + requests_mock.post( + url, + headers=CONTENT_TYPE, + status_code=201, + json={"data": {"id": ML_ID, "type": "monitoringLists", "attributes": {"name": "New", "comment": ""}}}, + ) + requests_mock.get( + f"{url}/{ML_ID}/relationships/components", + headers=CONTENT_TYPE, + json={"data": [], "links": {"next": None}}, + ) + requests_mock.patch(f"{url}/{ML_ID}", headers=CONTENT_TYPE, status_code=204) + args = ["monitoringlist", "import", "--name", "New", "--from-cyclonedx", sbom, "--yes"] + if group is not None: + args.extend(["--group", group]) + + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + attributes = requests_mock.request_history[1].json()["data"]["attributes"] + expected = {"name": "New", "comment": ""} + if group is not None: + expected["group"] = group + assert attributes == expected + + +@pytest.mark.parametrize( + ("selector", "message"), + [ + ([], "Specify exactly one of --id or --name."), + (["--id", ML_ID, "--name", "Existing"], "Specify exactly one of --id or --name."), + (["--id", ML_ID, "--comment", "Comment"], "--comment can only be used with --name."), + (["--id", ML_ID, "--comment", ""], "--comment can only be used with --name."), + ], +) +def test_import_invalid_selector(requests_mock: rm.Mocker, sbom: str, selector: list[str], message: str): + result = CliRunner().invoke(cli, ["monitoringlist", "import", *selector, "--from-cyclonedx", sbom]) + + assert result.exit_code == USAGE_ERROR_EXIT_CODE + assert message in result.output + assert requests_mock.call_count == 0 + + +def test_import_missing_id_does_not_create_list(requests_mock: rm.Mocker, sbom: str): + requests_mock.get( + f"{api_config.base_url}/monitoringLists/{ML_ID}", headers=CONTENT_TYPE, status_code=404, json={"errors": []} + ) + + result = CliRunner().invoke(cli, ["monitoringlist", "import", "--id", ML_ID, "--from-cyclonedx", sbom]) + + assert result.exit_code != 0 + assert requests_mock.call_count == 1 + assert requests_mock.request_history[0].method == "GET" diff --git a/vilocify/cli.py b/vilocify/cli.py index 1d00db5..99b7326 100644 --- a/vilocify/cli.py +++ b/vilocify/cli.py @@ -132,12 +132,23 @@ def _load_bom(file: io.FileIO) -> Bom: return bom -def _load_ml(name: str, comment: str) -> MonitoringList: - ml = MonitoringList.where("name", "eq", name).where("comment", "eq", comment).first() - if ml is None: - logger.info("No monitoring list with given name and comment found. Creating new list.") - ml = MonitoringList(name=name, comment=comment) - ml.create() +def _load_ml(name: str | None, comment: str, monitoring_list_id: str | None, group: str | None) -> MonitoringList: + ml: MonitoringList | None + if monitoring_list_id is not None: + ml = MonitoringList.get(monitoring_list_id) + elif name is not None: + ml = MonitoringList.where("name", "eq", name).where("comment", "eq", comment).first() + if ml is None: + logger.info("No monitoring list with given name and comment found. Creating new list.") + ml = MonitoringList(name=name, comment=comment) + if group is not None: + ml.group = group + ml.create() + else: + raise UsageError("Specify exactly one of --id or --name.") + + if group is not None: + ml.group = group logger.info("Using monitoring list %s", ml.id) return ml @@ -188,24 +199,46 @@ def monitoringlist_show(monitoring_list_id: str, export_format: str): @monitoringlist.command("import") -@click.option("--name", required=True, help="The monitoring list name.") -@click.option("--comment", default="", help="The comment set for the monitoring list.") +@click.option( + "--id", "monitoring_list_id", help="The UUID of an existing monitoring list. Mutually exclusive with --name." +) +@click.option("--name", help="The monitoring list display name. Required unless --id is given.") +@click.option( + "--comment", help="The comment used with --name to identify the monitoring list. Defaults to an empty string." +) +@click.option("--group", help="The organization group name to set on the monitoring list.") @click.option("--yes", is_flag=True, help="Skip interactive questions. Assumes 'yes' for all answers.") @click.option("--from-cyclonedx", type=click.File("rt"), required=True, help="The CycloneDX file to import.") -def monitoringlist_import(name: str, comment: str, yes: bool, from_cyclonedx: io.FileIO): +def monitoringlist_import( # noqa: PLR0913 - Each parameter corresponds to a CLI option. + *, + monitoring_list_id: str | None, + name: str | None, + comment: str | None, + group: str | None, + yes: bool, + from_cyclonedx: io.FileIO, +): """Creates or updates a monitoring list from a CycloneDX JSON or XML file. - The monitoring list is identified by the given name and comment. Changing the name or comment between runs will - create a new monitoring list. The JSON or XML filetype is identified by the filename ending. + Use --id to update an existing monitoring list, preserving its name and comment. Otherwise, the monitoring list is + identified by the given name and comment. Changing either between runs will create a new monitoring list. + --comment can only be used with --name. --group sets the organization group by name; if omitted, existing lists keep + their group and new lists use the organization's default group. The JSON or XML filetype is identified by the + filename ending. Some components might not be found on Vilocify. A ComponentRequest is created for components that cannot be identified. ComponentRequests might need several days to get processed and integrated into Vilocify. Running the same command repeatedly will update the monitoring list once the component requests are processed. """ + if (monitoring_list_id is None) == (name is None): + raise UsageError("Specify exactly one of --id or --name.") + if monitoring_list_id is not None and comment is not None: + raise UsageError("--comment can only be used with --name.") + component_requests = [] bom = _load_bom(from_cyclonedx) - ml = _load_ml(name, comment) + ml = _load_ml(name, comment or "", monitoring_list_id, group) components_cache = {(c.name, c.version): c for c in ml.components} components, unidentified_components = _match_bom(components_cache, bom)