Skip to content
Open
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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<monitoring-list UUID>" --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.
Expand Down
122 changes: 122 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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"
57 changes: 45 additions & 12 deletions vilocify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down