From 26301f7ad6a91c8b452a7e3c6621a965baa6d285 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Wed, 27 May 2026 08:11:02 -0500 Subject: [PATCH] fix(packs): write registry presets in the array shape the loader reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bcli pack install stored registries/.json `endpoints` as a name→body object, but the runtime EndpointRegistry iterates `endpoints` as a JSON array. Pack-installed presets therefore never resolved at query time, and merging into an existing array-shaped registry (e.g. one produced by `bcli registry import`) raised InstallError. Install and uninstall now read/merge/filter `endpoints` as a list keyed by each entry's entity_set_name. Added a regression test that installs a preset and resolves it through the real registry loader (load_custom_from_file + resolve) — the path that was silently broken. Patch on top of 0.6.0. --- CHANGELOG.md | 14 +++++++++ pyproject.toml | 2 +- src/bcli/packs/_installer.py | 37 ++++++++++++++++++------ tests/test_packs/test_installer.py | 46 ++++++++++++++++++++++++++---- uv.lock | 2 +- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 632b8fb..c4c9d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1] - 2026-05-25 + +### Fixed + +- **`bcli pack install` now writes registry presets in the array shape the + runtime registry loader reads.** The installer previously stored + `registries/.json` `endpoints` as a name→body object, but + `EndpointRegistry` iterates `endpoints` as a JSON array — so pack-installed + presets never resolved at query time (and merging into an existing + array-shaped registry raised `InstallError`). Install and uninstall now + read/merge/filter the array by each entry's `entity_set_name`. Added a + regression test that loads an installed preset through the real registry + loader and resolves it. + ## [0.6.0] - 2026-05-25 ### Changed — ETL stampers are now pluggable (BREAKING) diff --git a/pyproject.toml b/pyproject.toml index a852eae..b11093d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "hatchling.build" # installed CLI binary (`bcli`) are unaffected — only `pip install` / # `uv tool install` use this name. name = "bc-cli" -version = "0.6.0" +version = "0.6.1" description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs" readme = "README.md" license = "Apache-2.0" diff --git a/src/bcli/packs/_installer.py b/src/bcli/packs/_installer.py index 8663748..fe12d82 100644 --- a/src/bcli/packs/_installer.py +++ b/src/bcli/packs/_installer.py @@ -492,13 +492,31 @@ def execute_install( raise InstallError( f"existing {rpath} is not valid JSON: {e}" ) from e - endpoints = existing_reg.get("endpoints") or {} - if not isinstance(endpoints, dict): + # The runtime registry loader (`bcli.registry._registry`) reads + # `endpoints` as a JSON array of entry objects, each keyed by its + # `entity_set_name`. Write that shape — not a name→body map — so + # pack-installed presets are actually resolvable at query time. + endpoints = existing_reg.get("endpoints") or [] + if not isinstance(endpoints, list): raise InstallError( - f"{rpath}: 'endpoints' must be an object" + f"{rpath}: 'endpoints' must be a JSON array" ) + index_by_name: dict[str, int] = {} + for i, entry in enumerate(endpoints): + if isinstance(entry, dict): + key = entry.get("entity_set_name") + if key: + index_by_name[key] = i for preset in plan.preset_writes: - endpoints[preset.name] = preset.body + body = dict(preset.body) + body.setdefault("entity_set_name", preset.name) + key = body["entity_set_name"] + existing_idx = index_by_name.get(key) + if existing_idx is not None: + endpoints[existing_idx] = body + else: + index_by_name[key] = len(endpoints) + endpoints.append(body) registry_entries.append(LedgerRegistryEntry( name=preset.name, rendered_hash=preset.rendered_hash, @@ -674,10 +692,13 @@ def uninstall_pack( raw = json.loads(rpath.read_text(encoding="utf-8")) except json.JSONDecodeError: raw = {} - endpoints = raw.get("endpoints") or {} - for _, name in preset_keys: - if name in endpoints: - del endpoints[name] + endpoints = raw.get("endpoints") or [] + if isinstance(endpoints, list): + remove = {name for _, name in preset_keys} + endpoints = [ + e for e in endpoints + if not (isinstance(e, dict) and e.get("entity_set_name") in remove) + ] raw["endpoints"] = endpoints _atomic_write(rpath, json.dumps(raw, indent=2)) diff --git a/tests/test_packs/test_installer.py b/tests/test_packs/test_installer.py index cd164f5..135a483 100644 --- a/tests/test_packs/test_installer.py +++ b/tests/test_packs/test_installer.py @@ -65,9 +65,11 @@ def test_install_writes_all_artefacts(make_pack, config_dir, install_target) -> rpath = registries_path("prod", override=config_dir) assert rpath.is_file() reg = json.loads(rpath.read_text()) - assert "myEntity" in reg["endpoints"] - assert reg["endpoints"]["myEntity"]["source_pack"] == "demo" - assert reg["endpoints"]["myEntity"]["pack_version"] == "0.1.0" + assert isinstance(reg["endpoints"], list) + by_name = {e["entity_set_name"]: e for e in reg["endpoints"]} + assert "myEntity" in by_name + assert by_name["myEntity"]["source_pack"] == "demo" + assert by_name["myEntity"]["pack_version"] == "0.1.0" # Ledger persisted. ledger = read_ledger("demo", "prod", config_dir=config_dir) @@ -77,6 +79,38 @@ def test_install_writes_all_artefacts(make_pack, config_dir, install_target) -> assert any(p.kind == "agents_block" for p in ledger.paths) +def test_installed_preset_is_loadable_by_runtime_registry( + make_pack, config_dir, install_target +) -> None: + """Regression: the installer must write the `endpoints` array shape the + runtime registry loader reads, so a pack-installed preset actually + resolves at query time (not just round-trips through the installer).""" + from bcli.registry._registry import EndpointRegistry + + src = make_pack( + "demo", + presets={"myEntity": { + "entity_set_name": "myEntity", + "supports": ["GET"], + "api_publisher": "acme", + "api_group": "ops", + "api_version": "v1.0", + }}, + ) + install_pack( + load_pack(src), profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + rpath = registries_path("prod", override=config_dir) + + reg = EndpointRegistry(disable_standard=True) + count = reg.load_custom_from_file(rpath) + + assert count == 1 + meta = reg.resolve("myEntity") + assert meta.entity_set_name == "myEntity" + + def test_fragment_targets_route_blocks(make_pack, config_dir, install_target) -> None: src = make_pack( "tgts", @@ -165,7 +199,8 @@ def test_conflict_blocks_second_pack(make_pack, config_dir, install_target) -> N config_override=config_dir, ) reg = json.loads(registries_path("prod", override=config_dir).read_text()) - assert reg["endpoints"]["shared"]["source_pack"] == "beta" + by_name = {e["entity_set_name"]: e for e in reg["endpoints"]} + assert by_name["shared"]["source_pack"] == "beta" def test_uninstall_removes_artefacts(make_pack, config_dir, install_target) -> None: @@ -199,7 +234,8 @@ def test_uninstall_removes_artefacts(make_pack, config_dir, install_target) -> N assert "bcli-pack:demo:a.md START" not in agents # Preset removed. reg = json.loads(registries_path("prod", override=config_dir).read_text()) - assert "epX" not in (reg.get("endpoints") or {}) + names = {e.get("entity_set_name") for e in (reg.get("endpoints") or [])} + assert "epX" not in names # Ledger gone. assert read_ledger("demo", "prod", config_dir=config_dir) is None # No catastrophic warnings on a clean install/uninstall. diff --git a/uv.lock b/uv.lock index 5de2b3d..e05695f 100644 --- a/uv.lock +++ b/uv.lock @@ -321,7 +321,7 @@ wheels = [ [[package]] name = "bc-cli" -version = "0.6.0" +version = "0.6.1" source = { editable = "." } dependencies = [ { name = "httpx" },