From 2c672344bd84e3046e2badd4ede3d28c3fb22134 Mon Sep 17 00:00:00 2001 From: Gonzalo Vidal <35148159+Gonza10V@users.noreply.github.com> Date: Tue, 5 May 2026 15:25:37 -0600 Subject: [PATCH 1/3] Implement SBOL resolver and inventory indexes foundation --- src/buildcompiler/inventory/__init__.py | 6 +- src/buildcompiler/inventory/inventory.py | 171 ++++++++++++++++++ src/buildcompiler/sbol/__init__.py | 6 +- src/buildcompiler/sbol/resolver.py | 70 +++++++ .../unit/inventory/test_inventory_indexes.py | 70 +++++++ tests/unit/sbol/test_resolver.py | 83 +++++++++ 6 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 src/buildcompiler/inventory/inventory.py create mode 100644 src/buildcompiler/sbol/resolver.py create mode 100644 tests/unit/inventory/test_inventory_indexes.py create mode 100644 tests/unit/sbol/test_resolver.py diff --git a/src/buildcompiler/inventory/__init__.py b/src/buildcompiler/inventory/__init__.py index 5e19692..9558475 100644 --- a/src/buildcompiler/inventory/__init__.py +++ b/src/buildcompiler/inventory/__init__.py @@ -1 +1,5 @@ -"""Package scaffolding for clean architecture.""" +"""Inventory package exports for deterministic lookup/indexing contracts.""" + +from .inventory import Inventory + +__all__ = ["Inventory"] diff --git a/src/buildcompiler/inventory/inventory.py b/src/buildcompiler/inventory/inventory.py new file mode 100644 index 0000000..9196829 --- /dev/null +++ b/src/buildcompiler/inventory/inventory.py @@ -0,0 +1,171 @@ +"""Normalized inventory facade with eager deterministic indexes.""" + +from __future__ import annotations + +from collections import defaultdict + +from buildcompiler.domain import ( + BuildStage, + IndexedBackbone, + IndexedPlasmid, + IndexedReagent, + MaterialState, +) + + +_MATERIAL_ORDER = { + MaterialState.PLANNED: 0, + MaterialState.GENERATED: 1, + MaterialState.ASSEMBLED: 2, + MaterialState.TRANSFORMED: 3, + MaterialState.PLATED: 4, +} + + +class Inventory: + def __init__( + self, + *, + plasmids: list[IndexedPlasmid] | None = None, + backbones: list[IndexedBackbone] | None = None, + reagents: list[IndexedReagent] | None = None, + ) -> None: + self.plasmids_by_identity: dict[str, IndexedPlasmid] = {} + self.plasmids_by_insert_identity: dict[str, list[IndexedPlasmid]] = defaultdict(list) + self.plasmids_by_fusion_sites: dict[tuple[str, ...], list[IndexedPlasmid]] = defaultdict(list) + self.plasmids_by_antibiotic: dict[str, list[IndexedPlasmid]] = defaultdict(list) + + self.backbones_by_identity: dict[str, IndexedBackbone] = {} + self.backbones_by_fusion_sites_and_antibiotic: dict[ + tuple[tuple[str, ...], str], list[IndexedBackbone] + ] = defaultdict(list) + + self.reagents_by_identity: dict[str, IndexedReagent] = {} + self.reagents_by_name: dict[str, IndexedReagent] = {} + + self.generated_products_by_identity: dict[str, IndexedPlasmid] = {} + + for plasmid in plasmids or []: + self._add_plasmid(plasmid) + for backbone in backbones or []: + self._add_backbone(backbone) + for reagent in reagents or []: + self._add_reagent(reagent) + + def _sorted_plasmids(self, items: list[IndexedPlasmid]) -> list[IndexedPlasmid]: + return sorted(items, key=lambda p: p.identity) + + def _backbone_stage(self, backbone: IndexedBackbone) -> BuildStage | None: + raw = backbone.metadata.get("stage") if backbone.metadata else None + if raw is None: + return None + if isinstance(raw, BuildStage): + return raw + try: + return BuildStage(raw) + except ValueError: + return None + + def _add_plasmid(self, plasmid: IndexedPlasmid) -> None: + self.plasmids_by_identity[plasmid.identity] = plasmid + for insert_identity in sorted(plasmid.metadata.get("insert_identities", [])): + self.plasmids_by_insert_identity[insert_identity].append(plasmid) + self.plasmids_by_insert_identity[insert_identity] = self._sorted_plasmids( + self.plasmids_by_insert_identity[insert_identity] + ) + + fusion_sites = tuple(plasmid.metadata.get("fusion_sites", ())) + if fusion_sites: + self.plasmids_by_fusion_sites[fusion_sites].append(plasmid) + self.plasmids_by_fusion_sites[fusion_sites] = self._sorted_plasmids( + self.plasmids_by_fusion_sites[fusion_sites] + ) + + antibiotic = plasmid.metadata.get("antibiotic") + if antibiotic: + self.plasmids_by_antibiotic[antibiotic].append(plasmid) + self.plasmids_by_antibiotic[antibiotic] = self._sorted_plasmids( + self.plasmids_by_antibiotic[antibiotic] + ) + + def _add_backbone(self, backbone: IndexedBackbone) -> None: + self.backbones_by_identity[backbone.identity] = backbone + fusion_sites = tuple(backbone.metadata.get("fusion_sites", ())) + antibiotic = backbone.metadata.get("antibiotic") + if fusion_sites and antibiotic: + key = (fusion_sites, antibiotic) + self.backbones_by_fusion_sites_and_antibiotic[key].append(backbone) + self.backbones_by_fusion_sites_and_antibiotic[key] = sorted( + self.backbones_by_fusion_sites_and_antibiotic[key], + key=lambda b: b.identity, + ) + + def _add_reagent(self, reagent: IndexedReagent) -> None: + self.reagents_by_identity[reagent.identity] = reagent + if reagent.name: + self.reagents_by_name[reagent.name] = reagent + + def find_single_part_plasmids( + self, part_identity: str, *, antibiotic: str | None = None + ) -> list[IndexedPlasmid]: + matches = list(self.plasmids_by_insert_identity.get(part_identity, [])) + if antibiotic is not None: + matches = [p for p in matches if p.metadata.get("antibiotic") == antibiotic] + return self._sorted_plasmids(matches) + + def find_lvl1_region_plasmids( + self, + region_identity: str, + *, + min_material_state: MaterialState = MaterialState.PLANNED, + ) -> list[IndexedPlasmid]: + matches = self.plasmids_by_insert_identity.get(region_identity, []) + min_rank = _MATERIAL_ORDER[min_material_state] + filtered = [p for p in matches if _MATERIAL_ORDER[p.state] >= min_rank] + return self._sorted_plasmids(filtered) + + def find_backbone( + self, + *, + fusion_sites: tuple[str, ...] | None = None, + antibiotic: str | None = None, + stage: BuildStage | None = None, + ) -> IndexedBackbone | None: + if fusion_sites is not None and antibiotic is not None: + candidates = list( + self.backbones_by_fusion_sites_and_antibiotic.get( + (tuple(fusion_sites), antibiotic), [] + ) + ) + else: + candidates = sorted(self.backbones_by_identity.values(), key=lambda b: b.identity) + if fusion_sites is not None: + candidates = [ + b for b in candidates if tuple(b.metadata.get("fusion_sites", ())) == tuple(fusion_sites) + ] + if antibiotic is not None: + candidates = [b for b in candidates if b.metadata.get("antibiotic") == antibiotic] + if stage is not None: + candidates = [b for b in candidates if self._backbone_stage(b) == stage] + return candidates[0] if candidates else None + + def find_restriction_enzyme(self, name: str) -> IndexedReagent | None: + reagent = self.reagents_by_name.get(name) + if reagent and reagent.reagent_type == "restriction_enzyme": + return reagent + return None + + def find_ligase(self, preferred: str | None = None) -> IndexedReagent | None: + if preferred: + reagent = self.reagents_by_name.get(preferred) + if reagent and reagent.reagent_type == "ligase": + return reagent + ligases = sorted( + (r for r in self.reagents_by_identity.values() if r.reagent_type == "ligase"), + key=lambda r: r.identity, + ) + return ligases[0] if ligases else None + + def add_generated_product(self, product: IndexedPlasmid) -> None: + self.generated_products_by_identity[product.identity] = product + self._add_plasmid(product) diff --git a/src/buildcompiler/sbol/__init__.py b/src/buildcompiler/sbol/__init__.py index 5e19692..45d9597 100644 --- a/src/buildcompiler/sbol/__init__.py +++ b/src/buildcompiler/sbol/__init__.py @@ -1 +1,5 @@ -"""Package scaffolding for clean architecture.""" +"""SBOL package exports for clean architecture contracts.""" + +from .resolver import PullPolicy, SbolResolver + +__all__ = ["PullPolicy", "SbolResolver"] diff --git a/src/buildcompiler/sbol/resolver.py b/src/buildcompiler/sbol/resolver.py new file mode 100644 index 0000000..ced3fa7 --- /dev/null +++ b/src/buildcompiler/sbol/resolver.py @@ -0,0 +1,70 @@ +"""SBOL document resolver with deterministic pull policy.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Callable + +import sbol2 + + +class PullPolicy(str, Enum): + """Resolver behavior for remote pull attempts.""" + + NEVER = "never" + MISSING_ONLY = "missing_only" + ALWAYS_REFRESH = "always_refresh" + + +class SbolResolver: + """Resolve SBOL objects by identity from a local document with optional pull fallback.""" + + def __init__( + self, + document: sbol2.Document, + *, + pull_policy: PullPolicy = PullPolicy.MISSING_ONLY, + pull_client: Callable[[str], Any] | None = None, + ) -> None: + self.document = document + self.pull_policy = pull_policy + self.pull_client = pull_client + + def maybe_pull(self, identity: str) -> Any | None: + if self.pull_policy == PullPolicy.NEVER: + return None + if self.pull_client is None: + return None + return self.pull_client(identity) + + def _get(self, identity: str, expected_type: type) -> Any: + if self.pull_policy == PullPolicy.ALWAYS_REFRESH: + self.maybe_pull(identity) + + obj = self.document.find(identity) + if isinstance(obj, expected_type): + return obj + + if self.pull_policy == PullPolicy.MISSING_ONLY: + self.maybe_pull(identity) + obj = self.document.find(identity) + if isinstance(obj, expected_type): + return obj + + raise LookupError( + f"Could not resolve {expected_type.__name__} with identity '{identity}'" + ) + + def get_component(self, identity: str) -> sbol2.ComponentDefinition: + return self._get(identity, sbol2.ComponentDefinition) + + def get_module(self, identity: str) -> sbol2.ModuleDefinition: + return self._get(identity, sbol2.ModuleDefinition) + + def get_combinatorial_derivation( + self, identity: str + ) -> sbol2.CombinatorialDerivation: + return self._get(identity, sbol2.CombinatorialDerivation) + + def get_implementation(self, identity: str) -> sbol2.Implementation: + return self._get(identity, sbol2.Implementation) diff --git a/tests/unit/inventory/test_inventory_indexes.py b/tests/unit/inventory/test_inventory_indexes.py new file mode 100644 index 0000000..18719e2 --- /dev/null +++ b/tests/unit/inventory/test_inventory_indexes.py @@ -0,0 +1,70 @@ +from buildcompiler.domain import BuildStage, IndexedBackbone, IndexedPlasmid, IndexedReagent, MaterialState +from buildcompiler.inventory import Inventory + + +def _plasmid(identity: str, inserts: list[str], fusion_sites=("A", "B"), antibiotic="Ampicillin", state=MaterialState.PLANNED): + return IndexedPlasmid( + identity=identity, + display_id=identity.rsplit("/", 1)[-1], + state=state, + metadata={ + "insert_identities": inserts, + "fusion_sites": fusion_sites, + "antibiotic": antibiotic, + }, + ) + + +def test_inventory_indexes_and_queries_are_deterministic(): + p2 = _plasmid("https://example.org/p2", ["https://example.org/partA"], state=MaterialState.GENERATED) + p1 = _plasmid("https://example.org/p1", ["https://example.org/partA", "https://example.org/region1"]) + + b1 = IndexedBackbone( + identity="https://example.org/b1", + metadata={"fusion_sites": ("A", "B"), "antibiotic": "Ampicillin", "stage": BuildStage.ASSEMBLY_LVL1.value}, + ) + b2 = IndexedBackbone( + identity="https://example.org/b2", + metadata={"fusion_sites": ("A", "B"), "antibiotic": "Ampicillin", "stage": BuildStage.ASSEMBLY_LVL2.value}, + ) + + e1 = IndexedReagent(identity="https://example.org/r1", name="BsaI", reagent_type="restriction_enzyme") + l1 = IndexedReagent(identity="https://example.org/r2", name="T4_DNA_ligase", reagent_type="ligase") + + inv = Inventory(plasmids=[p2, p1], backbones=[b2, b1], reagents=[e1, l1]) + + assert inv.plasmids_by_identity[p1.identity] == p1 + assert [p.identity for p in inv.plasmids_by_insert_identity["https://example.org/partA"]] == [p1.identity, p2.identity] + assert [p.identity for p in inv.plasmids_by_fusion_sites[("A", "B")]] == [p1.identity, p2.identity] + assert [p.identity for p in inv.plasmids_by_antibiotic["Ampicillin"]] == [p1.identity, p2.identity] + + key = (("A", "B"), "Ampicillin") + assert [b.identity for b in inv.backbones_by_fusion_sites_and_antibiotic[key]] == [b1.identity, b2.identity] + assert inv.find_backbone(fusion_sites=("A", "B"), antibiotic="Ampicillin", stage=BuildStage.ASSEMBLY_LVL1) == b1 + + assert inv.find_restriction_enzyme("BsaI") == e1 + assert inv.find_ligase("T4_DNA_ligase") == l1 + assert inv.find_ligase().identity == l1.identity + + assert [p.identity for p in inv.find_single_part_plasmids("https://example.org/partA")] == [p1.identity, p2.identity] + assert [p.identity for p in inv.find_lvl1_region_plasmids("https://example.org/region1")] == [p1.identity] + assert inv.find_lvl1_region_plasmids("https://example.org/partA", min_material_state=MaterialState.GENERATED) == [p2] + + +def test_add_generated_product_updates_indexes_immediately(): + inv = Inventory() + product = _plasmid( + "https://example.org/generated1", + ["https://example.org/partG"], + fusion_sites=("C", "D"), + antibiotic="Kanamycin", + state=MaterialState.GENERATED, + ) + + inv.add_generated_product(product) + + assert inv.generated_products_by_identity[product.identity] == product + assert inv.plasmids_by_identity[product.identity] == product + assert inv.find_single_part_plasmids("https://example.org/partG") == [product] + assert inv.plasmids_by_fusion_sites[("C", "D")] == [product] + assert inv.plasmids_by_antibiotic["Kanamycin"] == [product] diff --git a/tests/unit/sbol/test_resolver.py b/tests/unit/sbol/test_resolver.py new file mode 100644 index 0000000..a1361de --- /dev/null +++ b/tests/unit/sbol/test_resolver.py @@ -0,0 +1,83 @@ +import sbol2 +import pytest + +from buildcompiler.sbol import PullPolicy, SbolResolver + + +class FakePullClient: + def __init__(self, document: sbol2.Document) -> None: + self.document = document + self.calls: list[str] = [] + + def __call__(self, identity: str): + self.calls.append(identity) + return self.document.find(identity) + + +def _make_doc() -> tuple[sbol2.Document, dict[str, str]]: + doc = sbol2.Document() + ns = "https://example.org" + sbol2.setHomespace(ns) + + comp = sbol2.ComponentDefinition(f"{ns}/component") + mod = sbol2.ModuleDefinition(f"{ns}/module") + impl = sbol2.Implementation(f"{ns}/impl") + comb = sbol2.CombinatorialDerivation(f"{ns}/comb", comp.identity) + + doc.add(comp) + doc.add(mod) + doc.add(impl) + doc.add(comb) + return doc, { + "component": comp.identity, + "module": mod.identity, + "implementation": impl.identity, + "combinatorial": comb.identity, + } + + +def test_resolver_gets_expected_types_from_local_document(): + doc, ids = _make_doc() + resolver = SbolResolver(doc, pull_policy=PullPolicy.NEVER) + + assert resolver.get_component(ids["component"]).identity == ids["component"] + assert resolver.get_module(ids["module"]).identity == ids["module"] + assert resolver.get_implementation(ids["implementation"]).identity == ids["implementation"] + assert ( + resolver.get_combinatorial_derivation(ids["combinatorial"]).identity + == ids["combinatorial"] + ) + + +def test_never_policy_does_not_pull_on_miss(): + doc, _ = _make_doc() + fake = FakePullClient(doc) + resolver = SbolResolver(doc, pull_policy=PullPolicy.NEVER, pull_client=fake) + + with pytest.raises(LookupError): + resolver.get_component("https://example.org/missing") + + assert fake.calls == [] + + +def test_missing_only_pulls_only_when_local_lookup_misses(): + doc, ids = _make_doc() + fake = FakePullClient(doc) + resolver = SbolResolver(doc, pull_policy=PullPolicy.MISSING_ONLY, pull_client=fake) + + resolver.get_component(ids["component"]) + assert fake.calls == [] + + with pytest.raises(LookupError): + resolver.get_component("https://example.org/missing") + assert fake.calls == ["https://example.org/missing"] + + +def test_always_refresh_pulls_even_on_hit(): + doc, ids = _make_doc() + fake = FakePullClient(doc) + resolver = SbolResolver(doc, pull_policy=PullPolicy.ALWAYS_REFRESH, pull_client=fake) + + resolver.get_component(ids["component"]) + + assert fake.calls == [ids["component"]] From c621e457a721a3f34adef30d47abdcd2317cec7f Mon Sep 17 00:00:00 2001 From: Gonzalo Vidal <35148159+Gonza10V@users.noreply.github.com> Date: Tue, 5 May 2026 15:42:21 -0600 Subject: [PATCH 2/3] Fix generated product reindexing for existing plasmids --- src/buildcompiler/inventory/inventory.py | 31 +++++++++++++++++++ .../unit/inventory/test_inventory_indexes.py | 30 ++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/buildcompiler/inventory/inventory.py b/src/buildcompiler/inventory/inventory.py index 9196829..fb06999 100644 --- a/src/buildcompiler/inventory/inventory.py +++ b/src/buildcompiler/inventory/inventory.py @@ -66,7 +66,38 @@ def _backbone_stage(self, backbone: IndexedBackbone) -> BuildStage | None: except ValueError: return None + def _remove_plasmid_from_secondary_indexes(self, plasmid: IndexedPlasmid) -> None: + for insert_identity in sorted(plasmid.metadata.get("insert_identities", [])): + existing = self.plasmids_by_insert_identity.get(insert_identity, []) + filtered = [indexed for indexed in existing if indexed.identity != plasmid.identity] + if filtered: + self.plasmids_by_insert_identity[insert_identity] = filtered + else: + self.plasmids_by_insert_identity.pop(insert_identity, None) + + fusion_sites = tuple(plasmid.metadata.get("fusion_sites", ())) + if fusion_sites: + existing = self.plasmids_by_fusion_sites.get(fusion_sites, []) + filtered = [indexed for indexed in existing if indexed.identity != plasmid.identity] + if filtered: + self.plasmids_by_fusion_sites[fusion_sites] = filtered + else: + self.plasmids_by_fusion_sites.pop(fusion_sites, None) + + antibiotic = plasmid.metadata.get("antibiotic") + if antibiotic: + existing = self.plasmids_by_antibiotic.get(antibiotic, []) + filtered = [indexed for indexed in existing if indexed.identity != plasmid.identity] + if filtered: + self.plasmids_by_antibiotic[antibiotic] = filtered + else: + self.plasmids_by_antibiotic.pop(antibiotic, None) + def _add_plasmid(self, plasmid: IndexedPlasmid) -> None: + existing = self.plasmids_by_identity.get(plasmid.identity) + if existing is not None: + self._remove_plasmid_from_secondary_indexes(existing) + self.plasmids_by_identity[plasmid.identity] = plasmid for insert_identity in sorted(plasmid.metadata.get("insert_identities", [])): self.plasmids_by_insert_identity[insert_identity].append(plasmid) diff --git a/tests/unit/inventory/test_inventory_indexes.py b/tests/unit/inventory/test_inventory_indexes.py index 18719e2..2ab1796 100644 --- a/tests/unit/inventory/test_inventory_indexes.py +++ b/tests/unit/inventory/test_inventory_indexes.py @@ -68,3 +68,33 @@ def test_add_generated_product_updates_indexes_immediately(): assert inv.find_single_part_plasmids("https://example.org/partG") == [product] assert inv.plasmids_by_fusion_sites[("C", "D")] == [product] assert inv.plasmids_by_antibiotic["Kanamycin"] == [product] + + +def test_add_generated_product_replaces_existing_secondary_indexes(): + inv = Inventory() + original = _plasmid( + "https://example.org/generated2", + ["https://example.org/partOld"], + fusion_sites=("A", "B"), + antibiotic="Ampicillin", + state=MaterialState.GENERATED, + ) + updated = _plasmid( + "https://example.org/generated2", + ["https://example.org/partNew"], + fusion_sites=("C", "D"), + antibiotic="Kanamycin", + state=MaterialState.ASSEMBLED, + ) + + inv.add_generated_product(original) + inv.add_generated_product(updated) + + assert inv.plasmids_by_identity[updated.identity] == updated + assert inv.generated_products_by_identity[updated.identity] == updated + assert inv.find_single_part_plasmids("https://example.org/partOld") == [] + assert inv.find_single_part_plasmids("https://example.org/partNew") == [updated] + assert inv.plasmids_by_fusion_sites.get(("A", "B"), []) == [] + assert inv.plasmids_by_fusion_sites[("C", "D")] == [updated] + assert inv.plasmids_by_antibiotic.get("Ampicillin", []) == [] + assert inv.plasmids_by_antibiotic["Kanamycin"] == [updated] From adf812cbed5865c37c483334432b75d72c98195a Mon Sep 17 00:00:00 2001 From: Gonzalo Vidal <35148159+Gonza10V@users.noreply.github.com> Date: Tue, 5 May 2026 15:48:49 -0600 Subject: [PATCH 3/3] Fix resolver to accept pulled SBOL objects directly --- src/buildcompiler/sbol/resolver.py | 8 ++++++-- tests/unit/sbol/test_resolver.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/buildcompiler/sbol/resolver.py b/src/buildcompiler/sbol/resolver.py index ced3fa7..3618983 100644 --- a/src/buildcompiler/sbol/resolver.py +++ b/src/buildcompiler/sbol/resolver.py @@ -39,14 +39,18 @@ def maybe_pull(self, identity: str) -> Any | None: def _get(self, identity: str, expected_type: type) -> Any: if self.pull_policy == PullPolicy.ALWAYS_REFRESH: - self.maybe_pull(identity) + pulled = self.maybe_pull(identity) + if isinstance(pulled, expected_type): + return pulled obj = self.document.find(identity) if isinstance(obj, expected_type): return obj if self.pull_policy == PullPolicy.MISSING_ONLY: - self.maybe_pull(identity) + pulled = self.maybe_pull(identity) + if isinstance(pulled, expected_type): + return pulled obj = self.document.find(identity) if isinstance(obj, expected_type): return obj diff --git a/tests/unit/sbol/test_resolver.py b/tests/unit/sbol/test_resolver.py index a1361de..f3b4297 100644 --- a/tests/unit/sbol/test_resolver.py +++ b/tests/unit/sbol/test_resolver.py @@ -14,6 +14,16 @@ def __call__(self, identity: str): return self.document.find(identity) +class ReturnOnlyPullClient: + def __init__(self, pulled_objects: dict[str, object]) -> None: + self.pulled_objects = pulled_objects + self.calls: list[str] = [] + + def __call__(self, identity: str): + self.calls.append(identity) + return self.pulled_objects.get(identity) + + def _make_doc() -> tuple[sbol2.Document, dict[str, str]]: doc = sbol2.Document() ns = "https://example.org" @@ -81,3 +91,19 @@ def test_always_refresh_pulls_even_on_hit(): resolver.get_component(ids["component"]) assert fake.calls == [ids["component"]] + + +def test_missing_only_returns_object_from_pull_client_without_document_mutation(): + local_doc = sbol2.Document() + ns = "https://example.org" + sbol2.setHomespace(ns) + remote_component = sbol2.ComponentDefinition(f"{ns}/remote-component") + pull_client = ReturnOnlyPullClient({remote_component.identity: remote_component}) + resolver = SbolResolver( + local_doc, pull_policy=PullPolicy.MISSING_ONLY, pull_client=pull_client + ) + + resolved = resolver.get_component(remote_component.identity) + + assert resolved is remote_component + assert pull_client.calls == [remote_component.identity]