Skip to content

Commit 0a05e5c

Browse files
authored
Add FirstDict type (#90)
* Fix vulnerability report docs * Add `FirstDict`
1 parent 22390ba commit 0a05e5c

File tree

8 files changed

+79
-8
lines changed

8 files changed

+79
-8
lines changed

CHANGELOG.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@ While the project is still on major version 0, breaking changes may be introduce
1212

1313
<!-- changelog follows -->
1414

15-
<!-- ## Unreleased -->
15+
## Unreleased
16+
17+
## Added
18+
19+
- `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).
20+
21+
## Changed
22+
23+
- `HarborAsyncClient.get_artifact_vulnerability_reports()` now returns `FirstDict` to provide easier access to the first (and likely only) report for the artifact.
1624

1725
## [0.25.0](https://github.com/unioslo/harborapi/tree/harborapi-v0.25.0) - 2024-06-17
1826

docs/recipes/artifacts/get-artifact-vulnerabilities.md

+16-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Get artifact vulnerability report
1+
# Get artifact vulnerability reports
22

3-
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.
3+
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.
44

55
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.
66

@@ -31,6 +31,19 @@ async def main() -> None:
3131
asyncio.run(main())
3232
```
3333

34+
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:
35+
36+
```py
37+
report = await client.get_artifact_vulnerabilities(
38+
"library",
39+
"hello-world",
40+
"latest",
41+
)
42+
report = report.first()
43+
```
44+
45+
## Filtering vulnerabilities
46+
3447
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:
3548

3649
```py
@@ -77,7 +90,7 @@ reports = await client.get_artifact_vulnerabilities(
7790
"application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0",
7891
],
7992
)
80-
for report in reports:
93+
for mime_type, report in reports.items():
8194
print(report)
8295

8396
# OR

docs/reference/models/mappings.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# harborapi.models.mappings
2+
3+
Custom mapping types.
4+
5+
::: harborapi.models.mappings
6+
options:
7+
show_if_no_docstring: true
8+
show_source: true
9+
show_bases: false

harborapi/client.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from pydantic import ValidationError
2929
from typing_extensions import deprecated
3030

31+
from harborapi.models.mappings import FirstDict
32+
3133
from ._types import JSONType
3234
from ._types import QueryParamMapping
3335
from .auth import load_harbor_auth_file
@@ -3816,7 +3818,7 @@ async def get_artifact_vulnerability_reports(
38163818
repository_name: str,
38173819
reference: str,
38183820
mime_type: Union[str, Sequence[str]] = DEFAULT_MIME_TYPES,
3819-
) -> Dict[str, HarborVulnerabilityReport]:
3821+
) -> FirstDict[str, HarborVulnerabilityReport]:
38203822
"""Get the vulnerability report(s) for an artifact.
38213823
38223824
Parameters
@@ -3832,8 +3834,9 @@ async def get_artifact_vulnerability_reports(
38323834
38333835
Returns
38343836
-------
3835-
Dict[str, HarborVulnerabilityReport]
3836-
A dict of vulnerability reports keyed by MIME type
3837+
FirstDict[str, HarborVulnerabilityReport]
3838+
A dict of vulnerability reports keyed by MIME type.
3839+
Supports the `first()` method to get the first report.
38373840
"""
38383841
path = get_artifact_path(project_name, repository_name, reference)
38393842
url = f"{path}/additions/vulnerabilities"
@@ -3848,7 +3851,7 @@ async def get_artifact_vulnerability_reports(
38483851
)
38493852
if not isinstance(resp, dict):
38503853
raise UnprocessableEntity(f"Unable to process response from {url}: {resp}")
3851-
reports: Dict[str, HarborVulnerabilityReport] = {}
3854+
reports: FirstDict[str, HarborVulnerabilityReport] = FirstDict()
38523855
if isinstance(mime_type, str):
38533856
mime_type = [mime_type]
38543857
for mt in mime_type:

harborapi/models/mappings.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import Optional
5+
from typing import TypeVar
6+
7+
if sys.version_info >= (3, 9):
8+
from collections import OrderedDict
9+
else:
10+
from typing import OrderedDict
11+
12+
13+
_KT = TypeVar("_KT") # key type
14+
_VT = TypeVar("_VT") # value type
15+
16+
17+
# NOTE: How to parametrize a normal dict in 3.8? In >=3.9 we can do `dict[_KT, _VT]`
18+
class FirstDict(OrderedDict[_KT, _VT]):
19+
"""Dict with method to get its first value."""
20+
21+
def first(self) -> Optional[_VT]:
22+
"""Return the first value in the dict or None if dict is empty."""
23+
return next(iter(self.values()), None)

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ nav:
154154
- reference/models/_scanner.md
155155
- reference/models/base.md
156156
- reference/models/models.md
157+
- reference/models/mappings.md
157158
- reference/models/scanner.md
158159
- reference/models/buildhistory.md
159160
- reference/models/oidc.md

tests/endpoints/test_artifacts.py

+14
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from harborapi.exceptions import UnprocessableEntity
1717
from harborapi.models import HarborVulnerabilityReport
1818
from harborapi.models.buildhistory import BuildHistoryEntry
19+
from harborapi.models.mappings import FirstDict
1920
from harborapi.models.models import Accessory
2021
from harborapi.models.models import Artifact
2122
from harborapi.models.models import Label
@@ -120,6 +121,11 @@ async def test_get_artifact_vulnerability_reports_mock(
120121
for mime_type, report in r.items():
121122
assert report == report
122123
assert mime_type in MIME_TYPES
124+
# Test return type
125+
assert isinstance(r, dict)
126+
assert isinstance(r, FirstDict)
127+
assert r.first() == report
128+
assert list(r.values()) == [report, report, report]
123129

124130

125131
@pytest.mark.asyncio
@@ -152,6 +158,9 @@ async def test_get_artifact_vulnerability_reports_single_mock(
152158
)
153159
assert len(r) == 1
154160
assert r[mime_type] == report
161+
# Test FirstDict methods
162+
assert r.first() == report
163+
assert list(r.values()) == [report]
155164

156165

157166
@pytest.mark.asyncio
@@ -192,6 +201,11 @@ async def test_get_artifact_vulnerability_reports_raw_mock(
192201
assert report == report_dict
193202
assert mime_type in MIME_TYPES
194203

204+
# Even in Raw mode, we should still get a FirstDict
205+
assert isinstance(r, FirstDict)
206+
assert r.first() == report_dict
207+
assert list(r.values()) == [report_dict, report_dict, report_dict]
208+
195209

196210
@pytest.mark.asyncio
197211
@given(st.lists(st.builds(BuildHistoryEntry)))

tests/models/test_mappings.py

Whitespace-only changes.

0 commit comments

Comments
 (0)