diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 749915b..f5c4392 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -20,10 +20,10 @@ Contents * Use `Pather` to snap ports together into a circuit * Check for dangling references - [library](library.py) - * Continue from `devices.py` using a lazy library - * Create a `LazyLibrary`, which loads / generates patterns only when they are first used + * Continue from `devices.py` by declaring a mixed library with `BuildLibrary` + * Import source-backed GDS cells and register python-generated recipes together + * Call `build()` to produce a normal library for downstream `Pather` usage and writing * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` - * Design a pattern which is meant to plug into an existing pattern (via `.interface()`) - [pather](pather.py) * Use `Pather` to route individual wires and wire bundles * Use `AutoTool` to generate paths diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index f6bcc4e..f4eb3f0 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,136 +1,114 @@ """ -Tutorial: using a source-backed lazy GDS library and `Pather.interface()`. +Tutorial: authoring a mixed library with `BuildLibrary`. This example assumes you have already read `devices.py` and generated the `circuit.gds` file it writes. The goal here is not the photonic-crystal geometry -itself, but rather how Masque lets you mix lazily loaded GDS content with -python-generated devices inside one library. +itself, but rather how Masque lets you combine imported GDS cells with +python-generated recipes, then turn that declaration set into a normal library +for downstream assembly and writing. """ from typing import Any from pprint import pformat -from masque import Pather +from masque import BuildLibrary, Pather, Pattern, cell from masque.file.gdsii import writefile -from masque.file.gdsii_lazy import OverlayLibrary, readfile +from masque.file.gdsii_lazy import readfile import basic_shapes import devices from basic_shapes import GDS_OPTS +def make_mixed_waveguide(lib: BuildLibrary) -> Pattern: + """ + Recipe which assembles imported and generated cells behind the builder API. + """ + circ = Pather(library=lib, ports='tri_l3cav') + + # First way to specify what we are plugging in: request an explicit abstract. + circ.plug(lib.abstract('wg10'), {'input': 'right'}) + + # Second way: use an AbstractView, which behaves like a mapping of names + # to abstracts. + abstracts = lib.abstract_view() + circ.plug(abstracts['wg10'], {'output': 'left'}) + + # Third way: let Pather resolve a pattern name through its own library. + circ.plug('tri_wg10', {'input': 'right'}) + circ.plug('tri_wg10', {'output': 'left'}) + + return circ.pattern + + def main() -> None: - # `OverlayLibrary` lets us mix source-backed GDS cells with python-generated - # patterns behind the same library interface. - lib = OverlayLibrary() + builder = BuildLibrary() + cells = builder.cells # # Load some devices from a GDS file # # Scan circuit.gds and prepare to lazy-load its contents. Port labels are - # imported on first materialization, but the raw source remains untouched. + # imported on first materialization, but the raw source remains untouched + # until we build the final library. gds_lib, _properties = readfile('circuit.gds') - lib.add_source(gds_lib.with_ports_from_data(layers=[(3, 0)], max_depth=1)) + builder.add_source(gds_lib.with_ports_from_data(layers=[(3, 0)], max_depth=1)) - print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys()))) + print('Registered imported cells:\n' + pformat(list(gds_lib.keys()))) # - # Add some new devices to the library, this time from python code rather than GDS + # Register some new devices, this time from python code rather than GDS. # - lib['triangle'] = basic_shapes.triangle(devices.RADIUS) + cells.triangle = basic_shapes.triangle(devices.RADIUS) opts: dict[str, Any] = dict( - lattice_constant = devices.LATTICE_CONSTANT, - hole = 'triangle', - ) + lattice_constant=devices.LATTICE_CONSTANT, + hole='triangle', + ) - lib['tri_wg10'] = devices.waveguide(length=10, mirror_periods=5, **opts) - lib['tri_wg05'] = devices.waveguide(length=5, mirror_periods=5, **opts) - lib['tri_wg28'] = devices.waveguide(length=28, mirror_periods=5, **opts) - lib['tri_bend0'] = devices.bend(mirror_periods=5, **opts) - lib['tri_ysplit'] = devices.y_splitter(mirror_periods=5, **opts) - lib['tri_l3cav'] = devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib) + cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts) + cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts) + cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts) + cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts) + cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts) + cells.tri_l3cav = cell(devices.perturbed_l3)(xy_size=(4, 10), **opts, hole_lib=builder) + cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder) + + print('Declared cells waiting to be built:\n' + pformat(list(builder.keys()))) # - # Build a mixed waveguide with an L3 cavity in the middle + # Build the declaration set into a normal library. # - # Start a new design by copying the ports from an existing library cell. - # This gives `circ2` the same external interface as `tri_l3cav`. - circ2 = Pather(library=lib, ports='tri_l3cav') - - # First way to specify what we are plugging in: request an explicit abstract. - # This works with `Pattern` methods directly as well as with `Pather`. - circ2.plug(lib.abstract('wg10'), {'input': 'right'}) - - # Second way: use an `AbstractView`, which behaves like a mapping of names - # to abstracts. - abstracts = lib.abstract_view() - circ2.plug(abstracts['wg10'], {'output': 'left'}) - - # Third way: let `Pather` resolve a pattern name through its own library. - # This shorthand is convenient, but it is specific to helpers that already - # carry a library reference. - circ2.plug('tri_wg10', {'input': 'right'}) - circ2.plug('tri_wg10', {'output': 'left'}) - - # Add the circuit to the device library. - lib['mixed_wg_cav'] = circ2.pattern - + built = builder.build() + print('Built library contains:\n' + pformat(list(built.keys()))) # - # Build a second device that is explicitly designed to mate with `circ2`. + # Continue designing against the built library. # - # `Pather.interface()` makes a new pattern whose ports mirror an existing - # design's external interface. That is useful when you want to design an - # adapter, continuation, or mating structure. - circ3 = Pather.interface(source=circ2) - - # Continue routing outward from those inherited ports. - circ3.plug('tri_bend0', {'input': 'right'}) - circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry - circ3.plug('tri_bend0', {'input': 'right'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('tri_wg10', {'input': 'right'}) - circ3.plug('tri_wg28', {'input': 'right'}) - circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'}) - - lib['loop_segment'] = circ3.pattern + # The built result behaves like a normal mutable library, so downstream code + # can use Pather, abstract views, and writing without going back through the + # builder interface. + circ = Pather.interface(source='mixed_wg_cav', library=built) + circ.plug('tri_bend0', {'input': 'right'}) + circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry + circ.plug('tri_bend0', {'input': 'right'}) + circ.plug('bend0', {'output': 'left'}) + circ.plug('bend0', {'output': 'left'}) + circ.plug('bend0', {'output': 'left'}) + circ.plug('tri_wg10', {'input': 'right'}) + circ.plug('tri_wg28', {'input': 'right'}) + circ.plug('tri_wg10', {'input': 'right', 'output': 'left'}) + built['loop_segment'] = circ.pattern # - # Write all devices into a GDS file + # Write all devices into a GDS file. # print('Writing library to file...') - writefile(lib, 'library.gds', **GDS_OPTS) + writefile(built, 'library.gds', **GDS_OPTS) if __name__ == '__main__': main() - - -# -#class prout: -# def place( -# self, -# other: Pattern, -# label_layer: layer_t = 'WATLAYER', -# *, -# port_map: Dict[str, str | None] | None = None, -# **kwargs, -# ) -> 'prout': -# -# Pattern.place(self, other, port_map=port_map, **kwargs) -# name: str | None -# for name in other.ports: -# if port_map: -# assert(name is not None) -# name = port_map.get(name, name) -# if name is None: -# continue -# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer) -# return self -# diff --git a/masque/__init__.py b/masque/__init__.py index b6cc5e9..b6dfb44 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -63,10 +63,15 @@ from .library import ( ILibrary as ILibrary, LibraryView as LibraryView, Library as Library, + BuiltLibrary as BuiltLibrary, + BuildLibrary as BuildLibrary, + BuildReport as BuildReport, + CellProvenance as CellProvenance, LazyLibrary as LazyLibrary, AbstractView as AbstractView, TreeView as TreeView, Tree as Tree, + cell as cell, ) from .ports import ( Port as Port, diff --git a/masque/file/gdsii_lazy.py b/masque/file/gdsii_lazy.py index f9561af..b587c89 100644 --- a/masque/file/gdsii_lazy.py +++ b/masque/file/gdsii_lazy.py @@ -1,9 +1,18 @@ """ -Source-backed lazy GDSII reader using the pure-python klamath path. +Classic source-backed lazy GDSII reader built on the pure-python klamath path. -This module mirrors the lazy Arrow reader's interface closely enough to share -the same overlay and ports-import helpers, while still materializing cells -through the classic `gdsii` decoder. +This module provides the non-Arrow half of Masque's lazy GDS architecture: + +- `GdsLibrarySource` scans a GDS stream once to discover library metadata, + struct order, and child edges without materializing every cell. +- cells are materialized on demand through the classic `gdsii` decoder + whenever a caller indexes the lazy view +- the source can be wrapped in `PortsLibraryView` or merged through + `OverlayLibrary`, both of which live in `gdsii_lazy_core` + +The public surface intentionally parallels `gdsii_lazy_arrow` closely so that +callers can swap between the classic and Arrow-backed implementations with +minimal changes. """ from __future__ import annotations @@ -36,6 +45,7 @@ logger = logging.getLogger(__name__) @dataclass class _SourceHandle: + """ Owns the underlying stream and any companion file handle for a source. """ path: pathlib.Path | None stream: IO[bytes] handle: IO[bytes] | None = None @@ -49,6 +59,7 @@ class _SourceHandle: @dataclass(frozen=True) class _CellScan: + """ Scan-time metadata for one cell in the source stream. """ offset: int children: set[str] @@ -107,6 +118,10 @@ class GdsLibrarySource(ILibraryView): Cells are scanned once up front to discover order and child edges, then materialized one at a time through the classic `gdsii.read_elements` path. + + The source owns the stream lifetime, preserves on-disk ordering through + `source_order()`, and answers graph queries from scan metadata whenever + possible so callers can inspect hierarchy without forcing a full load. """ def __init__( diff --git a/masque/file/gdsii_lazy_core.py b/masque/file/gdsii_lazy_core.py index eaaf863..d730348 100644 --- a/masque/file/gdsii_lazy_core.py +++ b/masque/file/gdsii_lazy_core.py @@ -1,5 +1,18 @@ """ Shared helpers for source-backed lazy GDS views. + +This module contains the reusable pieces that sit between lazy source readers +and ordinary mutable library usage: + +- `PortsLibraryView` layers a processed, ports-importing cache on top of a raw + source view without mutating the source itself +- `OverlayLibrary` exposes a mutable library surface that can mix source-backed + cells with overlay-owned materialized patterns +- the write helpers preserve source-backed copy-through behavior where + possible, falling back to normal pattern serialization when a cell has been + materialized or remapped + +Both the classic and Arrow-backed lazy GDS readers rely on these helpers. """ from __future__ import annotations @@ -30,6 +43,7 @@ logger = logging.getLogger(__name__) @dataclass class _SourceLayer: + """ One imported source layer tracked by an `OverlayLibrary`. """ library: ILibraryView source_to_visible: dict[str, str] visible_to_source: dict[str, str] @@ -39,6 +53,7 @@ class _SourceLayer: @dataclass(frozen=True) class _SourceEntry: + """ Reference to a single visible source-backed cell in an overlay. """ layer_index: int source_name: str @@ -73,6 +88,10 @@ class PortsLibraryView(ILibraryView): The wrapped source remains untouched; this view owns a separate processed cache so direct-copy workflows can continue to use the raw source view. + + Graph queries, source ordering, and copy-through capabilities are delegated + to the wrapped source whenever possible, while `__getitem__` and + `materialize_many()` return port-imported patterns. """ def __init__( @@ -231,6 +250,14 @@ class OverlayLibrary(ILibrary): Source-backed cells remain lazy until accessed through `__getitem__`, at which point that visible cell is promoted into an overlay-owned materialized `Pattern`. + + This is the main mutable integration surface for lazy GDS content. It lets + callers: + - expose one or more source-backed libraries behind a normal `ILibrary` + interface + - add or replace cells with overlay-owned patterns + - rename visible source cells + - remap references without immediately rewriting untouched source structs """ def __init__(self) -> None: @@ -522,6 +549,20 @@ class OverlayLibrary(ILibrary): return tuple(name for name in self._order if name in self._entries) +class BuiltOverlayLibrary(OverlayLibrary): + """ + Internal overlay output returned by `BuildLibrary.build(output='overlay')`. + + The type is intentionally not part of the public API. It exists so build + outputs can carry a `build_report` while still behaving like an + `OverlayLibrary`. + """ + + def __init__(self, *, build_report: Any | None = None) -> None: + super().__init__() + self.build_report = build_report + + def _iter_library_infos(library: Mapping[str, Pattern] | ILibraryView) -> Iterator[dict[str, Any]]: info = getattr(library, 'library_info', None) if isinstance(info, dict): diff --git a/masque/library.py b/masque/library.py index bc15969..942113a 100644 --- a/masque/library.py +++ b/masque/library.py @@ -14,7 +14,7 @@ Classes include: - `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked library. Generated with `ILibraryView.abstract_view()`. """ -from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal +from typing import Self, TYPE_CHECKING, Any, cast, TypeAlias, Protocol, Literal from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable import logging import re @@ -22,12 +22,14 @@ import copy from pprint import pformat from collections import defaultdict from abc import ABCMeta, abstractmethod +from contextvars import ContextVar +from dataclasses import dataclass, replace from graphlib import TopologicalSorter, CycleError import numpy from numpy.typing import ArrayLike, NDArray -from .error import LibraryError, PatternError +from .error import BuildError, LibraryError, PatternError from .utils import layer_t, apply_transforms from .shapes import Shape, Polygon from .label import Label @@ -40,6 +42,11 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +_ACTIVE_BUILD_SESSIONS: ContextVar[dict[int, '_BuildSessionLibrary'] | None] = ContextVar( + 'masque_active_build_sessions', + default=None, +) + class visitor_function_t(Protocol): """ Signature for `Library.dfs()` visitor functions. """ @@ -62,6 +69,69 @@ Tree: TypeAlias = MutableMapping[str, 'Pattern'] dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include'] """ How helpers should handle refs whose targets are not present in the library. """ +emitted_via_t: TypeAlias = Literal['declaration', 'helper_write', 'tree_merge', 'source_import'] +""" Build-provenance origin tags for emitted cells. """ + + +@dataclass(frozen=True) +class CellProvenance: + """ + Provenance record for one cell in a completed build output. + + Each output name in a `BuildReport` maps to one `CellProvenance`. The + record captures both where the cell came from and how its visible name was + chosen. + + Attributes: + final_name: Name exposed by the completed library. + requested_name: First name requested for this cell during the build. + kind: Whether the cell came from a declaration, helper emission, or an + imported source library. + owner_declared_name: Declared cell responsible for this output cell, if + any. Imported source cells leave this as `None`. + emitted_via: High-level path by which the cell entered the output. + build_chain: Declared-cell dependency chain that was active when the + cell was emitted. + renamed_from: Original requested name when the final name differs. + source_name: Original on-source name for imported cells. + source_metadata: Optional source-library metadata copied through from + lazy GDS readers. + """ + final_name: str + requested_name: str + kind: Literal['declared', 'helper', 'source'] + owner_declared_name: str | None + emitted_via: emitted_via_t + build_chain: tuple[str, ...] + renamed_from: str | None = None + source_name: str | None = None + source_metadata: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class BuildReport: + """ + Immutable summary of one `BuildLibrary.validate()` or `.build()` run. + + The report is designed to answer two questions after a build completes: + which declared cells depended on which other declared cells, and where each + output cell came from. + + Attributes: + requested_roots: Roots explicitly requested for the run. A full + `build()` uses all declared cells. + provenance: Mapping from final output name to provenance metadata. + owned_cells: Mapping from declared cell name to all final output cell + names it owns, including helper cells emitted while that declared + cell was building. + dependency_graph: Declared-cell dependency graph discovered through + library-mediated reads and explicit recipe hints. + """ + requested_roots: tuple[str, ...] + provenance: Mapping[str, CellProvenance] + owned_cells: Mapping[str, tuple[str, ...]] + dependency_graph: Mapping[str, frozenset[str]] + SINGLE_USE_PREFIX = '_' """ @@ -1397,6 +1467,819 @@ class Library(ILibrary): return tree, pat +class BuiltLibrary(Library): + """ + Eager library returned by `BuildLibrary.build(output='library')`. + + This is a normal materialized `Library` with one additional attribute, + `build_report`, which records how the library was assembled from + declarations, helper emissions, and imported source-backed cells. + """ + + def __init__( + self, + mapping: MutableMapping[str, 'Pattern'] | None = None, + *, + build_report: BuildReport | None = None, + ) -> None: + super().__init__(mapping=mapping) + self.build_report = build_report + + +class _CellFactory: + """ + Adapter that turns a plain pattern factory into a deferred recipe factory. + + Calling the wrapper captures arguments and returns a `_BuildRecipe` + instead of executing the function immediately. + """ + + def __init__(self, func: Callable[..., 'Pattern']) -> None: + self.func = func + self.__name__ = getattr(func, '__name__', type(self).__name__) + self.__doc__ = getattr(func, '__doc__') + + def __call__(self, *args: Any, **kwargs: Any) -> '_BuildRecipe': + return _BuildRecipe(func=self.func, args=args, kwargs=kwargs) + + +@dataclass +class _BuildRecipe: + """ Captured deferred call to a pattern factory. """ + func: Callable[..., 'Pattern'] + args: tuple[Any, ...] + kwargs: dict[str, Any] + explicit_dependencies: tuple[str, ...] = () + + def depends_on(self, *names: str) -> '_BuildRecipe': + self.explicit_dependencies += tuple(names) + return self + + +@dataclass(frozen=True) +class _PatternDeclaration: + """ Declared cell backed by an already-built `Pattern`. """ + pattern: 'Pattern' + + +@dataclass(frozen=True) +class _RecipeDeclaration: + """ Declared cell backed by a deferred recipe. """ + recipe: _BuildRecipe + + +@dataclass(frozen=True) +class _SourceDeclaration: + """ + Imported source-backed names registered with a `BuildLibrary`. + + The declaration stores visible-name remapping plus pre-scanned graph + metadata. Underlying source cells stay lazy until a build session + materializes or copies them through. + """ + library: ILibraryView + source_to_visible: Mapping[str, str] + visible_to_source: Mapping[str, str] + child_graph: Mapping[str, set[str]] + order: tuple[str, ...] + + +def cell(func: Callable[..., 'Pattern']) -> _CellFactory: + """ + Wrap a plain pattern factory so calls return deferred build recipes. + + Use as either `cell(fn)(...)` or `@cell`. + """ + return _CellFactory(func) + + +class BuildCellsView: + """ + Attribute-based declaration namespace for `BuildLibrary`. + + 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 + use the returned library. + """ + + def __init__(self, library: 'BuildLibrary') -> None: + object.__setattr__(self, '_library', library) + + def __getattr__(self, name: str) -> 'Pattern': + raise BuildError( + f'BuildLibrary.cells.{name} is write-only during authoring. ' + 'Call build() and index the returned library instead.' + ) + + def __setattr__(self, name: str, value: 'Pattern | _BuildRecipe') -> None: + if name.startswith('_'): + object.__setattr__(self, name, value) + return + self._library[name] = value + + def __delattr__(self, name: str) -> None: + if name.startswith('_'): + raise AttributeError(name) + del self._library[name] + + +class BuildLibrary(ILibrary): + """ + Two-phase declaration surface for mixed imported/generated libraries. + + A `BuildLibrary` collects three kinds of inputs: + - direct declared `Pattern` objects + - deferred recipes created with `cell(...)` + - imported source-backed library views added with `add_source(...)` + + The builder itself is not a normal readable library during authoring. + Instead, `validate()` and `build()` create a temporary build-session library + that recipes can read from and write helper cells into while dependencies + are resolved. `build()` then freezes the builder on success and returns a + normal library-like object carrying a `build_report`. + """ + + def __init__(self, *, check_on_register: bool = False) -> None: + self.check_on_register = check_on_register + self.cells = BuildCellsView(self) + self.last_build_report: BuildReport | None = None + self._frozen = False + self._declarations: dict[str, _PatternDeclaration | _RecipeDeclaration] = {} + self._sources: list[_SourceDeclaration] = [] + self._names: set[str] = set() + self._order: list[str] = [] + + def _active_session(self) -> '_BuildSessionLibrary | None': + sessions = _ACTIVE_BUILD_SESSIONS.get() + if sessions is None: + return None + return sessions.get(id(self)) + + def _require_active_session(self, operation: str) -> '_BuildSessionLibrary': + session = self._active_session() + if session is None: + raise BuildError( + f'BuildLibrary.{operation}() is only available while validate() or build() is running. ' + 'Use the built output library for reads.' + ) + return session + + def _assert_editable(self) -> None: + if self._frozen: + raise BuildError('This BuildLibrary has already been built successfully and is now frozen.') + + def __iter__(self) -> Iterator[str]: + session = self._active_session() + if session is not None: + return iter(session) + return iter(self._order) + + def __len__(self) -> int: + session = self._active_session() + if session is not None: + return len(session) + return len(self._names) + + def __contains__(self, key: object) -> bool: + session = self._active_session() + if session is not None: + return key in session + return key in self._names + + def __getitem__(self, key: str) -> 'Pattern': + return self._require_active_session('__getitem__')[key] + + def __setitem__( + self, + key: str, + value: 'Pattern | _BuildRecipe | Callable[[], Pattern]', + ) -> None: + session = self._active_session() + if session is not None: + session[key] = value + return + + self._assert_editable() + if key in self._names: + raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!') + + declaration: _PatternDeclaration | _RecipeDeclaration + if isinstance(value, _BuildRecipe): + declaration = _RecipeDeclaration(value) + else: + if callable(value): + raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.') + declaration = _PatternDeclaration(value) + + self._declarations[key] = declaration + self._names.add(key) + self._order.append(key) + + if self.check_on_register: + try: + self.validate(names=(key,)) + except Exception: + del self._declarations[key] + self._names.remove(key) + self._order.remove(key) + raise + + def __delitem__(self, key: str) -> None: + session = self._active_session() + if session is not None: + del session[key] + return + + self._assert_editable() + if key not in self._declarations: + raise KeyError(key) + del self._declarations[key] + self._names.remove(key) + self._order.remove(key) + + def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: + session = self._active_session() + if session is not None: + session._merge(key_self, other, key_other) + return + self[key_self] = copy.deepcopy(other[key_other]) + + def add( + self, + other: Mapping[str, 'Pattern'], + rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns, + mutate_other: bool = False, + ) -> dict[str, str]: + session = self._active_session() + if session is not None: + return session.add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) + return super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) + + def rename( + self, + old_name: str, + new_name: str, + move_references: bool = False, + ) -> Self: + """ + Rename an imported source-backed visible name during authoring. + + Only imported source-backed cells may be renamed on the builder itself. + Declared/generated cells must be registered under their intended final + names. `move_references=True` is intentionally unsupported here because + deferred recipes and declaration internals cannot be rewritten safely. + """ + session = self._active_session() + if session is not None: + session.rename(old_name, new_name, move_references=move_references) + return self + + self._assert_editable() + if old_name == new_name: + return self + if old_name in self._declarations: + raise BuildError( + f'Cannot rename declared build cell "{old_name}" during authoring. ' + 'Register it under the intended final name instead.' + ) + if old_name not in self._names: + raise LibraryError(f'"{old_name}" does not exist in the builder.') + if new_name in self._names: + raise LibraryError(f'"{new_name}" already exists in the builder.') + if move_references: + raise BuildError( + 'BuildLibrary.rename(..., move_references=True) is not supported for imported source cells. ' + 'Builder-level renames only change the visible imported name.' + ) + + source_index = next( + (idx for idx, spec in enumerate(self._sources) if old_name in spec.visible_to_source), + None, + ) + if source_index is None: + raise BuildError( + f'Cannot rename "{old_name}" during authoring because only imported source-backed ' + 'cells may be renamed on a BuildLibrary.' + ) + + spec = self._sources[source_index] + source_name = spec.visible_to_source[old_name] + source_to_visible = dict(spec.source_to_visible) + visible_to_source = dict(spec.visible_to_source) + order = list(spec.order) + + source_to_visible[source_name] = new_name + del visible_to_source[old_name] + visible_to_source[new_name] = source_name + order[order.index(old_name)] = new_name + + self._sources[source_index] = replace( + spec, + source_to_visible=source_to_visible, + visible_to_source=visible_to_source, + order=tuple(order), + ) + self._names.remove(old_name) + self._names.add(new_name) + self._order[self._order.index(old_name)] = new_name + return self + + def abstract(self, name: str) -> Abstract: + return self._require_active_session('abstract').abstract(name) + + def resolve( + self, + other: 'Abstract | str | Pattern | TreeView', + append: bool = False, + ) -> 'Abstract | Pattern': + return self._require_active_session('resolve').resolve(other, append=append) + + def add_source( + self, + source: Mapping[str, 'Pattern'] | ILibraryView, + *, + rename_theirs: Callable[[ILibraryView, str], str] | None = None, + ) -> dict[str, str]: + """ + Register an imported source-backed library with the builder. + + The source is not materialized immediately. Instead, its names and + child graph are scanned once and stored as an import declaration. The + source may be renamed on entry to avoid collisions with existing + declarations or other imported sources. + + Returns: + Mapping of `{source_name: visible_name}` for imported names that + were renamed while being added. + """ + self._assert_editable() + + view = source if isinstance(source, ILibraryView) else LibraryView(source) + source_order = tuple(view.source_order()) + child_graph = view.child_graph(dangling='include') + + source_to_visible: dict[str, str] = {} + visible_to_source: dict[str, str] = {} + rename_map: dict[str, str] = {} + new_names: list[str] = [] + + for name in source_order: + visible = name + if visible in self._names or visible in visible_to_source: + if rename_theirs is None: + raise LibraryError(f'Conflicting name while adding source: {name!r}') + visible = rename_theirs(self, name) + if visible in self._names or visible in visible_to_source: + raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') + rename_map[name] = visible + source_to_visible[name] = visible + visible_to_source[visible] = name + new_names.append(visible) + + self._sources.append(_SourceDeclaration( + library=view, + source_to_visible=dict(source_to_visible), + visible_to_source=dict(visible_to_source), + child_graph={name: set(children) for name, children in child_graph.items()}, + order=tuple(source_to_visible[name] for name in source_order), + )) + for visible in new_names: + self._names.add(visible) + self._order.append(visible) + return rename_map + + def validate( + self, + names: Sequence[str] | None = None, + *, + allow_dangling: bool = False, + ) -> BuildReport: + """ + Run the full build logic and return a `BuildReport` without producing output. + + This is a dry run over the same dependency resolution and recipe + execution path used by `build()`. Any generated library is discarded + after validation completes. + """ + report, _output = self._run_build(names=names, output='overlay', allow_dangling=allow_dangling, persist_output=False) + self.last_build_report = report + return report + + def build( + self, + *, + output: Literal['overlay', 'library'] = 'overlay', + allow_dangling: bool = False, + ) -> 'BuiltLibrary | ILibrary': + """ + Materialize declarations and return a usable output library. + + Args: + output: `'overlay'` preserves imported source-backed cells where + possible, while `'library'` eagerly materializes the full + result. + allow_dangling: If `False`, fail the build when the completed + library still contains dangling references. + """ + self._assert_editable() + report, built_output = self._run_build(names=None, output=output, allow_dangling=allow_dangling, persist_output=True) + self._frozen = True + self.last_build_report = report + return built_output + + def _run_build( + self, + *, + names: Sequence[str] | None, + output: Literal['overlay', 'library'], + allow_dangling: bool, + persist_output: bool, + ) -> tuple[BuildReport, BuiltLibrary | ILibrary | None]: + roots = tuple(dict.fromkeys(names if names is not None else self._declarations.keys())) + unknown = [name for name in roots if name not in self._names] + if unknown: + raise BuildError(f'Unknown build roots requested: {unknown}') + + session = _BuildSessionLibrary(self) + sessions = dict(_ACTIVE_BUILD_SESSIONS.get() or {}) + sessions[id(self)] = session + token = _ACTIVE_BUILD_SESSIONS.set(sessions) + try: + session.materialize_many(roots) + if not allow_dangling: + session.child_graph(dangling='error') + if output == 'library': + built_output = session.to_library() if persist_output else None + elif persist_output: + built_output = session.to_overlay() + else: + built_output = None + finally: + _ACTIVE_BUILD_SESSIONS.reset(token) + + report = session.build_report(roots) + if built_output is not None: + built_output.build_report = report + return report, built_output + + +class _BuildSessionLibrary(ILibrary): + """ + Internal overlay-backed library used while a `BuildLibrary` is executing. + + This object provides the mutable-library surface that recipes expect while + also tracking declared-cell dependencies, helper-cell provenance, and + imported source cells. It exists only for the duration of a validation or + build run. + """ + + def __init__(self, builder: BuildLibrary) -> None: + from .file.gdsii_lazy_core import BuiltOverlayLibrary, _SourceEntry, _SourceLayer # noqa: PLC0415 + + self._builder = builder + self._overlay = BuiltOverlayLibrary() + self._source_entry_type = _SourceEntry + self._source_layer_type = _SourceLayer + self._states: dict[str, Literal['unbuilt', 'building', 'built']] = { + name: 'unbuilt' for name in builder._declarations + } + self._declared_stack: list[str] = [] + self._emission_stack: list[str] = [] + self._emission_via_stack: list[emitted_via_t] = [] + self._names = set(builder._names) + self._order = list(builder._order) + self._provenance: dict[str, CellProvenance] = {} + self._owned_cells: defaultdict[str, list[str]] = defaultdict(list) + self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set) + self._install_sources() + + def _install_sources(self) -> None: + for spec in self._builder._sources: + layer = self._source_layer_type( + library=spec.library, + source_to_visible=dict(spec.source_to_visible), + visible_to_source=dict(spec.visible_to_source), + child_graph={name: set(children) for name, children in spec.child_graph.items()}, + order=list(spec.order), + ) + layer_index = len(self._overlay._layers) + self._overlay._layers.append(layer) + source_info = getattr(spec.library, 'library_info', None) + source_meta = dict(source_info) if isinstance(source_info, dict) else None + + for source_name, visible_name in spec.source_to_visible.items(): + self._overlay._entries[visible_name] = self._source_entry_type( + layer_index=layer_index, + source_name=source_name, + ) + if visible_name not in self._overlay._order: + self._overlay._order.append(visible_name) + self._provenance[visible_name] = CellProvenance( + final_name=visible_name, + requested_name=source_name, + kind='source', + owner_declared_name=None, + emitted_via='source_import', + build_chain=(), + renamed_from=source_name if visible_name != source_name else None, + source_name=source_name, + source_metadata=source_meta, + ) + + def __iter__(self) -> Iterator[str]: + return (name for name in self._order if name in self._names) + + def __len__(self) -> int: + return len(self._names) + + def __contains__(self, key: object) -> bool: + return key in self._names or key in self._overlay + + def _touch_name(self, key: str) -> None: + if key not in self._names: + self._names.add(key) + self._order.append(key) + + def _current_declared(self) -> str | None: + if not self._declared_stack: + return None + return self._declared_stack[-1] + + def _record_dependency(self, target: str) -> None: + current = self._current_declared() + if current is None or current == target or target not in self._builder._declarations: + return + self._dependency_graph[current].add(target) + + def _guard_mutable_output_name(self, key: str, *, operation: str) -> None: + if key in self._builder._declarations: + raise BuildError(f'Cannot {operation} declared build cell "{key}" during an active build session.') + + provenance = self._provenance.get(key) + if provenance is not None and provenance.kind == 'source': + raise BuildError(f'Cannot {operation} imported source cell "{key}" during an active build session.') + + def _remove_owned_cell(self, owner: str | None, name: str) -> None: + if owner is None or owner not in self._owned_cells: + return + cells = self._owned_cells[owner] + self._owned_cells[owner] = [cell for cell in cells if cell != name] + if not self._owned_cells[owner]: + del self._owned_cells[owner] + + def rename( + self, + old_name: str, + new_name: str, + move_references: bool = False, + ) -> Self: + if old_name == new_name: + return self + if old_name not in self._overlay: + if old_name in self._builder._declarations: + self._guard_mutable_output_name(old_name, operation='rename') + raise LibraryError(f'"{old_name}" does not exist in the library.') + + self._guard_mutable_output_name(old_name, operation='rename') + if new_name in self._names: + raise LibraryError(f'"{new_name}" already exists in the library.') + + self._overlay.rename(old_name, new_name, move_references=move_references) + self._names.discard(old_name) + self._names.add(new_name) + if old_name in self._order: + idx = self._order.index(old_name) + self._order[idx] = new_name + + provenance = self._provenance.pop(old_name) + requested_name = provenance.requested_name + self._provenance[new_name] = replace( + provenance, + final_name=new_name, + renamed_from=requested_name if new_name != requested_name else None, + ) + + owner = provenance.owner_declared_name + if owner is not None and owner in self._owned_cells: + self._owned_cells[owner] = [ + new_name if cell_name == old_name else cell_name + for cell_name in self._owned_cells[owner] + ] + return self + + def __getitem__(self, key: str) -> 'Pattern': + if key in self._builder._declarations: + self._record_dependency(key) + self._ensure_declared(key) + return self._overlay[key] + + def __setitem__( + self, + key: str, + value: 'Pattern | Callable[[], Pattern]', + ) -> None: + if key in self._overlay: + raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') + current = self._current_declared() + if key in self._builder._declarations and key != current: + raise LibraryError(f'"{key}" is reserved for a declared cell and cannot be used as a helper name.') + + pattern = value() if callable(value) else value + self._overlay[key] = pattern + self._touch_name(key) + + kind: Literal['declared', 'helper'] + via = self._emission_via_stack[-1] if self._emission_via_stack else 'helper_write' + if current is not None and key == current: + kind = 'declared' + via = 'declaration' + else: + kind = 'helper' + if not self._emission_via_stack: + via = 'helper_write' + + self._emission_stack.append(key) + try: + self._record_provenance( + final_name=key, + requested_name=key, + kind=kind, + owner_declared_name=current if kind == 'helper' else key, + emitted_via=via, + build_chain=tuple(self._declared_stack), + renamed_from=None, + ) + finally: + self._emission_stack.pop() + + def __delitem__(self, key: str) -> None: + if key not in self._overlay: + if key in self._builder._declarations: + self._guard_mutable_output_name(key, operation='delete') + raise KeyError(key) + + self._guard_mutable_output_name(key, operation='delete') + provenance = self._provenance.get(key) + if key in self._overlay: + del self._overlay[key] + self._names.discard(key) + if key in self._order: + self._order.remove(key) + self._provenance.pop(key, None) + if provenance is not None: + self._remove_owned_cell(provenance.owner_declared_name, key) + + def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: + self[key_self] = copy.deepcopy(other[key_other]) + + def add( + self, + other: Mapping[str, 'Pattern'], + rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns, + mutate_other: bool = False, + ) -> dict[str, str]: + self._emission_via_stack.append('tree_merge') + try: + rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) + finally: + self._emission_via_stack.pop() + + current = self._current_declared() + for old_name, new_name in rename_map.items(): + if new_name in self._provenance: + self._provenance[new_name] = replace( + self._provenance[new_name], + requested_name=old_name, + renamed_from=old_name, + owner_declared_name=current if current is not None else self._provenance[new_name].owner_declared_name, + ) + return rename_map + + def _record_provenance( + self, + *, + final_name: str, + requested_name: str, + kind: Literal['declared', 'helper'], + owner_declared_name: str | None, + emitted_via: emitted_via_t, + build_chain: tuple[str, ...], + renamed_from: str | None, + ) -> None: + self._provenance[final_name] = CellProvenance( + final_name=final_name, + requested_name=requested_name, + kind=kind, + owner_declared_name=owner_declared_name, + emitted_via=emitted_via, + build_chain=build_chain, + renamed_from=renamed_from, + ) + if owner_declared_name is not None and final_name not in self._owned_cells[owner_declared_name]: + self._owned_cells[owner_declared_name].append(final_name) + + def _wrap_error(self, name: str, exc: Exception) -> BuildError: + helper = self._emission_stack[-1] if self._emission_stack else None + chain = tuple(self._declared_stack) + msg = [f'Failed while building declared cell "{name}"'] + if helper is not None and helper != name: + msg.append(f'while materializing helper/output "{helper}"') + if chain: + msg.append(f'Dependency chain: {" -> ".join(chain)}') + msg.append(f'Cause: {exc}') + return BuildError('\n'.join(msg)) + + def _ensure_named(self, name: str) -> None: + if name in self._builder._declarations: + self._record_dependency(name) + self._ensure_declared(name) + return + if name in self._overlay: + return + raise BuildError(f'Missing dependency "{name}"') + + def _ensure_declared(self, name: str) -> None: + from .pattern import Pattern # noqa: PLC0415 + + state = self._states[name] + if state == 'built': + return + if state == 'building': + chain = ' -> '.join(self._declared_stack + [name]) + raise BuildError(f'Cycle detected while building declared cells: {chain}') + + declaration = self._builder._declarations[name] + self._states[name] = 'building' + self._declared_stack.append(name) + try: + if isinstance(declaration, _PatternDeclaration): + pattern = declaration.pattern.deepcopy() + else: + for dep in declaration.recipe.explicit_dependencies: + self._ensure_named(dep) + pattern = declaration.recipe.func(*declaration.recipe.args, **declaration.recipe.kwargs) + if not isinstance(pattern, Pattern): + raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') + + if name in self._overlay: + if self._overlay[name] is not pattern: + raise BuildError( + f'Recipe for "{name}" wrote a different pattern into the session under its own name.' + ) + else: + self[name] = pattern + self._states[name] = 'built' + except Exception as exc: + self._states[name] = 'unbuilt' + raise self._wrap_error(name, exc) from exc + finally: + self._declared_stack.pop() + + def materialize_many(self, names: Sequence[str]) -> None: + for name in dict.fromkeys(names): + self._ensure_named(name) + + def source_order(self) -> tuple[str, ...]: + return self._overlay.source_order() + + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + return self._overlay.child_graph(dangling=dangling) + + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + return self._overlay.parent_graph(dangling=dangling) + + def build_report(self, requested_roots: Sequence[str]) -> BuildReport: + dependency_graph = { + name: frozenset(self._dependency_graph.get(name, set())) + for name in self._builder._declarations + if name in self._dependency_graph or name in requested_roots + } + owned_cells = { + name: tuple(cells) + for name, cells in self._owned_cells.items() + } + return BuildReport( + requested_roots=tuple(dict.fromkeys(requested_roots)), + provenance=dict(self._provenance), + owned_cells=owned_cells, + dependency_graph=dependency_graph, + ) + + def to_overlay(self) -> ILibrary: + return self._overlay + + def to_library(self) -> BuiltLibrary: + mapping = {name: self._overlay[name] for name in self._overlay.source_order()} + return BuiltLibrary(mapping) + + class LazyLibrary(ILibrary): """ This class is usually used to create a library of Patterns by mapping names to diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py new file mode 100644 index 0000000..ad43aba --- /dev/null +++ b/masque/test/test_build_library.py @@ -0,0 +1,315 @@ +import pytest + +from ..builder import Pather +from ..error import BuildError +from ..library import BuildLibrary, BuiltLibrary, Library, cell +from ..pattern import Pattern +from ..ports import Port + + +def test_build_library_traces_declared_dependencies_out_of_order() -> None: + builder = BuildLibrary() + + def make_parent(lib: BuildLibrary) -> Pattern: + pat = Pattern() + pat.ref("child") + assert lib.abstract("child").name == "child" + return pat + + builder.cells.parent = cell(make_parent)(builder) + builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) + + built = builder.build() + + assert "parent" in built + assert "child" in built + assert built.build_report.dependency_graph["parent"] == frozenset({"child"}) + assert built.build_report.provenance["parent"].kind == "declared" + + +def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + tree = Library({"_helper": Pattern()}) + name_a = lib << tree + name_b = lib << tree + top = Pattern() + top.ref(name_a) + top.ref(name_b) + return top + + builder.cells.top = cell(make_top)(builder) + built = builder.build() + report = built.build_report + + helpers = [ + prov for prov in report.provenance.values() + if prov.owner_declared_name == "top" and prov.kind == "helper" + ] + + assert "top" in report.owned_cells["top"] + assert len(helpers) == 2 + assert all(prov.emitted_via == "tree_merge" for prov in helpers) + assert any(prov.renamed_from == "_helper" for prov in helpers) + + +def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + + with pytest.raises(BuildError, match="validate\\(\\) or build\\(\\)"): + _ = builder["leaf"] + + with pytest.raises(BuildError, match="write-only"): + _ = builder.cells.leaf + + built = builder.build(output="library") + + assert isinstance(built, BuiltLibrary) + assert built.build_report.requested_roots == ("leaf",) + + with pytest.raises(BuildError, match="frozen"): + builder["later"] = Pattern() + with pytest.raises(BuildError, match="frozen"): + builder.build() + + +def test_build_library_validate_is_retryable_after_failure() -> None: + builder = BuildLibrary() + + def make_parent(lib: BuildLibrary) -> Pattern: + pat = Pattern() + pat.ref("child") + lib.abstract("child") + return pat + + builder.cells.parent = cell(make_parent)(builder) + + with pytest.raises(BuildError, match='Failed while building declared cell "parent"'): + builder.validate() + + builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) + report = builder.validate() + + assert report.dependency_graph["parent"] == frozenset({"child"}) + + +def test_build_library_check_on_register_rolls_back_failed_declarations() -> None: + builder = BuildLibrary(check_on_register=True) + + def make_parent(lib: BuildLibrary) -> Pattern: + pat = Pattern() + pat.ref("child") + lib.abstract("child") + return pat + + with pytest.raises(BuildError, match='Failed while building declared cell "parent"'): + builder.cells.parent = cell(make_parent)(builder) + + assert "parent" not in builder + + +def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None: + builder = BuildLibrary() + builder["child"] = Pattern() + + def make_parent() -> Pattern: + pat = Pattern() + pat.ref("child") + return pat + + builder.cells.parent = cell(make_parent)().depends_on("child") + report = builder.validate(names=("parent",)) + + assert report.requested_roots == ("parent",) + assert report.dependency_graph["parent"] == frozenset({"child"}) + + +def test_build_library_validate_rejects_removed_output_argument() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + + with pytest.raises(TypeError): + builder.validate(output="library") # type: ignore[call-arg] + + +def test_build_library_allows_helper_writes_via_pather() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)}) + + def make_top(lib: BuildLibrary) -> Pattern: + helper = Pather(library=lib, ports="leaf", name="_route") + top = Pattern() + top.ref("_route") + top.ref("leaf") + top.ports.update(helper.pattern.ports) + return top + + builder.cells.top = cell(make_top)(builder) + built = builder.build() + + helper_prov = built.build_report.provenance["_route"] + assert helper_prov.kind == "helper" + assert helper_prov.owner_declared_name == "top" + + +def test_build_library_preserves_source_cells_and_records_source_provenance() -> None: + source = Library({"src": Pattern()}) + builder = BuildLibrary() + builder.add_source(source) + builder.cells.top = cell(lambda: Pattern())() + + built = builder.build() + + assert "src" in built + assert built.build_report.provenance["src"].kind == "source" + assert built.build_report.provenance["src"].emitted_via == "source_import" + + +def test_build_library_can_rename_imported_source_cells_during_authoring() -> None: + source = Library() + source["child"] = Pattern() + parent = Pattern() + parent.ref("child") + source["parent"] = parent + + builder = BuildLibrary() + builder.add_source(source) + builder.rename("child", "renamed_child") + + built = builder.build() + + assert "renamed_child" in built + assert "child" not in built + assert "renamed_child" in built["parent"].refs + assert built.build_report.provenance["renamed_child"].source_name == "child" + + +def test_build_library_rejects_move_references_for_source_rename() -> None: + builder = BuildLibrary() + builder.add_source(Library({"src": Pattern()})) + + with pytest.raises(BuildError, match="move_references=True"): + builder.rename("src", "renamed_src", move_references=True) + + +def test_build_library_rejects_renaming_declared_cells_during_authoring() -> None: + builder = BuildLibrary() + builder["declared"] = Pattern() + + with pytest.raises(BuildError, match='Cannot rename declared build cell "declared"'): + builder.rename("declared", "renamed_declared") + + +def test_build_library_helper_rename_updates_provenance_and_owned_cells() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + lib["_helper"] = Pattern() + lib.rename("_helper", "final_helper") + top = Pattern() + top.ref("final_helper") + return top + + builder.cells.top = cell(make_top)(builder) + built = builder.build() + report = built.build_report + + assert "final_helper" in built + assert "_helper" not in built + assert "final_helper" in report.owned_cells["top"] + assert "_helper" not in report.owned_cells["top"] + prov = report.provenance["final_helper"] + assert prov.kind == "helper" + assert prov.requested_name == "_helper" + assert prov.renamed_from == "_helper" + assert prov.final_name == "final_helper" + + +def test_build_library_helper_delete_removes_provenance_and_ownership() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + lib["_helper"] = Pattern() + del lib["_helper"] + return Pattern() + + builder.cells.top = cell(make_top)(builder) + built = builder.build() + report = built.build_report + + assert "_helper" not in built + assert "_helper" not in report.provenance + assert report.owned_cells["top"] == ("top",) + + +def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + tree = Library({"_helper": Pattern()}) + _ = lib << tree + renamed = lib << tree + lib.rename(renamed, "final_helper") + top = Pattern() + top.ref("_helper") + top.ref("final_helper") + return top + + builder.cells.top = cell(make_top)(builder) + built = builder.build() + report = built.build_report + + assert "final_helper" in built + prov = report.provenance["final_helper"] + assert prov.requested_name == "_helper" + assert prov.renamed_from == "_helper" + + +def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None: + declared = BuildLibrary() + declared["leaf"] = Pattern() + + def rename_declared(lib: BuildLibrary) -> Pattern: + lib.rename("leaf", "renamed_leaf") + return Pattern() + + declared.cells.top = cell(rename_declared)(declared) + with pytest.raises(BuildError, match='Cannot rename declared build cell "leaf"'): + declared.build() + + source = BuildLibrary() + source.add_source(Library({"src": Pattern()})) + + def rename_source(lib: BuildLibrary) -> Pattern: + lib.rename("src", "renamed_src") + return Pattern() + + source.cells.top = cell(rename_source)(source) + with pytest.raises(BuildError, match='Cannot rename imported source cell "src"'): + source.build() + + +def test_build_library_rejects_deleting_declared_or_source_cells_during_build() -> None: + declared = BuildLibrary() + declared["leaf"] = Pattern() + + def delete_declared(lib: BuildLibrary) -> Pattern: + del lib["leaf"] + return Pattern() + + declared.cells.top = cell(delete_declared)(declared) + with pytest.raises(BuildError, match='Cannot delete declared build cell "leaf"'): + declared.build() + + source = BuildLibrary() + source.add_source(Library({"src": Pattern()})) + + def delete_source(lib: BuildLibrary) -> Pattern: + del lib["src"] + return Pattern() + + source.cells.top = cell(delete_source)(source) + with pytest.raises(BuildError, match='Cannot delete imported source cell "src"'): + source.build()