Skip to content

Commit 7f7ae9d

Browse files
fix: metadata enum update (#298)
* fix: metadata enum update * fix: copilot feedback
1 parent 65f985a commit 7f7ae9d

5 files changed

Lines changed: 67 additions & 143 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
88

99
## [Unreleased]
1010

11+
### Fixed
12+
13+
- `metadata list` filters (`--source-kind`, `--classification`) now send the enum name the v2 API expects instead of an integer, fixing an HTTP 400 on every filtered list. Valid source kinds: `unknown, system, upstream, custom, third_party`; classifications: `unknown, intrinsic, security, provenance, sbom, generic`.
14+
15+
1116
## [1.17.0] - 2026-05-18
1217

1318
### Added

cloudsmith_cli/cli/commands/metadata.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
delete_metadata as api_delete_metadata,
1010
get_metadata as api_get_metadata,
1111
list_metadata as api_list_metadata,
12-
normalise_classification,
13-
normalise_source_kind,
1412
update_metadata as api_update_metadata,
1513
)
1614
from ...core.api.packages import get_package_slug_perm as api_get_package_slug_perm
@@ -121,19 +119,17 @@ def metadata_(ctx, opts): # pylint: disable=unused-argument
121119
"source_kind",
122120
default=None,
123121
help=(
124-
"Filter by source kind. Accepts an integer or name "
125-
"(for example, 'customer' or 'third_party'). Ignored when "
126-
"METADATA_SLUG_PERM is given."
122+
"Filter by source kind name (one of: unknown, system, upstream, "
123+
"custom, third_party). Ignored when METADATA_SLUG_PERM is given."
127124
),
128125
)
129126
@click.option(
130127
"--classification",
131128
"classification",
132129
default=None,
133130
help=(
134-
"Filter by classification. Accepts an integer or name "
135-
"(for example, 'provenance' or 'sbom'). Ignored when "
136-
"METADATA_SLUG_PERM is given."
131+
"Filter by classification name (one of: unknown, intrinsic, security, "
132+
"provenance, sbom, generic). Ignored when METADATA_SLUG_PERM is given."
137133
),
138134
)
139135
@click.pass_context
@@ -187,14 +183,6 @@ def list_metadata(
187183
_print_metadata_entry(opts, entry)
188184
return
189185

190-
# Validate filter values up-front for a friendlier error than what the
191-
# API would return (the normalisers raise ValueError on invalid values).
192-
try:
193-
normalise_source_kind(source_kind)
194-
normalise_classification(classification)
195-
except ValueError as exc:
196-
raise click.UsageError(str(exc)) from exc
197-
198186
_echo_action(
199187
"Listing metadata for %(package)s ... "
200188
% {"package": click.style(package, bold=True)},

cloudsmith_cli/cli/tests/commands/test_metadata.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,16 @@ def test_list_passes_filters(self, mock_resolve, mock_list):
111111
"list",
112112
"myorg/myrepo/mypkg",
113113
"--source-kind",
114-
"customer",
114+
"custom",
115115
"--classification",
116-
"4",
116+
"generic",
117117
],
118118
)
119119

120120
self.assertEqual(result.exit_code, 0, msg=result.output)
121121
kwargs = mock_list.call_args.kwargs
122-
self.assertEqual(kwargs["source_kind"], "customer")
123-
self.assertEqual(kwargs["classification"], "4")
122+
self.assertEqual(kwargs["source_kind"], "custom")
123+
self.assertEqual(kwargs["classification"], "generic")
124124

125125
@patch("cloudsmith_cli.cli.commands.metadata.api_list_metadata")
126126
@patch("cloudsmith_cli.cli.commands.metadata.api_get_package_slug_perm")
@@ -132,7 +132,7 @@ def test_list_json_output(self, mock_resolve, mock_list):
132132
"slug_perm": "abc",
133133
"content_type": "application/json",
134134
"classification": "GENERIC",
135-
"source_kind": "CUSTOMER",
135+
"source_kind": "CUSTOM",
136136
"source_identity": "cloudsmith-cli@1.16.0",
137137
}
138138
],
@@ -150,17 +150,22 @@ def test_list_json_output(self, mock_resolve, mock_list):
150150

151151
@patch("cloudsmith_cli.cli.commands.metadata.api_list_metadata")
152152
@patch("cloudsmith_cli.cli.commands.metadata.api_get_package_slug_perm")
153-
def test_list_invalid_filter_value_is_usage_error(self, mock_resolve, mock_list):
153+
def test_list_forwards_filter_name_without_client_validation(
154+
self, mock_resolve, mock_list
155+
):
156+
# Filter values are no longer validated client-side; an unknown name is
157+
# forwarded verbatim (the backend is the source of truth and 4xxs it).
154158
mock_resolve.return_value = "pkg-slug-perm"
159+
mock_list.return_value = ([], _empty_page_info())
155160

156161
result = self.runner.invoke(
157162
metadata_,
158163
["list", "myorg/myrepo/mypkg", "--source-kind", "not-a-kind"],
159164
)
160165

161-
self.assertNotEqual(result.exit_code, 0)
162-
self.assertIn("source_kind", result.output.lower())
163-
mock_list.assert_not_called()
166+
self.assertEqual(result.exit_code, 0, msg=result.output)
167+
mock_list.assert_called_once()
168+
self.assertEqual(mock_list.call_args.kwargs["source_kind"], "not-a-kind")
164169

165170
@patch("cloudsmith_cli.cli.commands.metadata.api_list_metadata")
166171
@patch("cloudsmith_cli.cli.commands.metadata.api_get_package_slug_perm")

cloudsmith_cli/core/api/metadata.py

Lines changed: 9 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -10,60 +10,6 @@
1010
from ..rest import RestClient
1111
from .exceptions import catch_raise_api_exception
1212

13-
SOURCE_KIND_VALUES = {
14-
"unknown": 0,
15-
"system": 1,
16-
"ecosystem": 2,
17-
"customer": 3,
18-
"third_party": 4,
19-
}
20-
21-
CLASSIFICATION_VALUES = {
22-
"unknown": 0,
23-
"intrinsic": 1,
24-
"upstream": 2,
25-
"security": 3,
26-
"provenance": 4,
27-
"sbom": 5,
28-
"generic": 6,
29-
}
30-
31-
32-
def _normalise_enum(value, mapping, name):
33-
if value is None:
34-
return None
35-
if isinstance(value, bool):
36-
raise ValueError(f"Invalid {name} value: {value!r}")
37-
if isinstance(value, int):
38-
return value
39-
if isinstance(value, str):
40-
text = value.strip()
41-
if not text:
42-
raise ValueError(f"Invalid {name} value: {value!r}")
43-
try:
44-
return int(text)
45-
except ValueError:
46-
pass
47-
key = text.lower().replace("-", "_")
48-
try:
49-
return mapping[key]
50-
except KeyError:
51-
valid = ", ".join(sorted(mapping))
52-
raise ValueError(
53-
f"Invalid {name} {value!r}. Expected an integer or one of: {valid}."
54-
)
55-
raise ValueError(f"Invalid {name} type: {type(value).__name__}")
56-
57-
58-
def normalise_source_kind(value):
59-
"""Coerce a MetadataSourceKind name or integer to its integer value."""
60-
return _normalise_enum(value, SOURCE_KIND_VALUES, "source_kind")
61-
62-
63-
def normalise_classification(value):
64-
"""Coerce a MetadataClassification name or integer to its integer value."""
65-
return _normalise_enum(value, CLASSIFICATION_VALUES, "classification")
66-
6713

6814
class _MetadataApi:
6915
"""Small client for metadata endpoints not yet present in cloudsmith_api."""
@@ -136,28 +82,27 @@ def _response_json(response):
13682
def list_metadata(
13783
package_slug_perm: str,
13884
*,
139-
source_kind: int | str | None = None,
140-
classification: int | str | None = None,
85+
source_kind: str | None = None,
86+
classification: str | None = None,
14187
page: int | None = None,
14288
page_size: int | None = None,
14389
):
14490
"""List metadata entries attached to a package.
14591
146-
`source_kind` and `classification` may be supplied as either an integer
147-
or the matching enum name (case-insensitive); both are converted to the
148-
integer the v2 API expects before the request is issued.
92+
`source_kind` and `classification` are sent as the lowercased enum name
93+
the v2 API expects; the authoritative backend validates the value and
94+
surfaces a 4xx for anything it does not recognise.
14995
15096
Returns a (results, PageInfo) tuple.
15197
"""
15298
client = get_metadata_api()
15399
api_kwargs = {}
154100

155-
source_kind_value = normalise_source_kind(source_kind)
156-
if source_kind_value is not None:
101+
source_kind_value = str(source_kind).strip().lower() if source_kind else ""
102+
if source_kind_value:
157103
api_kwargs["source_kind"] = source_kind_value
158-
159-
classification_value = normalise_classification(classification)
160-
if classification_value is not None:
104+
classification_value = str(classification).strip().lower() if classification else ""
105+
if classification_value:
161106
api_kwargs["classification"] = classification_value
162107

163108
api_kwargs.update(utils.get_page_kwargs(page=page, page_size=page_size))

cloudsmith_cli/core/tests/test_metadata.py

Lines changed: 35 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -53,52 +53,6 @@ def _last_request():
5353
return httpretty.last_request()
5454

5555

56-
class TestNormalisers:
57-
@pytest.mark.parametrize(
58-
"value, expected",
59-
[
60-
(None, None),
61-
(3, 3),
62-
("3", 3),
63-
("customer", 3),
64-
("CUSTOMER", 3),
65-
("Third-Party", 4),
66-
("third_party", 4),
67-
],
68-
)
69-
def test_source_kind(self, value, expected):
70-
assert metadata.normalise_source_kind(value) == expected
71-
72-
@pytest.mark.parametrize(
73-
"value, expected",
74-
[
75-
(None, None),
76-
(6, 6),
77-
("generic", 6),
78-
("GENERIC", 6),
79-
("PROVENANCE", 4),
80-
],
81-
)
82-
def test_classification(self, value, expected):
83-
assert metadata.normalise_classification(value) == expected
84-
85-
def test_invalid_source_kind_name(self):
86-
with pytest.raises(ValueError, match="Invalid source_kind"):
87-
metadata.normalise_source_kind("not-a-kind")
88-
89-
def test_invalid_classification_name(self):
90-
with pytest.raises(ValueError, match="Invalid classification"):
91-
metadata.normalise_classification("nope")
92-
93-
def test_invalid_type(self):
94-
with pytest.raises(ValueError):
95-
metadata.normalise_source_kind(3.14)
96-
97-
def test_bool_rejected(self):
98-
with pytest.raises(ValueError):
99-
metadata.normalise_source_kind(True)
100-
101-
10256
class TestListMetadata:
10357
@httpretty.activate(allow_net_connect=False)
10458
def test_success_returns_results_and_page_info(self):
@@ -128,7 +82,7 @@ def test_success_returns_results_and_page_info(self):
12882
assert sent.headers.get("Accept") == "application/json"
12983

13084
@httpretty.activate(allow_net_connect=False)
131-
def test_filters_normalised_to_integers(self):
85+
def test_filters_sent_as_lowercased_names(self):
13286
httpretty.register_uri(
13387
httpretty.GET,
13488
LIST_URL,
@@ -138,12 +92,12 @@ def test_filters_normalised_to_integers(self):
13892
)
13993

14094
metadata.list_metadata(
141-
PKG, source_kind="customer", classification="GENERIC", page=2, page_size=50
95+
PKG, source_kind="custom", classification="GENERIC", page=2, page_size=50
14296
)
14397

14498
qs = _last_request().querystring # pylint: disable=no-member
145-
assert qs["source_kind"] == ["3"]
146-
assert qs["classification"] == ["6"]
99+
assert qs["source_kind"] == ["custom"]
100+
assert qs["classification"] == ["generic"]
147101
assert qs["page"] == ["2"]
148102
assert qs["page_size"] == ["50"]
149103

@@ -163,6 +117,24 @@ def test_non_positive_page_options_omitted(self):
163117
assert "page" not in qs
164118
assert "page_size" not in qs
165119

120+
@httpretty.activate(allow_net_connect=False)
121+
def test_blank_filters_omitted(self):
122+
# A whitespace-only filter normalises to an empty string, which must be
123+
# dropped rather than sent as an empty query param (the backend 4xxs it).
124+
httpretty.register_uri(
125+
httpretty.GET,
126+
LIST_URL,
127+
body=json.dumps({"results": []}),
128+
status=200,
129+
content_type="application/json",
130+
)
131+
132+
metadata.list_metadata(PKG, source_kind=" ", classification="")
133+
134+
qs = _last_request().querystring # pylint: disable=no-member
135+
assert "source_kind" not in qs
136+
assert "classification" not in qs
137+
166138
@httpretty.activate(allow_net_connect=False)
167139
def test_404_raises_api_exception(self):
168140
httpretty.register_uri(
@@ -180,10 +152,17 @@ def test_404_raises_api_exception(self):
180152
assert exc_info.value.detail == "Not found."
181153

182154
@httpretty.activate(allow_net_connect=False)
183-
def test_422_raises_with_fields(self):
155+
def test_invalid_filter_name_surfaces_backend_error(self):
156+
# The API client lowercases/strips filter values but does not validate
157+
# them; an unknown name is forwarded and the backend rejects it via
158+
# EnumFieldV2.
159+
message = (
160+
"bogus is not valid for source_kind - must be one of "
161+
"['UNKNOWN', 'SYSTEM', 'UPSTREAM', 'CUSTOM', 'THIRD_PARTY']"
162+
)
184163
body = {
185164
"detail": "Invalid query parameters.",
186-
"fields": {"source_kind": ["Not a valid choice."]},
165+
"fields": {"source_kind": [message]},
187166
}
188167
httpretty.register_uri(
189168
httpretty.GET,
@@ -194,10 +173,12 @@ def test_422_raises_with_fields(self):
194173
)
195174

196175
with pytest.raises(ApiException) as exc_info:
197-
metadata.list_metadata(PKG, source_kind=3)
176+
metadata.list_metadata(PKG, source_kind="bogus")
198177

178+
qs = _last_request().querystring # pylint: disable=no-member
179+
assert qs["source_kind"] == ["bogus"]
199180
assert exc_info.value.status == 422
200-
assert exc_info.value.fields == {"source_kind": ["Not a valid choice."]}
181+
assert exc_info.value.fields == {"source_kind": [message]}
201182

202183

203184
class TestGetMetadata:

0 commit comments

Comments
 (0)