Skip to content

Commit

Permalink
Add FirstDict type (#90)
Browse files Browse the repository at this point in the history
* Fix vulnerability report docs

* Add `FirstDict`
  • Loading branch information
pederhan authored Jun 18, 2024
1 parent 22390ba commit 0a05e5c
Show file tree
Hide file tree
Showing 8 changed files with 79 additions and 8 deletions.
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ While the project is still on major version 0, breaking changes may be introduce

<!-- changelog follows -->

<!-- ## Unreleased -->
## 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

Expand Down
19 changes: 16 additions & 3 deletions docs/recipes/artifacts/get-artifact-vulnerabilities.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/models/mappings.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 7 additions & 4 deletions harborapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions harborapi/models/mappings.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/endpoints/test_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)))
Expand Down
Empty file added tests/models/test_mappings.py
Empty file.

0 comments on commit 0a05e5c

Please sign in to comment.