Skip to content

Commit e7327dc

Browse files
committed
feat(ctl): reconcile existing schemas when resolving dependencies
Avoid duplicating a schema across the output tree when resolving dependencies: before writing, reconcile against schemas already present (e.g. a dependency at the root vs. a copy under a collection directory from a prior download). An already-present schema is kept by default, or overwritten in place with the new `-y`/`--yes` flag; an interactive terminal is prompted. Only files present before the run are considered, and the check is skipped entirely without --dependencies and in --stdout mode, so existing behavior is unchanged. Refs: IHS-246, #1117
1 parent e4d4c78 commit e7327dc

4 files changed

Lines changed: 156 additions & 4 deletions

File tree

changelog/1117.added.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Added a `--dependencies` flag to `infrahubctl marketplace get`. When downloading a schema or a collection, it now also resolves and downloads the schemas they depend on, via the marketplace API. For collections, prerequisite collections are grouped into their own directories and standalone dependency schemas land in the output root; for a single schema, its transitive dependencies are downloaded alongside it. Resolution is transitive and cycle-safe, and referenced kinds the marketplace cannot resolve are reported as unresolved dependencies.
1+
Added a `--dependencies` flag to `infrahubctl marketplace get`. When downloading a schema or a collection, it now also resolves and downloads the schemas they depend on, via the marketplace API. For collections, prerequisite collections are grouped into their own directories and standalone dependency schemas land in the output root; for a single schema, its transitive dependencies are downloaded alongside it. Resolution is transitive and cycle-safe, and referenced kinds the marketplace cannot resolve are reported as unresolved dependencies. A schema that already exists in the output directory is reconciled to a single file rather than duplicated across directories — kept by default, or overwritten with the new `-y`/`--yes` flag.

docs/docs/infrahubctl/infrahubctl-marketplace.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ $ infrahubctl marketplace get [OPTIONS] IDENTIFIER
4040
* `-v, --version TEXT`: Specific schema version, for example 1.2.0. Default: latest published.
4141
* `-c, --collection`: Force collection download. Default: auto-detect whether the identifier is a schema or collection.
4242
* `--dependencies`: Also download the schemas this schema or collection depends on.
43+
* `-y, --yes`: Overwrite schemas that already exist in the output directory without prompting.
4344
* `-s, --stdout`: Print content to stdout instead of writing to disk. Status messages go to stderr.
4445
* `-o, --output-dir PATH`: Directory to save downloaded files. [default: schemas]
4546
* `--marketplace-url TEXT`: Base URL of the Infrahub Marketplace. Overrides configuration and environment.

infrahub_sdk/ctl/marketplace.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,44 @@ def _mkdir_or_fail(path: Path) -> None:
9292
_fail(_ErrorClass.INVALID_INPUT, f"Cannot write to '{path}': {exc}")
9393

9494

95+
class _WriteContext(NamedTuple):
96+
"""Controls overwriting when a schema already exists in the output tree.
97+
98+
``preexisting`` maps a schema filename (``<name>.yml``) to the path where it was found in
99+
the output directory *before* this command ran, so a schema being written now that already
100+
exists elsewhere (e.g. a dependency at the root vs. a copy under a collection directory) is
101+
reconciled to a single file instead of duplicated. Only files present before the run are
102+
considered, so schemas written during this same run never trigger a prompt.
103+
"""
104+
105+
assume_yes: bool
106+
preexisting: dict[str, Path]
107+
108+
109+
def _snapshot_existing_schemas(output_root: Path) -> dict[str, Path]:
110+
"""Map ``<name>.yml`` -> existing path for every schema already under ``output_root``."""
111+
existing: dict[str, Path] = {}
112+
if output_root.exists():
113+
for path in sorted(output_root.rglob("*.yml")):
114+
if path.is_file():
115+
existing.setdefault(path.name, path)
116+
return existing
117+
118+
119+
def _confirm_overwrite(prompt: str, *, assume_yes: bool) -> bool:
120+
"""Return whether to overwrite an existing schema file.
121+
122+
``--yes`` overwrites unconditionally; an interactive terminal is prompted; a
123+
non-interactive run without ``--yes`` declines (keep the existing file) so scripts and CI
124+
never block or clobber.
125+
"""
126+
if assume_yes:
127+
return True
128+
if not sys.stdin.isatty():
129+
return False
130+
return typer.confirm(prompt, default=False)
131+
132+
95133
def _make_http_client(sdk_cfg: _SdkConfig) -> httpx.AsyncClient:
96134
"""Build an httpx client that inherits the SDK's proxy and TLS configuration."""
97135
proxy_kwargs: dict[str, Any] = {}
@@ -163,6 +201,7 @@ async def _download_schema(
163201
schema_confirmed_exists: bool = False,
164202
needs_separator: bool = False,
165203
soft_fail: bool = False,
204+
write_ctx: _WriteContext | None = None,
166205
) -> bool:
167206
"""Download a single schema and write it to disk or stdout.
168207
@@ -179,8 +218,23 @@ async def _download_schema(
179218
from a collection) form a valid multi-document YAML stream.
180219
``soft_fail`` downgrades a 404 to an informational note and a ``False`` return instead of
181220
aborting — used for resolved dependencies so one missing dependency does not fail the
182-
whole download.
221+
whole download. ``write_ctx`` reconciles against schemas already present on disk: an
222+
already-present schema is overwritten (``--yes``/prompt) or kept, decided before fetching
223+
so a kept schema costs no download.
183224
"""
225+
filename = f"{name}.yml"
226+
227+
# Reconcile with a pre-existing copy before fetching (disk mode only).
228+
existing_path = write_ctx.preexisting.get(filename) if write_ctx is not None and not stdout else None
229+
if existing_path is not None and not _confirm_overwrite(
230+
f"{namespace}/{name} already exists at {existing_path}. Overwrite?",
231+
assume_yes=write_ctx.assume_yes if write_ctx else False,
232+
):
233+
_status_console(stdout).print(
234+
f"[yellow]Kept existing {existing_path}; skipped {namespace}/{name} (pass --yes to overwrite)."
235+
)
236+
return False
237+
184238
if prefetched is not None and version is None:
185239
resp = prefetched
186240
else:
@@ -215,7 +269,11 @@ async def _download_schema(
215269
err_console.print(f"[green]Fetched schema {namespace}/{name} v{resolved_version}")
216270
return True
217271

218-
filename = f"{name}.yml"
272+
if existing_path is not None:
273+
existing_path.write_text(resp.text, encoding="utf-8")
274+
console.print(f"[green]Updated schema {namespace}/{name} v{resolved_version} -> {existing_path}")
275+
return True
276+
219277
_mkdir_or_fail(output_dir)
220278
file_path = output_dir / filename
221279
file_path.write_text(resp.text, encoding="utf-8")
@@ -354,6 +412,7 @@ async def _download_schema_set(
354412
already_written: int = 0,
355413
soft_fail: bool = False,
356414
reserved_names: set[str] | None = None,
415+
write_ctx: _WriteContext | None = None,
357416
) -> int:
358417
"""Download a resolved set of schemas into ``target_dir``, returning the count written.
359418
@@ -398,6 +457,7 @@ async def _download_schema_set(
398457
schema_confirmed_exists=True,
399458
needs_separator=already_written + written_here > 0,
400459
soft_fail=soft_fail,
460+
write_ctx=write_ctx,
401461
)
402462
if written:
403463
written_here += 1
@@ -527,6 +587,7 @@ async def _download_collection_tree(
527587
output_dir: Path,
528588
*,
529589
stdout: bool,
590+
write_ctx: _WriteContext | None = None,
530591
) -> None:
531592
"""Download a collection together with its dependencies, grouped by source collection.
532593
@@ -567,6 +628,7 @@ async def _download_collection_tree(
567628
seen=seen_schemas,
568629
already_written=total_written,
569630
soft_fail=False,
631+
write_ctx=write_ctx,
570632
)
571633

572634
# Loose schema dependencies (standalone + transitively discovered) soft-fail: a referenced
@@ -584,6 +646,7 @@ async def _download_collection_tree(
584646
seen=seen_schemas,
585647
already_written=total_written,
586648
soft_fail=True,
649+
write_ctx=write_ctx,
587650
)
588651

589652
_report_collection_tree(status, namespace, name, total_written, requested_member_count, prerequisites, unresolved)
@@ -600,6 +663,7 @@ async def _download_schema_tree(
600663
stdout: bool,
601664
prefetched: httpx.Response | None = None,
602665
schema_confirmed_exists: bool = False,
666+
write_ctx: _WriteContext | None = None,
603667
) -> None:
604668
"""Download a single schema together with its transitive dependencies.
605669
@@ -618,6 +682,7 @@ async def _download_schema_tree(
618682
stdout=stdout,
619683
prefetched=prefetched,
620684
schema_confirmed_exists=schema_confirmed_exists,
685+
write_ctx=write_ctx,
621686
)
622687
total_written = 1 if requested_written else 0
623688

@@ -637,6 +702,7 @@ async def _download_schema_tree(
637702
already_written=total_written,
638703
soft_fail=True,
639704
reserved_names=reserved,
705+
write_ctx=write_ctx,
640706
)
641707

642708
dependency_count = total_written - (1 if requested_written else 0)
@@ -661,6 +727,7 @@ async def _download_collection(
661727
stdout: bool,
662728
prefetched: httpx.Response | None = None,
663729
with_dependencies: bool = False,
730+
write_ctx: _WriteContext | None = None,
664731
) -> None:
665732
"""Fetch every schema in a collection, writing to disk or stdout.
666733
@@ -696,7 +763,9 @@ async def _download_collection(
696763
status = _status_console(stdout)
697764

698765
if with_dependencies:
699-
await _download_collection_tree(client, base_url, namespace, name, payload, output_dir, stdout=stdout)
766+
await _download_collection_tree(
767+
client, base_url, namespace, name, payload, output_dir, stdout=stdout, write_ctx=write_ctx
768+
)
700769
return
701770

702771
members = _collection_members(payload, status)
@@ -723,6 +792,12 @@ async def get(
723792
"--dependencies",
724793
help="Also download the schemas this schema or collection depends on.",
725794
),
795+
yes: bool = typer.Option(
796+
False,
797+
"--yes",
798+
"-y",
799+
help="Overwrite schemas that already exist in the output directory without prompting.",
800+
),
726801
stdout: bool = typer.Option(
727802
False,
728803
"--stdout",
@@ -749,6 +824,12 @@ async def get(
749824
sdk_cfg = _SdkConfig()
750825
resolved_url = (marketplace_url or SETTINGS.active.marketplace_url).rstrip("/")
751826

827+
# When resolving dependencies to disk, reconcile against schemas already present so a
828+
# dependency is not duplicated across directories; overwriting is gated by --yes/prompt.
829+
write_ctx: _WriteContext | None = None
830+
if dependencies and not stdout:
831+
write_ctx = _WriteContext(assume_yes=yes, preexisting=_snapshot_existing_schemas(output_dir))
832+
752833
async with _make_http_client(sdk_cfg) as client:
753834
prefetched: httpx.Response | None = None
754835
schema_confirmed_exists = False
@@ -775,6 +856,7 @@ async def get(
775856
stdout=stdout,
776857
prefetched=prefetched,
777858
with_dependencies=dependencies,
859+
write_ctx=write_ctx,
778860
)
779861
elif dependencies:
780862
await _download_schema_tree(
@@ -787,6 +869,7 @@ async def get(
787869
stdout=stdout,
788870
prefetched=prefetched,
789871
schema_confirmed_exists=schema_confirmed_exists,
872+
write_ctx=write_ctx,
790873
)
791874
else:
792875
await _download_schema(

tests/unit/ctl/test_marketplace_app.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,3 +1024,71 @@ def test_dependencies_on_schema_disambiguates_same_name_dependency(httpx_mock: H
10241024
# Requested schema at the root; the same-named dependency disambiguated into its namespace.
10251025
assert (tmp_path / "thing.yml").exists()
10261026
assert (tmp_path / "other" / "thing.yml").exists()
1027+
1028+
1029+
def _stub_schema_get(httpx_mock: HTTPXMock, ns: str, name: str, *, deps: list[dict] | None = None) -> None:
1030+
"""Register the auto-detect (download + collection-404) and dependency-read stubs for a schema."""
1031+
httpx_mock.add_response(
1032+
method="GET",
1033+
url=f"https://marketplace.infrahub.app/api/v1/schemas/{ns}/{name}/download",
1034+
text=SCHEMA_YAML,
1035+
headers={"x-schema-version": "1.0.0"},
1036+
)
1037+
httpx_mock.add_response(
1038+
method="GET",
1039+
url=f"https://marketplace.infrahub.app/api/v1/collections/{ns}/{name}",
1040+
status_code=404,
1041+
json={"detail": "Collection not found"},
1042+
)
1043+
httpx_mock.add_response(
1044+
method="GET",
1045+
url=f"https://marketplace.infrahub.app/api/v1/schemas/{ns}/{name}",
1046+
json=_schema_detail(ns, name, deps=deps or []),
1047+
)
1048+
1049+
1050+
def test_dependencies_existing_file_kept_without_yes(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
1051+
"""A schema already present in the output tree is kept (not overwritten/duplicated) without --yes."""
1052+
# Pre-existing copy from a prior download, in a collection subdirectory.
1053+
(tmp_path / "base-schemas").mkdir()
1054+
existing = tmp_path / "base-schemas" / "dcim.yml"
1055+
existing.write_text("OLD CONTENT\n")
1056+
1057+
_stub_schema_get(httpx_mock, "acme", "app", deps=[_resolved_dep("acme", "dcim")])
1058+
httpx_mock.add_response(
1059+
method="GET",
1060+
url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim",
1061+
json=_schema_detail("acme", "dcim", deps=[]),
1062+
)
1063+
# Non-interactive (CliRunner stdin is not a tty) and no --yes → dcim is kept, not re-fetched.
1064+
result = runner.invoke(app, ["get", "acme/app", "--dependencies", "-o", str(tmp_path)])
1065+
1066+
assert result.exit_code == 0
1067+
assert "Kept existing" in result.output
1068+
assert existing.read_text() == "OLD CONTENT\n" # untouched
1069+
assert not (tmp_path / "dcim.yml").exists() # no duplicate created at the root
1070+
1071+
1072+
def test_dependencies_existing_file_overwritten_with_yes(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
1073+
"""With --yes, an existing schema is overwritten in place rather than duplicated."""
1074+
(tmp_path / "base-schemas").mkdir()
1075+
existing = tmp_path / "base-schemas" / "dcim.yml"
1076+
existing.write_text("OLD CONTENT\n")
1077+
1078+
_stub_schema_get(httpx_mock, "acme", "app", deps=[_resolved_dep("acme", "dcim")])
1079+
httpx_mock.add_response(
1080+
method="GET",
1081+
url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim",
1082+
json=_schema_detail("acme", "dcim", deps=[]),
1083+
)
1084+
httpx_mock.add_response(
1085+
method="GET",
1086+
url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim/download",
1087+
text=SCHEMA_YAML,
1088+
)
1089+
result = runner.invoke(app, ["get", "acme/app", "--dependencies", "--yes", "-o", str(tmp_path)])
1090+
1091+
assert result.exit_code == 0
1092+
assert "Updated schema acme/dcim" in result.output
1093+
assert existing.read_text() == SCHEMA_YAML # overwritten in place
1094+
assert not (tmp_path / "dcim.yml").exists() # still no duplicate at the root

0 commit comments

Comments
 (0)