diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index 03170a0..5650540 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -45,6 +45,16 @@ def main() -> None: builder = LibraryBuilder() cells = builder.cells + # Naming and export policy can be bound once without changing individual + # declarations. For example, a foundry-specific group could use: + # + # cells = builder.cells.prefixed( + # 'foundry_', + # facade=lambda proxy: devices.ports_to_data(proxy), + # ) + # + # Dynamic names use the same view: cells[generated_name] = cell(factory)(...) + # # Load some devices from a GDS file # diff --git a/masque/library/build.py b/masque/library/build.py index e69fa9a..1681bc1 100644 --- a/masque/library/build.py +++ b/masque/library/build.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Mapping from dataclasses import dataclass, replace from functools import wraps from types import MappingProxyType @@ -16,7 +17,7 @@ from .mapping import Library, LibraryView from .overlay import OverlayLibrary if TYPE_CHECKING: - from collections.abc import Callable, Iterator, KeysView, Mapping, Sequence + from collections.abc import Callable, Iterator, KeysView, Sequence from ..pattern import Pattern @@ -76,8 +77,8 @@ class BuildReport: @dataclass class _BuildRecipe: - """ Captured deferred call to a pattern factory. """ - func: Callable[..., Pattern] + """ Captured deferred call to a pattern or tree factory. """ + func: Callable[..., Pattern | TreeView] args: tuple[Any, ...] kwargs: dict[str, Any] explicit_dependencies: tuple[str, ...] = () @@ -87,9 +88,16 @@ class _BuildRecipe: return self -def cell(func: Callable[..., Pattern]) -> Callable[..., _BuildRecipe]: +@dataclass(frozen=True) +class _BuildDeclaration: + """One deferred or direct declaration plus its implementation transform.""" + value: Pattern | TreeView | _BuildRecipe + postprocess: Callable[[Pattern], Pattern] | None = None + + +def cell(func: Callable[..., Pattern | TreeView]) -> Callable[..., _BuildRecipe]: """ - Wrap a plain pattern factory so calls return deferred build recipes. + Wrap a plain Pattern or single-top tree factory so calls return deferred build recipes. Use as either `cell(fn)(...)` or `@cell`. """ @@ -111,19 +119,138 @@ class _LibraryPlaceholder: return '' +def _identity_name(name: str) -> str: + return name + + +def _make_facade( + library: ILibrary, + target: str, + transform: Callable[[Pattern], Pattern], + ) -> Pattern: + from ..pattern import Pattern # noqa: PLC0415 + + implementation = library[target] + proxy = Pattern(ports=implementation.ports).ref(target) + result = transform(proxy) + if not isinstance(result, Pattern): + raise BuildError(f'Facade transform returned {type(result).__name__}, expected Pattern') + return result + + class BuildCellsView: """ Attribute-based declaration namespace for `LibraryBuilder`. This is the ergonomic authoring surface exposed as `builder.cells`. It is - intentionally write-focused: attribute assignment and deletion register - declarations, while attribute reads fail with guidance to build first and + intentionally write-focused: attribute or item assignment and deletion + register declarations, while reads fail with guidance to build first and use the returned library. - """ - __slots__ = ('_library',) - def __init__(self, library: LibraryBuilder) -> None: + `prefixed()` and `renamed()` create independent write-through views over + the same builder. Their optional `postprocess` callable transforms the + implementation Pattern. If a name changes, their optional `facade` + callable transforms a generated Pattern with copied ports and a reference + to the implementation. + + Example: + `cells = builder.cells.prefixed(prefix, facade=encode_ports)` + `cells.device = cell(make_device)(width=10)` + `cells[generated_name] = cell(make_device)(width=20)` + """ + __slots__ = ('_facade', '_library', '_postprocess', '_rename') + + def __init__( + self, + library: LibraryBuilder, + *, + rename: Callable[[str], str] = _identity_name, + facade: Callable[[Pattern], Pattern] | None = None, + postprocess: Callable[[Pattern], Pattern] | None = None, + ) -> None: object.__setattr__(self, '_library', library) + object.__setattr__(self, '_rename', rename) + object.__setattr__(self, '_facade', facade) + object.__setattr__(self, '_postprocess', postprocess) + + def prefixed( + self, + prefix: str | None, + *, + facade: Callable[[Pattern], Pattern] | None = None, + postprocess: Callable[[Pattern], Pattern] | None = None, + ) -> BuildCellsView: + """ + Return an independent write-through view which conditionally prefixes names. + + Names which already start with a non-empty `prefix` are left unchanged. + `None` and the empty string select the identity naming policy. + + Args: + prefix: Prefix for implementation names. + facade: Transform applied to a generated logical-name proxy when + prefixing changes a name. + postprocess: Transform applied to the implementation Pattern on + each validation or build. + """ + if prefix is not None and not isinstance(prefix, str): + raise TypeError('Build cell prefixes must be strings or None.') + self._validate_transform('facade', facade) + self._validate_transform('postprocess', postprocess) + + def add_prefix(name: str) -> str: + if not prefix or name.startswith(prefix): + return name + return prefix + name + + return BuildCellsView( + self._library, + rename=add_prefix, + facade=facade, + postprocess=postprocess, + ) + + def renamed( + self, + rename: Callable[[str], str], + *, + facade: Callable[[Pattern], Pattern] | None = None, + postprocess: Callable[[Pattern], Pattern] | None = None, + ) -> BuildCellsView: + """ + Return an independent write-through view using `rename(name)` for output names. + + `rename` must be deterministic and return a string. `facade` and + `postprocess` have the same behavior as in `prefixed()`. + """ + if not callable(rename): + raise TypeError('Build cell name transforms must be callable.') + self._validate_transform('facade', facade) + self._validate_transform('postprocess', postprocess) + return BuildCellsView( + self._library, + rename=rename, + facade=facade, + postprocess=postprocess, + ) + + @staticmethod + def _validate_transform( + description: str, + transform: Callable[[Pattern], Pattern] | None, + ) -> None: + if transform is not None and not callable(transform): + raise TypeError(f'Build cell {description} transforms must be callable or None.') + + def _physical_name(self, name: str) -> str: + if not isinstance(name, str): + raise TypeError('Build cell names must be strings.') + physical_name = self._rename(name) + if not isinstance(physical_name, str): + raise TypeError( + f'Build cell name transform returned {type(physical_name).__name__}, expected str.' + ) + return physical_name def __getattr__(self, name: str) -> Pattern: raise BuildError( @@ -131,16 +258,47 @@ class BuildCellsView: 'Call build() and index the returned library instead.' ) - def __setattr__(self, name: str, value: Pattern | _BuildRecipe) -> None: - if name == '_library': - object.__setattr__(self, name, value) - return - self._library[name] = value + def __getitem__(self, name: str) -> Pattern: + raise BuildError( + f'LibraryBuilder.cells[{name!r}] is write-only during authoring. ' + 'Call build() and index the returned library instead.' + ) + + def __setitem__( + self, + name: str, + value: Pattern | TreeView | _BuildRecipe, + ) -> None: + self._library._assert_editable() + physical_name = self._physical_name(name) + self._library._register_view_declaration( + logical_name=name, + physical_name=physical_name, + value=value, + facade=self._facade, + postprocess=self._postprocess, + ) + + def __delitem__(self, name: str) -> None: + self._library._assert_editable() + physical_name = self._physical_name(name) + if physical_name != name: + emitted = [physical_name] + if self._facade is not None: + emitted.append(name) + raise BuildError( + f'Cannot delete renamed build declaration {name!r} through this view; ' + f'it emitted {emitted}. Delete concrete names through builder.cells[...] instead.' + ) + del self._library[name] + + def __setattr__(self, name: str, value: Pattern | TreeView | _BuildRecipe) -> None: + self[name] = value def __delattr__(self, name: str) -> None: - if name == '_library': + if name in self.__slots__: raise AttributeError(name) - del self._library[name] + del self[name] class LibraryBuilder(INameView): @@ -148,8 +306,8 @@ class LibraryBuilder(INameView): Two-phase declaration surface for mixed imported/generated libraries. A `LibraryBuilder` collects three kinds of inputs: - - direct declared `Pattern` objects - - deferred recipes created with `cell(...)` + - direct declared `Pattern` objects or single-top trees + - deferred Pattern or tree recipes created with `cell(...)` - imported source-backed library views added with `add_source(...)` The builder itself is not a normal readable library during authoring. @@ -164,7 +322,7 @@ class LibraryBuilder(INameView): self._library_placeholder = _LibraryPlaceholder(self) self._frozen = False self._building = False - self._declarations: dict[str, Pattern | _BuildRecipe] = {} + self._declarations: dict[str, _BuildDeclaration] = {} self._sources: list[tuple[ILibraryView, dict[str, str]]] = [] self._names: dict[str, None] = {} @@ -194,11 +352,16 @@ class LibraryBuilder(INameView): def __setitem__( self, key: str, - value: Pattern | _BuildRecipe, + value: Pattern | TreeView | _BuildRecipe, ) -> None: - self._assert_editable() - if key in self._names: - raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!') + self._register_declarations(((key, value, None),)) + + def _prepare_declaration( + self, + value: Pattern | TreeView | _BuildRecipe, + postprocess: Callable[[Pattern], Pattern] | None, + ) -> _BuildDeclaration: + from ..pattern import Pattern # noqa: PLC0415 if isinstance(value, _BuildRecipe): placeholders = ( @@ -207,14 +370,57 @@ class LibraryBuilder(INameView): ) if any(placeholder is not self.library for placeholder in placeholders): raise BuildError('A recipe cannot use another LibraryBuilder.library placeholder.') - declaration = value - else: - if callable(value): - raise TypeError('LibraryBuilder recipes must be wrapped with cell(fn)(...) or @cell.') - declaration = value + elif callable(value): + raise TypeError('LibraryBuilder recipes must be wrapped with cell(fn)(...) or @cell.') + elif not isinstance(value, Pattern | Mapping): + raise TypeError( + f'LibraryBuilder declarations must be a Pattern, tree, or cell recipe, not {type(value).__name__}.' + ) + return _BuildDeclaration(value=value, postprocess=postprocess) - self._declarations[key] = declaration - self._names[key] = None + def _register_declarations( + self, + entries: Sequence[tuple[str, Pattern | TreeView | _BuildRecipe, Callable[[Pattern], Pattern] | None]], + ) -> None: + self._assert_editable() + entry_names = [name for name, _value, _postprocess in entries] + if any(not isinstance(name, str) for name in entry_names): + raise TypeError('Build cell names must be strings.') + if len(set(entry_names)) != len(entry_names): + raise LibraryError(f'Duplicate names in build declaration: {entry_names}') + conflicts = [name for name in entry_names if name in self._names] + if conflicts: + raise LibraryError(f'Build declaration names already exist: {conflicts}') + + prepared = [ + (name, self._prepare_declaration(value, postprocess)) + for name, value, postprocess in entries + ] + for name, declaration in prepared: + self._declarations[name] = declaration + self._names[name] = None + + def _register_view_declaration( + self, + *, + logical_name: str, + physical_name: str, + value: Pattern | TreeView | _BuildRecipe, + facade: Callable[[Pattern], Pattern] | None, + postprocess: Callable[[Pattern], Pattern] | None, + ) -> None: + entries: list[tuple[str, Pattern | TreeView | _BuildRecipe, Callable[[Pattern], Pattern] | None]] = [ + (physical_name, value, postprocess), + ] + if physical_name != logical_name and facade is not None: + facade_recipe = _BuildRecipe( + func=_make_facade, + args=(self.library, physical_name, facade), + kwargs={}, + explicit_dependencies=(physical_name,), + ) + entries.append((logical_name, facade_recipe, None)) + self._register_declarations(entries) def __delitem__(self, key: str) -> None: self._assert_editable() @@ -431,6 +637,23 @@ class LibraryBuilder(INameView): return session, report +class _NamesWithout(INameView): + """Name view which temporarily makes one reserved declaration available.""" + + def __init__(self, names: INameView, omitted: str) -> None: + self._names = names + self._omitted = omitted + + def __iter__(self) -> Iterator[str]: + return (name for name in self._names if name != self._omitted) + + def __len__(self) -> int: + return len(self._names) - int(self._omitted in self._names) + + def __contains__(self, key: object) -> bool: + return key != self._omitted and key in self._names + + class _BuildSessionLibrary(ILibrary): """ Internal overlay-backed library used while a `LibraryBuilder` is executing. @@ -629,6 +852,75 @@ class _BuildSessionLibrary(ILibrary): return raise BuildError(f'Missing dependency "{name}"') + def _apply_postprocess( + self, + name: str, + pattern: Pattern, + postprocess: Callable[[Pattern], Pattern] | None, + ) -> Pattern: + from ..pattern import Pattern # noqa: PLC0415 + + if postprocess is None: + return pattern + result = postprocess(pattern) + if not isinstance(result, Pattern): + raise BuildError( + f'Postprocess transform for "{name}" returned {type(result).__name__}, expected Pattern' + ) + return result + + def _commit_declared_tree( + self, + name: str, + tree: TreeView, + postprocess: Callable[[Pattern], Pattern] | None, + ) -> Pattern: + from ..pattern import map_targets # noqa: PLC0415 + + view = tree if isinstance(tree, ILibraryView) else LibraryView(tree) + source_order = tuple(view.source_order()) + temp = Library(copy.deepcopy({source_name: view[source_name] for source_name in source_order})) + tops = temp.tops() + if len(tops) != 1: + raise BuildError(f'Tree declaration for "{name}" must have exactly one topcell, found {tops}') + + old_top = tops[0] + source_order = (old_top, *(source_name for source_name in temp if source_name != old_top)) + + def rename_tree_source(names: INameView, source_name: str) -> str: + if source_name == old_top: + return name + if source_name in names: + return _rename_patterns(names, source_name) + return source_name + + source_to_visible = _plan_source_names( + _NamesWithout(self, name), + source_order, + rename_theirs=rename_tree_source, + rename_when='always', + ) + temp.mapping[old_top] = self._apply_postprocess(name, temp[old_top], postprocess) + rename_map = _source_rename_map(source_to_visible) + if rename_map: + target_map = cast('dict[str | None, str | None]', rename_map) + remapped_refs = { + source_name: map_targets(temp[source_name].refs, lambda target: target_map.get(target, target)) + for source_name in source_order + } + for source_name, refs in remapped_refs.items(): + temp[source_name].refs = refs + + for source_name in source_order: + visible_name = source_to_visible[source_name] + self[visible_name] = temp[source_name] + if source_name not in (old_top, visible_name): + self._provenance[visible_name] = replace( + self._provenance[visible_name], + requested_name=source_name, + ) + return temp[old_top] + def _ensure_declared(self, name: str) -> None: from ..pattern import Pattern # noqa: PLC0415 @@ -639,30 +931,44 @@ class _BuildSessionLibrary(ILibrary): raise BuildError(f'Cycle detected while building declared cells: {chain}') declaration = self._builder._declarations[name] + value = declaration.value self._declared_stack.append(name) try: - if isinstance(declaration, _BuildRecipe): - for dep in declaration.explicit_dependencies: + if isinstance(value, _BuildRecipe): + for dep in value.explicit_dependencies: self._ensure_named(dep) args = tuple( self if arg is self._builder.library else arg - for arg in declaration.args + for arg in value.args ) kwargs = { - key: self if value is self._builder.library else value - for key, value in declaration.kwargs.items() + key: self if arg_value is self._builder.library else arg_value + for key, arg_value in value.kwargs.items() } - pattern = declaration.func(*args, **kwargs) - if not isinstance(pattern, Pattern): - raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') # noqa: TRY301 + result = value.func(*args, **kwargs) else: - pattern = declaration.deepcopy() + result = value.deepcopy() if isinstance(value, Pattern) else value + + if isinstance(result, Pattern): + published_pattern = result + pattern = self._apply_postprocess(name, result, declaration.postprocess) + elif isinstance(result, Mapping): + pattern = self._commit_declared_tree(name, result, declaration.postprocess) + published_pattern = pattern + else: + raise BuildError( # noqa: TRY301 + f'Recipe for "{name}" returned {type(result).__name__}, expected Pattern or single-top tree' + ) if name in self._overlay: - if self._overlay[name] is not pattern: + existing = self._overlay[name] + if existing is not published_pattern: raise BuildError( # noqa: TRY301 f'Recipe for "{name}" wrote a different pattern into the session under its own name.' ) + if existing is not pattern: + del self._overlay[name] + self._overlay[name] = pattern else: self[name] = pattern self._built.add(name) diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index 27393af..647b5f0 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -21,6 +21,7 @@ from ..library import ( ) from ..pattern import Pattern from ..ports import Port +from ..utils.ports2data import ports_to_data def _owned_by(report: BuildReport, owner: str) -> set[str]: @@ -832,3 +833,257 @@ def test_build_session_subtree_preserves_type_and_builds_deferred_dependencies() assert child_calls == 1 assert set(built) == {"top", "child", "leaf", "unrelated"} + + +def test_build_cells_prefixed_view_generates_facades_for_attributes_and_items() -> None: + builder = LibraryBuilder() + cells = builder.cells.prefixed( + "vendor_", + facade=lambda proxy: ports_to_data(proxy, (10, 2)), + ) + + cells.fixed = cell(Pattern)(ports={"p": Port((1, 2), 0)}) + dynamic_name = "dynamic" + cells[dynamic_name] = cell(Pattern)(ports={"q": Port((3, 4), 0)}) + built, report = builder.build(output="library") + + assert set(built) == {"vendor_fixed", "fixed", "vendor_dynamic", "dynamic"} + assert set(built["fixed"].refs) == {"vendor_fixed"} + assert set(built["dynamic"].refs) == {"vendor_dynamic"} + assert set(built["fixed"].ports) == {"p"} + assert len(built["fixed"].labels[(10, 2)]) == 1 + assert not built["vendor_fixed"].labels + assert report.dependency_graph["fixed"] == frozenset({"vendor_fixed"}) + assert report.dependency_graph["dynamic"] == frozenset({"vendor_dynamic"}) + + +def test_build_cells_prefixed_view_postprocesses_only_the_implementation() -> None: + source = Pattern(ports={"p": Port((0, 0), 0)}) + builder = LibraryBuilder() + physical = builder.cells.prefixed( + "vendor_", + postprocess=lambda pattern: ports_to_data(pattern, (20, 1)), + ) + physical["cell"] = source + unprefixed = builder.cells.prefixed( + None, + postprocess=lambda pattern: ports_to_data(pattern, (21, 1)), + ) + unprefixed["local"] = Pattern(ports={"q": Port((1, 1), 0)}) + built, _report = builder.build(output="library") + + assert set(built) == {"vendor_cell", "local"} + assert len(built["vendor_cell"].labels[(20, 1)]) == 1 + assert len(built["local"].labels[(21, 1)]) == 1 + assert not source.labels + + +def test_build_cells_postprocess_precedes_facade_port_copy() -> None: + builder = LibraryBuilder() + + def add_port(pattern: Pattern) -> Pattern: + pattern.ports["added"] = Port((5, 6), 0) + return pattern + + cells = builder.cells.prefixed( + "impl_", + postprocess=add_port, + facade=lambda pattern: ports_to_data(pattern, (30, 0)), + ) + cells.device = Pattern() + built, _report = builder.build(output="library") + + assert set(built["impl_device"].ports) == {"added"} + assert set(built["device"].ports) == {"added"} + assert built["device"].labels[(30, 0)][0].string.startswith("added:") + + +def test_build_cells_facade_is_ignored_when_name_does_not_change() -> None: + builder = LibraryBuilder() + + def fail_if_called(_pattern: Pattern) -> Pattern: + raise AssertionError("facade should not be called") + + cells = builder.cells.prefixed("vendor_", facade=fail_if_called) + cells["vendor_cell"] = Pattern() + built, _report = builder.build(output="library") + + assert set(built) == {"vendor_cell"} + + +def test_build_cells_renamed_views_are_independent_and_accept_dynamic_names() -> None: + builder = LibraryBuilder() + upper = builder.cells.renamed(str.upper) + suffixed = builder.cells.renamed(lambda name: name + "_v2") + + builder.cells.plain = Pattern() + upper["upper"] = Pattern() + suffixed["other"] = Pattern() + built, _report = builder.build(output="library") + + assert set(built) == {"plain", "UPPER", "other_v2"} + + +def test_build_cells_policy_registration_is_atomic() -> None: + builder = LibraryBuilder() + builder.cells.public = Pattern() + prefixed = builder.cells.prefixed("impl_", facade=lambda pattern: pattern) + + with pytest.raises(LibraryError, match="already exist"): + prefixed.public = Pattern() + + assert set(builder) == {"public"} + assert "impl_public" not in builder + + +def test_build_cells_policy_views_validate_names_and_transforms() -> None: + builder = LibraryBuilder() + invalid_name = builder.cells.renamed(lambda _name: None) # type: ignore[arg-type,return-value] + + with pytest.raises(TypeError, match="expected str"): + invalid_name["cell"] = Pattern() + with pytest.raises(TypeError, match="must be callable"): + builder.cells.prefixed("x_", facade=object()) # type: ignore[arg-type] + + assert not builder + + +def test_build_cells_view_deletion_requires_concrete_names_after_rename() -> None: + builder = LibraryBuilder() + prefixed = builder.cells.prefixed("impl_", facade=lambda pattern: pattern) + prefixed["cell"] = Pattern() + + with pytest.raises(BuildError, match="Delete concrete names"): + del prefixed["cell"] + + del builder.cells["cell"] + del builder.cells["impl_cell"] + builder.cells["plain"] = Pattern() + del builder.cells["plain"] + assert not builder + + +def test_build_cells_item_reads_remain_write_only() -> None: + builder = LibraryBuilder() + builder.cells["cell"] = Pattern() + + with pytest.raises(BuildError, match="write-only"): + _ = builder.cells["cell"] + + +def test_build_cells_prefixed_view_normalizes_recipe_tree_and_generates_facade() -> None: + builder = LibraryBuilder() + builder.cells["_helper"] = Pattern() + cells = builder.cells.prefixed( + "impl_", + postprocess=lambda pattern: Pattern(ports={"out": Port((7, 8), 0)}, refs=pattern.refs), + facade=lambda pattern: ports_to_data(pattern, (40, 0)), + ) + + def make_tree() -> Library: + tree = Library({"_helper": Pattern(), "old_top": Pattern()}) + tree["old_top"].ref("_helper") + return tree + + cells.tree = cell(make_tree)() + built, report = builder.build(output="library") + + assert {"_helper", "impl_tree", "tree"} <= set(built) + helper_targets = set(built["impl_tree"].refs) + assert "_helper" not in helper_targets + assert len(helper_targets) == 1 + renamed_helper = helper_targets.pop() + assert renamed_helper in built + assert report.provenance[renamed_helper].kind == "helper" + assert report.provenance[renamed_helper].requested_name == "_helper" + assert report.provenance[renamed_helper].owner_declared_name == "impl_tree" + assert set(built["tree"].ports) == {"out"} + assert set(built["tree"].refs) == {"impl_tree"} + + +def test_build_cells_view_accepts_direct_tree_and_rejects_multiple_tops() -> None: + builder = LibraryBuilder() + builder.cells["tree"] = Library({"old_top": Pattern()}) + built, _report = builder.build(output="library") + assert set(built) == {"tree"} + + invalid = LibraryBuilder() + invalid.cells["tree"] = cell(lambda: Library({"one": Pattern(), "two": Pattern()}))() + with pytest.raises(BuildError, match="exactly one topcell"): + invalid.validate() + + +def test_build_cells_tree_declared_name_wins_over_single_use_helper() -> None: + builder = LibraryBuilder() + + def make_tree() -> Library: + top = Pattern() + top.ref("_device") + return Library({"_device": Pattern(), "old_top": top}) + + builder.cells["_device"] = cell(make_tree)() + built, report = builder.build(output="library") + + assert "_device" in built + assert set(built["_device"].refs) != {"_device"} + assert len(built["_device"].refs) == 1 + helper_name = next(iter(built["_device"].refs)) + assert helper_name in built + assert report.provenance["_device"].kind == "declared" + assert report.provenance["_device"].requested_name == "_device" + assert report.provenance[helper_name].kind == "helper" + assert report.provenance[helper_name].requested_name == "_device" + assert report.provenance[helper_name].owner_declared_name == "_device" + + +def test_build_cells_tree_declared_name_rejects_public_helper_conflict() -> None: + builder = LibraryBuilder() + + def make_tree() -> Library: + top = Pattern() + top.ref("device") + return Library({"device": Pattern(), "old_top": top}) + + builder.cells["device"] = cell(make_tree)() + with pytest.raises(BuildError, match="Unresolved duplicate key"): + builder.validate() + + del builder.cells["device"] + builder.cells["device"] = Pattern() + builder.validate() + + +def test_build_cells_replacement_postprocess_updates_self_published_recipe_and_facade() -> None: + builder = LibraryBuilder() + cells = builder.cells.prefixed( + "impl_", + postprocess=lambda _pattern: Pattern(ports={"new": Port((5, 6), 0)}), + facade=lambda pattern: ports_to_data(pattern, (50, 0)), + ) + + def make_device(lib: ILibrary) -> Pattern: + original = Pattern(ports={"old": Port((1, 2), 0)}) + lib["impl_device"] = original + return original + + cells.device = cell(make_device)(builder.library) + built, report = builder.build(output="library") + + assert set(built) == {"impl_device", "device"} + assert set(built["impl_device"].ports) == {"new"} + assert set(built["device"].ports) == {"new"} + assert built["device"].labels[(50, 0)][0].string.startswith("new:") + assert report.provenance["impl_device"].kind == "declared" + assert report.dependency_graph["device"] == frozenset({"impl_device"}) + + +def test_build_cells_rejects_unrelated_self_published_pattern() -> None: + builder = LibraryBuilder() + + def make_device(lib: ILibrary) -> Pattern: + lib["device"] = Pattern() + return Pattern() + + builder.cells.device = cell(make_device)(builder.library) + with pytest.raises(BuildError, match="wrote a different pattern"): + builder.validate()