From 0a05e5c1a38957d78fba6c3673b21ebf190d0852 Mon Sep 17 00:00:00 2001 From: Peder Hovdan Andresen <107681714+pederhan@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:40:06 +0200 Subject: [PATCH] Add `FirstDict` type (#90) * Fix vulnerability report docs * Add `FirstDict` --- CHANGELOG.md | 10 +++++++- .../artifacts/get-artifact-vulnerabilities.md | 19 ++++++++++++--- docs/reference/models/mappings.md | 9 ++++++++ harborapi/client.py | 11 +++++---- harborapi/models/mappings.py | 23 +++++++++++++++++++ mkdocs.yml | 1 + tests/endpoints/test_artifacts.py | 14 +++++++++++ tests/models/test_mappings.py | 0 8 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 docs/reference/models/mappings.md create mode 100644 harborapi/models/mappings.py create mode 100644 tests/models/test_mappings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eeed2f6..a535be0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,15 @@ While the project is still on major version 0, breaking changes may be introduce - +## Unreleased + +## Added + +- `harborapi.models.mappings.FirstDict` which is a subclass of Python's built-in `dict` that provides a `first()` method to get the first value in the dict (or `None` if the dict is empty). + +## Changed + +- `HarborAsyncClient.get_artifact_vulnerability_reports()` now returns `FirstDict` to provide easier access to the first (and likely only) report for the artifact. ## [0.25.0](https://github.com/unioslo/harborapi/tree/harborapi-v0.25.0) - 2024-06-17 diff --git a/docs/recipes/artifacts/get-artifact-vulnerabilities.md b/docs/recipes/artifacts/get-artifact-vulnerabilities.md index 4eddabd9..701567c8 100644 --- a/docs/recipes/artifacts/get-artifact-vulnerabilities.md +++ b/docs/recipes/artifacts/get-artifact-vulnerabilities.md @@ -1,6 +1,6 @@ -# Get artifact vulnerability report +# Get artifact vulnerability reports -We can fetch the vulnerability report for an artifact using [`get_artifact_vulnerability_reports`][harborapi.client.HarborAsyncClient.get_artifact_vulnerability_reports]. It returns a dict of [`HarborVulnerabilityReport`][harborapi.models.HarborVulnerabilityReport] objects indexed by MIME type. If no reports are found, the dict will be empty. +We can fetch the vulnerability report(s) for an artifact using [`get_artifact_vulnerability_reports`][harborapi.client.HarborAsyncClient.get_artifact_vulnerability_reports]. It returns a dict of [`HarborVulnerabilityReport`][harborapi.models.HarborVulnerabilityReport] objects indexed by MIME type. If no reports are found, the dict will be empty. A [`HarborVulnerabilityReport`][harborapi.models.HarborVulnerabilityReport] is more comprehensive than the [`NativeReportSummary`][harborapi.models.models.NativeReportSummary] returned by [`get_artifact(..., with_scan_overview=True)`](../get-artifact-scan-overview). It contains detailed information about the vulnerabilities found in the artifact. @@ -31,6 +31,19 @@ async def main() -> None: asyncio.run(main()) ``` +The dict returned by the method is a [`FirstDict`][harborapi.models.mappings.FirstDict] object, which is a subclass of Python's built-in `dict` that provides a `first()` method to get the first value in the dict. We often only have a single vulnerability report for an artifact, so we can use the `first()` method to get the report directly: + +```py +report = await client.get_artifact_vulnerabilities( + "library", + "hello-world", + "latest", +) +report = report.first() +``` + +## Filtering vulnerabilities + The [`HarborVulnerabilityReport`][harborapi.models.HarborVulnerabilityReport] class provides a simple interface for filtering the vulnerabilities by severity. For example, if we only want to see vulnerabilities with a [`Severity`][harborapi.models.Severity] of [`critical`][harborapi.models.Severity.critical] we can access the [`HarborVulnerabilityReport.critical`][harborapi.models.HarborVulnerabilityReport.critical] attribute, which is a property that returns a list of [`VulnerabilityItem`][harborapi.models.VulnerabilityItem] objects: ```py @@ -77,7 +90,7 @@ reports = await client.get_artifact_vulnerabilities( "application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0", ], ) -for report in reports: +for mime_type, report in reports.items(): print(report) # OR diff --git a/docs/reference/models/mappings.md b/docs/reference/models/mappings.md new file mode 100644 index 00000000..1aaac91d --- /dev/null +++ b/docs/reference/models/mappings.md @@ -0,0 +1,9 @@ +# harborapi.models.mappings + +Custom mapping types. + +::: harborapi.models.mappings + options: + show_if_no_docstring: true + show_source: true + show_bases: false diff --git a/harborapi/client.py b/harborapi/client.py index 20069f95..bc2fa6da 100644 --- a/harborapi/client.py +++ b/harborapi/client.py @@ -28,6 +28,8 @@ from pydantic import ValidationError from typing_extensions import deprecated +from harborapi.models.mappings import FirstDict + from ._types import JSONType from ._types import QueryParamMapping from .auth import load_harbor_auth_file @@ -3816,7 +3818,7 @@ async def get_artifact_vulnerability_reports( repository_name: str, reference: str, mime_type: Union[str, Sequence[str]] = DEFAULT_MIME_TYPES, - ) -> Dict[str, HarborVulnerabilityReport]: + ) -> FirstDict[str, HarborVulnerabilityReport]: """Get the vulnerability report(s) for an artifact. Parameters @@ -3832,8 +3834,9 @@ async def get_artifact_vulnerability_reports( Returns ------- - Dict[str, HarborVulnerabilityReport] - A dict of vulnerability reports keyed by MIME type + FirstDict[str, HarborVulnerabilityReport] + A dict of vulnerability reports keyed by MIME type. + Supports the `first()` method to get the first report. """ path = get_artifact_path(project_name, repository_name, reference) url = f"{path}/additions/vulnerabilities" @@ -3848,7 +3851,7 @@ async def get_artifact_vulnerability_reports( ) if not isinstance(resp, dict): raise UnprocessableEntity(f"Unable to process response from {url}: {resp}") - reports: Dict[str, HarborVulnerabilityReport] = {} + reports: FirstDict[str, HarborVulnerabilityReport] = FirstDict() if isinstance(mime_type, str): mime_type = [mime_type] for mt in mime_type: diff --git a/harborapi/models/mappings.py b/harborapi/models/mappings.py new file mode 100644 index 00000000..96adc94e --- /dev/null +++ b/harborapi/models/mappings.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import sys +from typing import Optional +from typing import TypeVar + +if sys.version_info >= (3, 9): + from collections import OrderedDict +else: + from typing import OrderedDict + + +_KT = TypeVar("_KT") # key type +_VT = TypeVar("_VT") # value type + + +# NOTE: How to parametrize a normal dict in 3.8? In >=3.9 we can do `dict[_KT, _VT]` +class FirstDict(OrderedDict[_KT, _VT]): + """Dict with method to get its first value.""" + + def first(self) -> Optional[_VT]: + """Return the first value in the dict or None if dict is empty.""" + return next(iter(self.values()), None) diff --git a/mkdocs.yml b/mkdocs.yml index 17592e4d..74bffa68 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -154,6 +154,7 @@ nav: - reference/models/_scanner.md - reference/models/base.md - reference/models/models.md + - reference/models/mappings.md - reference/models/scanner.md - reference/models/buildhistory.md - reference/models/oidc.md diff --git a/tests/endpoints/test_artifacts.py b/tests/endpoints/test_artifacts.py index 30234f2c..1c73537c 100644 --- a/tests/endpoints/test_artifacts.py +++ b/tests/endpoints/test_artifacts.py @@ -16,6 +16,7 @@ from harborapi.exceptions import UnprocessableEntity from harborapi.models import HarborVulnerabilityReport from harborapi.models.buildhistory import BuildHistoryEntry +from harborapi.models.mappings import FirstDict from harborapi.models.models import Accessory from harborapi.models.models import Artifact from harborapi.models.models import Label @@ -120,6 +121,11 @@ async def test_get_artifact_vulnerability_reports_mock( for mime_type, report in r.items(): assert report == report assert mime_type in MIME_TYPES + # Test return type + assert isinstance(r, dict) + assert isinstance(r, FirstDict) + assert r.first() == report + assert list(r.values()) == [report, report, report] @pytest.mark.asyncio @@ -152,6 +158,9 @@ async def test_get_artifact_vulnerability_reports_single_mock( ) assert len(r) == 1 assert r[mime_type] == report + # Test FirstDict methods + assert r.first() == report + assert list(r.values()) == [report] @pytest.mark.asyncio @@ -192,6 +201,11 @@ async def test_get_artifact_vulnerability_reports_raw_mock( assert report == report_dict assert mime_type in MIME_TYPES + # Even in Raw mode, we should still get a FirstDict + assert isinstance(r, FirstDict) + assert r.first() == report_dict + assert list(r.values()) == [report_dict, report_dict, report_dict] + @pytest.mark.asyncio @given(st.lists(st.builds(BuildHistoryEntry))) diff --git a/tests/models/test_mappings.py b/tests/models/test_mappings.py new file mode 100644 index 00000000..e69de29b