Skip to content

Commit 3c40973

Browse files
minitrigaclaude
andcommitted
Address code review: NoReturn _fail, _status_console rename, SETTINGS config, helper extraction
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 1e3ee7a commit 3c40973

4 files changed

Lines changed: 90 additions & 59 deletions

File tree

dev/specs/001-marketplace-api-update/quickstart.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Quickstart: Marketplace Download Command
1+
# Quickstart: Marketplace Get Command
22

3-
End-to-end smoke test for the updated `infrahubctl marketplace download` command. These steps exercise each acceptance scenario in `spec.md` and should be green before merging.
3+
End-to-end smoke test for the updated `infrahubctl marketplace get` command. These steps exercise each acceptance scenario in `spec.md` and should be green before merging.
44

55
## Prerequisites
66

@@ -28,33 +28,33 @@ All existing tests must stay green, and new tests MUST be added to cover:
2828

2929
```bash
3030
# Scenario 1: download a schema by auto-detection
31-
uv run infrahubctl marketplace download acme/network-base
31+
uv run infrahubctl marketplace get acme/network-base
3232
ls schemas/
3333

3434
# Scenario 2: download a collection by auto-detection
35-
uv run infrahubctl marketplace download acme/starter-pack
35+
uv run infrahubctl marketplace get acme/starter-pack
3636
ls schemas/
3737

3838
# Scenario 3: pin a specific schema version
39-
uv run infrahubctl marketplace download acme/network-base --version 0.9.0
39+
uv run infrahubctl marketplace get acme/network-base --version 0.9.0
4040
grep '^version:' schemas/network-base.yml
4141

4242
# Scenario 4: custom output directory
43-
uv run infrahubctl marketplace download acme/network-base --output-dir ./tmp/market-test
43+
uv run infrahubctl marketplace get acme/network-base --output-dir ./tmp/market-test
4444
ls ./tmp/market-test
4545

4646
# Scenario 5: explicit --collection still works (override path)
47-
uv run infrahubctl marketplace download acme/starter-pack --collection
47+
uv run infrahubctl marketplace get acme/starter-pack --collection
4848

4949
# Scenario 6: version on a collection emits a warning, proceeds
50-
uv run infrahubctl marketplace download acme/starter-pack --version 1.0.0
50+
uv run infrahubctl marketplace get acme/starter-pack --version 1.0.0
5151
# Expect: "Warning: --version is ignored when downloading a collection." followed by success output.
5252
```
5353

5454
## Manual verification against a local/staging marketplace
5555

5656
```bash
57-
uv run infrahubctl marketplace download acme/test \
57+
uv run infrahubctl marketplace get acme/test \
5858
--marketplace-url http://localhost:8000 \
5959
--output-dir ./tmp/local-market
6060
```

infrahub_sdk/ctl/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
2727
server_address: str = Field(default="http://localhost:8000", validation_alias="infrahub_address")
2828
api_token: str | None = Field(default=None)
2929
default_branch: str = Field(default="main")
30+
marketplace_url: str = Field(
31+
default="https://marketplace.infrahub.app", description="Base URL for the Infrahub Marketplace."
32+
)
3033

3134
@field_validator("server_address")
3235
@classmethod

infrahub_sdk/ctl/marketplace.py

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from contextlib import suppress
66
from enum import Enum
77
from pathlib import Path
8-
from typing import Any, Literal
8+
from typing import Any, Literal, NoReturn
99
from urllib.parse import urlparse
1010

1111
import httpx
@@ -14,6 +14,7 @@
1414

1515
from ..async_typer import AsyncTyper
1616
from ..config import ConfigBase as _SdkConfig
17+
from ..ctl.config import SETTINGS
1718
from ..ctl.parameters import CONFIG_PARAM
1819
from ..ctl.utils import catch_exception
1920

@@ -34,12 +35,12 @@ def exit_code(self) -> int:
3435
return 2 if self is _ErrorClass.NETWORK else 1
3536

3637

37-
def _fail(error_class: _ErrorClass, message: str) -> typer.Exit:
38+
def _fail(error_class: _ErrorClass, message: str) -> NoReturn:
3839
err_console.print(f"[red]{message}")
39-
return typer.Exit(error_class.exit_code)
40+
raise typer.Exit(error_class.exit_code)
4041

4142

42-
def _status(stdout: bool) -> Console:
43+
def _status_console(stdout: bool) -> Console:
4344
return err_console if stdout else console
4445

4546

@@ -51,19 +52,29 @@ def callback() -> None:
5152
def _parse_identifier(identifier: str) -> tuple[str, str]:
5253
parts = identifier.split("/")
5354
if len(parts) != 2 or not all(parts):
54-
raise _fail(_ErrorClass.INVALID_INPUT, f"Invalid identifier '{identifier}'. Expected format: namespace/name")
55+
_fail(_ErrorClass.INVALID_INPUT, f"Invalid identifier '{identifier}'. Expected format: namespace/name")
5556
return parts[0], parts[1]
5657

5758

5859
def _host_from(base_url: str) -> str:
5960
return urlparse(base_url).netloc or base_url
6061

6162

63+
def _schema_url(base_url: str, namespace: str, name: str, version: str | None = None) -> str:
64+
if version:
65+
return f"{base_url}/api/v1/schemas/{namespace}/{name}/versions/{version}/download"
66+
return f"{base_url}/api/v1/schemas/{namespace}/{name}/download"
67+
68+
69+
def _collection_url(base_url: str, namespace: str, name: str) -> str:
70+
return f"{base_url}/api/v1/collections/{namespace}/{name}/download"
71+
72+
6273
def _mkdir_or_fail(path: Path) -> None:
6374
try:
6475
path.mkdir(parents=True, exist_ok=True)
6576
except OSError as exc:
66-
raise _fail(_ErrorClass.INVALID_INPUT, f"Cannot write to '{path}': {exc}") from exc
77+
_fail(_ErrorClass.INVALID_INPUT, f"Cannot write to '{path}': {exc}")
6778

6879

6980
def _make_http_client(sdk_cfg: _SdkConfig) -> httpx.AsyncClient:
@@ -93,8 +104,8 @@ async def _detect_item_type(
93104
Returns the resolved type and the winning 200 response so the caller can reuse
94105
it instead of re-fetching the same URL.
95106
"""
96-
schema_url = f"{base_url}/api/v1/schemas/{namespace}/{name}/download"
97-
collection_url = f"{base_url}/api/v1/collections/{namespace}/{name}/download"
107+
schema_url = _schema_url(base_url, namespace, name)
108+
collection_url = _collection_url(base_url, namespace, name)
98109

99110
schema_resp, collection_resp = await asyncio.gather(
100111
client.get(schema_url),
@@ -104,7 +115,7 @@ async def _detect_item_type(
104115

105116
if isinstance(schema_resp, httpx.Response) and schema_resp.status_code == 200:
106117
if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200:
107-
_status(stdout).print(
118+
_status_console(stdout).print(
108119
f"[yellow]Note: '{namespace}/{name}' exists as both a schema and a collection. "
109120
"Resolving as schema. Pass --collection to force the collection path."
110121
)
@@ -118,12 +129,12 @@ def is_transport_failure(r: object) -> bool:
118129
return isinstance(r, httpx.Response) and r.status_code >= 500
119130

120131
if is_transport_failure(schema_resp) or is_transport_failure(collection_resp):
121-
raise _fail(
132+
_fail(
122133
_ErrorClass.NETWORK,
123134
f"Could not reach marketplace at {base_url}. Check your connection or --marketplace-url.",
124135
)
125136

126-
raise _fail(
137+
_fail(
127138
_ErrorClass.NOT_FOUND,
128139
f"No schema or collection named '{namespace}/{name}' found on {_host_from(base_url)}.",
129140
)
@@ -139,32 +150,32 @@ async def _download_schema(
139150
*,
140151
stdout: bool,
141152
prefetched: httpx.Response | None = None,
153+
schema_confirmed_exists: bool = False,
142154
) -> None:
143155
"""Download a single schema and write it to disk or stdout.
144156
145157
When ``prefetched`` is supplied and ``version`` is None, reuses the response
146158
instead of re-fetching the unversioned download URL.
159+
``schema_confirmed_exists`` signals that the schema is known to exist (e.g. from
160+
the auto-detect probe), so a 404 on a versioned URL is reported as version-not-found
161+
rather than the generic not-found message.
147162
"""
148163
if prefetched is not None and version is None:
149164
resp = prefetched
150165
else:
151-
if version:
152-
url = f"{base_url}/api/v1/schemas/{namespace}/{name}/versions/{version}/download"
153-
else:
154-
url = f"{base_url}/api/v1/schemas/{namespace}/{name}/download"
155-
resp = await client.get(url)
166+
resp = await client.get(_schema_url(base_url, namespace, name, version=version))
156167

157168
if resp.status_code == 404:
158-
if version is not None and prefetched is not None:
159-
raise _fail(
169+
if version is not None and schema_confirmed_exists:
170+
_fail(
160171
_ErrorClass.NOT_FOUND,
161172
f"Schema '{namespace}/{name}' has no published version '{version}'. "
162173
"Run without --version for the latest.",
163174
)
164175
detail = "not found"
165176
with suppress(Exception):
166177
detail = resp.json().get("detail", detail)
167-
raise _fail(_ErrorClass.NOT_FOUND, f"Error: {detail}")
178+
_fail(_ErrorClass.NOT_FOUND, f"Error: {detail}")
168179
resp.raise_for_status()
169180

170181
resolved_version = version or resp.headers.get("x-schema-version", "latest")
@@ -202,20 +213,19 @@ async def _download_collection(
202213
if prefetched is not None:
203214
resp = prefetched
204215
else:
205-
url = f"{base_url}/api/v1/collections/{namespace}/{name}/download"
206-
resp = await client.get(url)
216+
resp = await client.get(_collection_url(base_url, namespace, name))
207217
if resp.status_code == 404:
208218
detail = "not found"
209219
with suppress(Exception):
210220
detail = resp.json().get("detail", detail)
211-
raise _fail(_ErrorClass.NOT_FOUND, f"Error: {detail}")
221+
_fail(_ErrorClass.NOT_FOUND, f"Error: {detail}")
212222
resp.raise_for_status()
213223

214224
data = resp.json()
215225
meta = data["collection"]
216226
schemas = data["schemas"]
217227
skipped = meta.get("skipped", [])
218-
status = _status(stdout)
228+
status = _status_console(stdout)
219229

220230
if stdout:
221231
for index, schema in enumerate(schemas):
@@ -280,27 +290,42 @@ async def get(
280290
namespace, name = _parse_identifier(identifier)
281291

282292
sdk_cfg = _SdkConfig()
283-
resolved_url = (marketplace_url or sdk_cfg.marketplace_url).rstrip("/")
293+
resolved_url = (marketplace_url or SETTINGS.active.marketplace_url).rstrip("/")
284294

285295
async with _make_http_client(sdk_cfg) as client:
286296
prefetched: httpx.Response | None = None
297+
schema_confirmed_exists = False
287298
if collection is None:
288299
item_type, prefetched = await _detect_item_type(client, resolved_url, namespace, name, stdout=stdout)
300+
schema_confirmed_exists = item_type == "schema"
289301
elif collection:
290302
item_type = "collection"
291303
else:
304+
# Typer exposes only --collection, not --no-collection, so collection=False
305+
# is unreachable from the CLI. Kept for defensive completeness against the
306+
# bool | None type.
292307
item_type = "schema"
293308

294309
try:
295310
if item_type == "collection":
296311
if version:
297-
_status(stdout).print("[yellow]Warning: --version is ignored when downloading a collection.")
312+
_status_console(stdout).print(
313+
"[yellow]Warning: --version is ignored when downloading a collection."
314+
)
298315
await _download_collection(
299316
client, resolved_url, namespace, name, output_dir, stdout=stdout, prefetched=prefetched
300317
)
301318
else:
302319
await _download_schema(
303-
client, resolved_url, namespace, name, version, output_dir, stdout=stdout, prefetched=prefetched
320+
client,
321+
resolved_url,
322+
namespace,
323+
name,
324+
version,
325+
output_dir,
326+
stdout=stdout,
327+
prefetched=prefetched,
328+
schema_confirmed_exists=schema_confirmed_exists,
304329
)
305330
except httpx.HTTPError as exc:
306-
raise _fail(_ErrorClass.NETWORK, f"Marketplace request failed: {exc}") from exc
331+
_fail(_ErrorClass.NETWORK, f"Marketplace request failed: {exc}")

tests/unit/ctl/test_marketplace_app.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pytest_httpx import HTTPXMock
66
from typer.testing import CliRunner
77

8+
from infrahub_sdk.ctl import config as ctl_config
89
from infrahub_sdk.ctl.marketplace import app
910

1011
runner = CliRunner()
@@ -18,28 +19,6 @@
1819
"""
1920

2021

21-
def test_download_schema_latest(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
22-
httpx_mock.add_response(
23-
method="GET",
24-
url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download",
25-
text=SCHEMA_YAML,
26-
headers={"x-schema-version": "1.2.0"},
27-
)
28-
httpx_mock.add_response(
29-
method="GET",
30-
url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download",
31-
status_code=404,
32-
json={"detail": "Collection not found"},
33-
)
34-
result = runner.invoke(app, ["get", "acme/network-base", "-o", str(tmp_path)])
35-
36-
assert result.exit_code == 0
37-
assert "Downloaded schema acme/network-base v1.2.0" in result.stdout
38-
written = tmp_path / "network-base.yml"
39-
assert written.exists()
40-
assert written.read_text() == SCHEMA_YAML
41-
42-
4322
def test_download_schema_specific_version(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
4423
# Auto-detect probes
4524
httpx_mock.add_response(
@@ -66,6 +45,7 @@ def test_download_schema_specific_version(httpx_mock: HTTPXMock, tmp_path: Path)
6645
assert "Downloaded schema acme/network-base v0.9.0" in result.stdout
6746
written = tmp_path / "network-base.yml"
6847
assert written.exists()
48+
assert written.read_text() == SCHEMA_YAML
6949

7050

7151
def test_download_collection(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
@@ -124,7 +104,7 @@ def test_download_not_found(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
124104
result = runner.invoke(app, ["get", "acme/nonexistent", "-o", str(tmp_path)])
125105

126106
assert result.exit_code == 1
127-
assert "acme/nonexistent" in result.stdout
107+
assert "No schema or collection named 'acme/nonexistent'" in result.stdout
128108
assert "marketplace.infrahub.app" in result.stdout
129109

130110

@@ -157,6 +137,29 @@ def test_download_custom_marketplace_url(httpx_mock: HTTPXMock, tmp_path: Path)
157137
assert "Downloaded schema acme/test v1.0.0" in result.stdout
158138

159139

140+
def test_marketplace_url_from_env(httpx_mock: HTTPXMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
141+
# SETTINGS is a module-level singleton that only loads once. Reset it so the
142+
# next invoke re-reads from env, then patch the env var before that happens.
143+
monkeypatch.setattr(ctl_config.SETTINGS, "_settings", None)
144+
monkeypatch.setenv("INFRAHUB_MARKETPLACE_URL", "http://staging.example.com")
145+
httpx_mock.add_response(
146+
method="GET",
147+
url="http://staging.example.com/api/v1/schemas/acme/network-base/download",
148+
text=SCHEMA_YAML,
149+
headers={"x-schema-version": "1.0.0"},
150+
)
151+
httpx_mock.add_response(
152+
method="GET",
153+
url="http://staging.example.com/api/v1/collections/acme/network-base/download",
154+
status_code=404,
155+
json={"detail": "Collection not found"},
156+
)
157+
result = runner.invoke(app, ["get", "acme/network-base", "-o", str(tmp_path)])
158+
159+
assert result.exit_code == 0
160+
assert "Downloaded schema acme/network-base v1.0.0" in result.stdout
161+
162+
160163
def test_autodetect_schema(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
161164
httpx_mock.add_response(
162165
method="GET",

0 commit comments

Comments
 (0)