From 1f4185107b1d678acb0615d0eca341e7dab0977b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 19 Jun 2026 19:43:21 -0700 Subject: [PATCH 1/8] [BuildLibrary] whitespace --- masque/library.py | 146 +++++++++++++++++++++++----------------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/masque/library.py b/masque/library.py index 942113a..07d000e 100644 --- a/masque/library.py +++ b/masque/library.py @@ -598,12 +598,12 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): for ref_transform in ref_transforms: self.dfs( - pattern=self[target], - visit_before=visit_before, - visit_after=visit_after, - hierarchy=hierarchy + (target,), - transform=ref_transform, - memo=memo, + pattern = self[target], + visit_before = visit_before, + visit_after = visit_after, + hierarchy = hierarchy + (target,), + transform = ref_transform, + memo = memo, ) if visit_after is not None: @@ -1622,7 +1622,7 @@ class BuildLibrary(ILibrary): 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: @@ -1742,7 +1742,7 @@ class BuildLibrary(ILibrary): 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: @@ -1751,17 +1751,17 @@ class BuildLibrary(ILibrary): 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] @@ -1776,10 +1776,10 @@ class BuildLibrary(ILibrary): self._sources[source_index] = replace( spec, - source_to_visible=source_to_visible, - visible_to_source=visible_to_source, - order=tuple(order), - ) + 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 @@ -1838,12 +1838,12 @@ class BuildLibrary(ILibrary): 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), - )) + 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) @@ -1943,7 +1943,7 @@ class _BuildSessionLibrary(ILibrary): 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] = [] @@ -1957,12 +1957,12 @@ class _BuildSessionLibrary(ILibrary): 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), - ) + 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) @@ -1970,22 +1970,22 @@ class _BuildSessionLibrary(ILibrary): 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, - ) + 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, - ) + 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) @@ -2056,16 +2056,16 @@ class _BuildSessionLibrary(ILibrary): 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, - ) + 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': @@ -2102,14 +2102,14 @@ class _BuildSessionLibrary(ILibrary): 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, - ) + 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() @@ -2150,10 +2150,10 @@ class _BuildSessionLibrary(ILibrary): 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, - ) + 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( @@ -2168,14 +2168,14 @@ class _BuildSessionLibrary(ILibrary): 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, - ) + 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) @@ -2226,7 +2226,7 @@ class _BuildSessionLibrary(ILibrary): 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' @@ -2260,17 +2260,17 @@ class _BuildSessionLibrary(ILibrary): 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, - ) + 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 From 717c31abf40f5d0d6a4abe5d63d51f652ffff54a Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 19 Jun 2026 21:04:18 -0700 Subject: [PATCH 2/8] [BuildLibrary] eliminate BuiltLibrary and BuiltOverlayLibrary --- examples/tutorial/README.md | 2 +- examples/tutorial/library.py | 3 +- masque/__init__.py | 1 - masque/file/gdsii_lazy_core.py | 14 --- masque/library.py | 186 +++++++----------------------- masque/test/test_build_library.py | 80 ++++++++----- 6 files changed, 95 insertions(+), 191 deletions(-) diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index f5c4392..dfdb1ff 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -22,7 +22,7 @@ Contents - [library](library.py) * 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 + * Call `build()` to produce a normal library and report for downstream `Pather` usage and writing * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` - [pather](pather.py) * Use `Pather` to route individual wires and wire bundles diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index f4eb3f0..d0ca60e 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -81,8 +81,9 @@ def main() -> None: # Build the declaration set into a normal library. # - built = builder.build() + built, report = builder.build() print('Built library contains:\n' + pformat(list(built.keys()))) + print('Build dependency graph:\n' + pformat(report.dependency_graph)) # # Continue designing against the built library. diff --git a/masque/__init__.py b/masque/__init__.py index b6dfb44..e240ae3 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -63,7 +63,6 @@ from .library import ( ILibrary as ILibrary, LibraryView as LibraryView, Library as Library, - BuiltLibrary as BuiltLibrary, BuildLibrary as BuildLibrary, BuildReport as BuildReport, CellProvenance as CellProvenance, diff --git a/masque/file/gdsii_lazy_core.py b/masque/file/gdsii_lazy_core.py index d730348..f5dc994 100644 --- a/masque/file/gdsii_lazy_core.py +++ b/masque/file/gdsii_lazy_core.py @@ -549,20 +549,6 @@ 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 07d000e..59a1655 100644 --- a/masque/library.py +++ b/masque/library.py @@ -69,9 +69,6 @@ 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: @@ -83,29 +80,22 @@ class CellProvenance: 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) @@ -121,15 +111,11 @@ class BuildReport: 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]] @@ -1467,25 +1453,6 @@ 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. @@ -1516,18 +1483,6 @@ class _BuildRecipe: 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: """ @@ -1596,16 +1551,15 @@ class BuildLibrary(ILibrary): 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`. + are resolved. `build()` then freezes the builder on success and returns the + built library plus a `BuildReport`. """ 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._declarations: dict[str, 'Pattern | _BuildRecipe'] = {} self._sources: list[_SourceDeclaration] = [] self._names: set[str] = set() self._order: list[str] = [] @@ -1664,13 +1618,12 @@ class BuildLibrary(ILibrary): 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) + declaration = value else: if callable(value): raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.') - declaration = _PatternDeclaration(value) + declaration = value self._declarations[key] = declaration self._names.add(key) @@ -1813,6 +1766,8 @@ class BuildLibrary(ILibrary): Mapping of `{source_name: visible_name}` for imported names that were renamed while being added. """ + if self._active_session() is not None: + raise BuildError('BuildLibrary.add_source() is only available while authoring, not during validate() or build().') self._assert_editable() view = source if isinstance(source, ILibraryView) else LibraryView(source) @@ -1862,8 +1817,7 @@ class BuildLibrary(ILibrary): 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 + _session, report = self._run_build(names=names, allow_dangling=allow_dangling) return report def build( @@ -1871,9 +1825,9 @@ class BuildLibrary(ILibrary): *, output: Literal['overlay', 'library'] = 'overlay', allow_dangling: bool = False, - ) -> 'BuiltLibrary | ILibrary': + ) -> tuple[ILibrary, BuildReport]: """ - Materialize declarations and return a usable output library. + Materialize declarations and return a usable output library plus report. Args: output: `'overlay'` preserves imported source-backed cells where @@ -1882,20 +1836,23 @@ class BuildLibrary(ILibrary): allow_dangling: If `False`, fail the build when the completed library still contains dangling references. """ + if output not in ('overlay', 'library'): + raise ValueError(f'Unknown build output mode: {output!r}') self._assert_editable() - report, built_output = self._run_build(names=None, output=output, allow_dangling=allow_dangling, persist_output=True) + session, report = self._run_build(names=None, allow_dangling=allow_dangling) + if output == 'library': + built_output = session.to_library() + else: + built_output = session.to_overlay() self._frozen = True - self.last_build_report = report - return built_output + return built_output, report def _run_build( self, *, names: Sequence[str] | None, - output: Literal['overlay', 'library'], allow_dangling: bool, - persist_output: bool, - ) -> tuple[BuildReport, BuiltLibrary | ILibrary | None]: + ) -> tuple['_BuildSessionLibrary', BuildReport]: 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: @@ -1909,19 +1866,11 @@ class BuildLibrary(ILibrary): 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 + return session, report class _BuildSessionLibrary(ILibrary): @@ -1935,22 +1884,19 @@ class _BuildSessionLibrary(ILibrary): """ def __init__(self, builder: BuildLibrary) -> None: - from .file.gdsii_lazy_core import BuiltOverlayLibrary, _SourceEntry, _SourceLayer # noqa: PLC0415 + from .file.gdsii_lazy_core import OverlayLibrary, _SourceEntry, _SourceLayer # noqa: PLC0415 self._builder = builder - self._overlay = BuiltOverlayLibrary() + self._overlay = OverlayLibrary() 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() @@ -1962,11 +1908,9 @@ class _BuildSessionLibrary(ILibrary): 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( @@ -1976,15 +1920,12 @@ class _BuildSessionLibrary(ILibrary): 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]: @@ -2020,14 +1961,6 @@ class _BuildSessionLibrary(ILibrary): 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, @@ -2056,16 +1989,8 @@ class _BuildSessionLibrary(ILibrary): 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': @@ -2090,28 +2015,19 @@ class _BuildSessionLibrary(ILibrary): 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() + self._record_provenance( + name = key, + requested_name = key, + kind = kind, + owner_declared_name = current if kind == 'helper' else key, + build_chain = tuple(self._declared_stack), + renamed_from = None, + ) def __delitem__(self, key: str) -> None: if key not in self._overlay: @@ -2120,15 +2036,12 @@ class _BuildSessionLibrary(ILibrary): 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]) @@ -2139,11 +2052,7 @@ class _BuildSessionLibrary(ILibrary): 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() + rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) current = self._current_declared() for old_name, new_name in rename_map.items(): @@ -2159,32 +2068,24 @@ class _BuildSessionLibrary(ILibrary): def _record_provenance( self, *, - final_name: str, + 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, + self._provenance[name] = CellProvenance( 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}') @@ -2213,14 +2114,14 @@ class _BuildSessionLibrary(ILibrary): 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: + if isinstance(declaration, _BuildRecipe): + for dep in declaration.explicit_dependencies: self._ensure_named(dep) - pattern = declaration.recipe.func(*declaration.recipe.args, **declaration.recipe.kwargs) + pattern = declaration.func(*declaration.args, **declaration.kwargs) if not isinstance(pattern, Pattern): raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') + else: + pattern = declaration.deepcopy() if name in self._overlay: if self._overlay[name] is not pattern: @@ -2261,23 +2162,18 @@ class _BuildSessionLibrary(ILibrary): 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: + def to_library(self) -> Library: mapping = {name: self._overlay[name] for name in self._overlay.source_order()} - return BuiltLibrary(mapping) + return Library(mapping) class LazyLibrary(ILibrary): diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index ad43aba..4bd645f 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -2,11 +2,18 @@ import pytest from ..builder import Pather from ..error import BuildError -from ..library import BuildLibrary, BuiltLibrary, Library, cell +from ..library import BuildLibrary, Library, cell from ..pattern import Pattern from ..ports import Port +def _owned_by(report, owner: str) -> set[str]: + return { + name for name, prov in report.provenance.items() + if prov.owner_declared_name == owner + } + + def test_build_library_traces_declared_dependencies_out_of_order() -> None: builder = BuildLibrary() @@ -19,12 +26,12 @@ def test_build_library_traces_declared_dependencies_out_of_order() -> None: builder.cells.parent = cell(make_parent)(builder) builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) - built = builder.build() + built, report = 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" + assert report.dependency_graph["parent"] == frozenset({"child"}) + assert report.provenance["parent"].kind == "declared" def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None: @@ -40,17 +47,15 @@ def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None return top builder.cells.top = cell(make_top)(builder) - built = builder.build() - report = built.build_report + _built, report = builder.build() 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 "top" in _owned_by(report, "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) @@ -64,10 +69,10 @@ def test_build_library_requires_build_session_for_reads_and_freezes_after_build( with pytest.raises(BuildError, match="write-only"): _ = builder.cells.leaf - built = builder.build(output="library") + built, report = builder.build(output="library") - assert isinstance(built, BuiltLibrary) - assert built.build_report.requested_roots == ("leaf",) + assert isinstance(built, Library) + assert report.requested_roots == ("leaf",) with pytest.raises(BuildError, match="frozen"): builder["later"] = Pattern() @@ -134,6 +139,14 @@ def test_build_library_validate_rejects_removed_output_argument() -> None: builder.validate(output="library") # type: ignore[call-arg] +def test_build_library_rejects_unknown_build_output_mode() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + + with pytest.raises(ValueError, match="Unknown build output mode"): + builder.build(output="bad") # type: ignore[arg-type] + + def test_build_library_allows_helper_writes_via_pather() -> None: builder = BuildLibrary() builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)}) @@ -147,9 +160,9 @@ def test_build_library_allows_helper_writes_via_pather() -> None: return top builder.cells.top = cell(make_top)(builder) - built = builder.build() + _built, report = builder.build() - helper_prov = built.build_report.provenance["_route"] + helper_prov = report.provenance["_route"] assert helper_prov.kind == "helper" assert helper_prov.owner_declared_name == "top" @@ -160,11 +173,23 @@ def test_build_library_preserves_source_cells_and_records_source_provenance() -> builder.add_source(source) builder.cells.top = cell(lambda: Pattern())() - built = builder.build() + built, report = builder.build() assert "src" in built - assert built.build_report.provenance["src"].kind == "source" - assert built.build_report.provenance["src"].emitted_via == "source_import" + assert report.provenance["src"].kind == "source" + + +def test_build_library_rejects_add_source_during_build() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + lib.add_source(Library({"src": Pattern()})) + return Pattern() + + builder.cells.top = cell(make_top)(builder) + + with pytest.raises(BuildError, match="add_source"): + builder.build() def test_build_library_can_rename_imported_source_cells_during_authoring() -> None: @@ -178,12 +203,12 @@ def test_build_library_can_rename_imported_source_cells_during_authoring() -> No builder.add_source(source) builder.rename("child", "renamed_child") - built = builder.build() + built, report = 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" + assert report.provenance["renamed_child"].source_name == "child" def test_build_library_rejects_move_references_for_source_rename() -> None: @@ -202,7 +227,7 @@ def test_build_library_rejects_renaming_declared_cells_during_authoring() -> Non builder.rename("declared", "renamed_declared") -def test_build_library_helper_rename_updates_provenance_and_owned_cells() -> None: +def test_build_library_helper_rename_updates_provenance_owner() -> None: builder = BuildLibrary() def make_top(lib: BuildLibrary) -> Pattern: @@ -213,18 +238,17 @@ def test_build_library_helper_rename_updates_provenance_and_owned_cells() -> Non return top builder.cells.top = cell(make_top)(builder) - built = builder.build() - report = built.build_report + built, report = builder.build() 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"] + owned = _owned_by(report, "top") + assert "final_helper" in owned + assert "_helper" not in owned 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: @@ -236,12 +260,11 @@ def test_build_library_helper_delete_removes_provenance_and_ownership() -> None: return Pattern() builder.cells.top = cell(make_top)(builder) - built = builder.build() - report = built.build_report + built, report = builder.build() assert "_helper" not in built assert "_helper" not in report.provenance - assert report.owned_cells["top"] == ("top",) + assert _owned_by(report, "top") == {"top"} def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None: @@ -258,8 +281,7 @@ def test_build_library_helper_rename_after_auto_rename_preserves_requested_name( return top builder.cells.top = cell(make_top)(builder) - built = builder.build() - report = built.build_report + built, report = builder.build() assert "final_helper" in built prov = report.provenance["final_helper"] From 58ab4f6c094d1595e787b4289e7c3a787f7af9c5 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 19 Jun 2026 21:34:16 -0700 Subject: [PATCH 3/8] [OverlayLibrary] enable renaming all cells during add_source with rename_when='always' --- masque/file/gdsii_lazy_core.py | 32 ++++++++++++++++++++++++++++---- masque/test/test_gdsii_lazy.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/masque/file/gdsii_lazy_core.py b/masque/file/gdsii_lazy_core.py index f5dc994..bf4cde0 100644 --- a/masque/file/gdsii_lazy_core.py +++ b/masque/file/gdsii_lazy_core.py @@ -17,7 +17,7 @@ Both the classic and Arrow-backed lazy GDS readers rely on these helpers. from __future__ import annotations from dataclasses import dataclass -from typing import IO, Any, cast +from typing import IO, Any, Literal, cast from collections import defaultdict from collections.abc import Callable, Iterator, Mapping, Sequence import copy @@ -303,7 +303,23 @@ class OverlayLibrary(ILibrary): source: Mapping[str, Pattern] | ILibraryView, *, rename_theirs: Callable[[ILibraryView, str], str] | None = None, + rename_when: Literal['conflict', 'always'] = 'conflict', ) -> dict[str, str]: + """ + Add a source-backed library layer. + + Args: + rename_theirs: Function used to choose visible names for imported + source cells. + rename_when: If `'conflict'`, only conflicting names are renamed. + If `'always'`, every imported source name is passed through + `rename_theirs`. + """ + if rename_when not in ('conflict', 'always'): + raise ValueError(f'Unknown source rename mode: {rename_when!r}') + if rename_when == 'always' and rename_theirs is None: + raise TypeError('rename_theirs is required when rename_when="always"') + view = _coerce_library_view(source) source_order = list(view.source_order()) child_graph = view.child_graph(dangling='include') @@ -314,12 +330,20 @@ class OverlayLibrary(ILibrary): for name in source_order: visible = name - if visible in self._entries or visible in visible_to_source: + renamed = False + if rename_when == 'always': + visible = cast('Callable[[ILibraryView, str], str]', rename_theirs)(self, name) + renamed = True + elif visible in self._entries 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._entries or visible in visible_to_source: - raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') + renamed = True + if visible in self._entries or visible in visible_to_source: + if not renamed: + raise LibraryError(f'Conflicting name while adding source: {name!r}') + raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') + if visible != name: rename_map[name] = visible source_to_visible[name] = visible visible_to_source[visible] = name diff --git a/masque/test/test_gdsii_lazy.py b/masque/test/test_gdsii_lazy.py index 6855783..0134d6e 100644 --- a/masque/test/test_gdsii_lazy.py +++ b/masque/test/test_gdsii_lazy.py @@ -1,6 +1,7 @@ from pathlib import Path import numpy +import pytest from numpy.testing import assert_allclose from ..file import gdsii, gdsii_lazy @@ -82,6 +83,35 @@ def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: P assert set(abstract.ports) == {'A'} +def test_gdsii_lazy_overlay_add_source_can_rename_every_source_cell() -> None: + src = _make_lazy_port_library() + overlay = gdsii_lazy.OverlayLibrary() + + rename_map = overlay.add_source( + src, + rename_theirs=lambda _lib, name: f'mapped_{name}', + rename_when='always', + ) + + assert rename_map == { + 'leaf': 'mapped_leaf', + 'child': 'mapped_child', + 'top': 'mapped_top', + } + assert tuple(overlay.keys()) == ('mapped_leaf', 'mapped_child', 'mapped_top') + assert 'mapped_leaf' in overlay['mapped_child'].refs + + +def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None: + src = _make_lazy_port_library() + + with pytest.raises(TypeError, match='rename_theirs'): + gdsii_lazy.OverlayLibrary().add_source(src, rename_when='always') + + with pytest.raises(ValueError, match='rename mode'): + gdsii_lazy.OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type] + + def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None: gds_file = tmp_path / 'lazy_roundtrip.gds' src = _make_lazy_port_library() From c410c4d381abc9f9921366e4d6f012b7ca9c7a0b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 19 Jun 2026 22:48:21 -0700 Subject: [PATCH 4/8] [BuildLibrary] misc simplifications --- masque/library.py | 132 ++++++++++++++++-------------- masque/test/test_build_library.py | 58 +++++++++---- 2 files changed, 115 insertions(+), 75 deletions(-) diff --git a/masque/library.py b/masque/library.py index 59a1655..8f8d842 100644 --- a/masque/library.py +++ b/masque/library.py @@ -1488,15 +1488,11 @@ 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. + The declaration stores visible-name remapping. 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: @@ -1555,8 +1551,7 @@ class BuildLibrary(ILibrary): built library plus a `BuildReport`. """ - def __init__(self, *, check_on_register: bool = False) -> None: - self.check_on_register = check_on_register + def __init__(self) -> None: self.cells = BuildCellsView(self) self._frozen = False self._declarations: dict[str, 'Pattern | _BuildRecipe'] = {} @@ -1629,15 +1624,6 @@ class BuildLibrary(ILibrary): 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: @@ -1706,10 +1692,16 @@ class BuildLibrary(ILibrary): '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, - ) + source_index: int | None = None + source_name: str | None = None + for idx, spec in enumerate(self._sources): + for candidate_source, candidate_visible in spec.source_to_visible.items(): + if candidate_visible == old_name: + source_index = idx + source_name = candidate_source + break + if source_index is not None: + break if source_index is None: raise BuildError( f'Cannot rename "{old_name}" during authoring because only imported source-backed ' @@ -1717,21 +1709,14 @@ class BuildLibrary(ILibrary): ) 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) + assert source_name is not None 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) @@ -1753,51 +1738,69 @@ class BuildLibrary(ILibrary): source: Mapping[str, 'Pattern'] | ILibraryView, *, rename_theirs: Callable[[ILibraryView, str], str] | None = None, + rename_when: Literal['conflict', 'always'] = 'conflict', ) -> 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 + The source is not materialized immediately. Its names are scanned once + to reserve visible builder names, then the source is read again when a + build session starts. The source's cell membership must not be + structurally mutated between `add_source()` and `build()`/`validate()`. + Source cells may be renamed on entry to avoid collisions with existing declarations or other imported sources. + Args: + rename_theirs: Function used to choose visible names for imported + source cells. + rename_when: If `'conflict'`, only conflicting names are renamed. + If `'always'`, every imported source name is passed through + `rename_theirs`. + Returns: Mapping of `{source_name: visible_name}` for imported names that were renamed while being added. """ + if rename_when not in ('conflict', 'always'): + raise ValueError(f'Unknown source rename mode: {rename_when!r}') + if rename_when == 'always' and rename_theirs is None: + raise TypeError('rename_theirs is required when rename_when="always"') if self._active_session() is not None: raise BuildError('BuildLibrary.add_source() is only available while authoring, not during validate() or build().') 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] = {} + visible_names: set[str] = set() 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: + renamed = False + if rename_when == 'always': + visible = cast('Callable[[ILibraryView, str], str]', rename_theirs)(self, name) + renamed = True + elif visible in self._names or visible in visible_names: 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}') + renamed = True + if visible in self._names or visible in visible_names: + if not renamed: + raise LibraryError(f'Conflicting name while adding source: {name!r}') + raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') + if visible != name: rename_map[name] = visible source_to_visible[name] = visible - visible_to_source[visible] = name + visible_names.add(visible) 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) @@ -1884,12 +1887,10 @@ class _BuildSessionLibrary(ILibrary): """ def __init__(self, builder: BuildLibrary) -> None: - from .file.gdsii_lazy_core import OverlayLibrary, _SourceEntry, _SourceLayer # noqa: PLC0415 + from .file.gdsii_lazy_core import OverlayLibrary # noqa: PLC0415 self._builder = builder self._overlay = OverlayLibrary() - 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 } @@ -1902,23 +1903,34 @@ class _BuildSessionLibrary(ILibrary): 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) - - 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, + source_order = spec.library.source_order() + expected_names = set(spec.source_to_visible) + actual_names = set(source_order) + if actual_names != expected_names: + added_names = sorted(actual_names - expected_names) + removed_names = sorted(expected_names - actual_names) + detail = [] + if added_names: + detail.append(f'added={added_names}') + if removed_names: + detail.append(f'removed={removed_names}') + raise BuildError( + 'Imported source library changed after add_source() was called ' + f'({", ".join(detail)}). ' + 'Do not structurally mutate source libraries between add_source() and build()/validate().' ) - if visible_name not in self._overlay._order: - self._overlay._order.append(visible_name) + + def rename_source(_lib: ILibraryView, name: str, *, mapping: Mapping[str, str] = spec.source_to_visible) -> str: + return mapping[name] + + self._overlay.add_source( + spec.library, + rename_theirs = rename_source, + rename_when = 'always', + ) + + for source_name in source_order: + visible_name = spec.source_to_visible[source_name] self._provenance[visible_name] = CellProvenance( requested_name = source_name, kind = 'source', diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index 4bd645f..2e7cb27 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -100,21 +100,6 @@ def test_build_library_validate_is_retryable_after_failure() -> None: 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() @@ -179,6 +164,49 @@ def test_build_library_preserves_source_cells_and_records_source_provenance() -> assert report.provenance["src"].kind == "source" +def test_build_library_add_source_can_rename_every_source_cell() -> None: + source = Library() + source["child"] = Pattern() + parent = Pattern() + parent.ref("child") + source["parent"] = parent + + builder = BuildLibrary() + rename_map = builder.add_source( + source, + rename_theirs=lambda _lib, name: f"mapped_{name}", + rename_when="always", + ) + built, report = builder.build() + + assert rename_map == { + "child": "mapped_child", + "parent": "mapped_parent", + } + assert "mapped_child" in built["mapped_parent"].refs + assert report.provenance["mapped_child"].source_name == "child" + + +def test_build_library_rejects_source_cells_added_after_add_source() -> None: + source = Library({"src": Pattern()}) + builder = BuildLibrary() + builder.add_source(source) + source["late"] = Pattern() + + with pytest.raises(BuildError, match="Do not structurally mutate source libraries"): + builder.build() + + +def test_build_library_rejects_source_cells_removed_after_add_source() -> None: + source = Library({"src": Pattern()}) + builder = BuildLibrary() + builder.add_source(source) + del source["src"] + + with pytest.raises(BuildError, match="Do not structurally mutate source libraries"): + builder.build() + + def test_build_library_rejects_add_source_during_build() -> None: builder = BuildLibrary() From 13d75dc0795020ee272c23f90864872bd0e09862 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 19 Jun 2026 23:11:23 -0700 Subject: [PATCH 5/8] [BuildLibrary / OverlayLibrary] further simplifications --- masque/file/gdsii_lazy_core.py | 38 ++----- masque/library.py | 171 +++++++++++++----------------- masque/test/test_build_library.py | 29 +++-- 3 files changed, 105 insertions(+), 133 deletions(-) diff --git a/masque/file/gdsii_lazy_core.py b/masque/file/gdsii_lazy_core.py index bf4cde0..9cb1c85 100644 --- a/masque/file/gdsii_lazy_core.py +++ b/masque/file/gdsii_lazy_core.py @@ -32,7 +32,7 @@ from numpy.typing import NDArray from . import gdsii from .utils import tmpfile from ..error import LibraryError -from ..library import ILibrary, ILibraryView, LibraryView, dangling_mode_t +from ..library import ILibrary, ILibraryView, LibraryView, _plan_source_names, dangling_mode_t from ..pattern import Pattern, map_targets from ..utils import apply_transforms from ..utils.ports2data import data_to_ports @@ -315,38 +315,18 @@ class OverlayLibrary(ILibrary): If `'always'`, every imported source name is passed through `rename_theirs`. """ - if rename_when not in ('conflict', 'always'): - raise ValueError(f'Unknown source rename mode: {rename_when!r}') - if rename_when == 'always' and rename_theirs is None: - raise TypeError('rename_theirs is required when rename_when="always"') - view = _coerce_library_view(source) source_order = list(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] = {} - - for name in source_order: - visible = name - renamed = False - if rename_when == 'always': - visible = cast('Callable[[ILibraryView, str], str]', rename_theirs)(self, name) - renamed = True - elif visible in self._entries 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) - renamed = True - if visible in self._entries or visible in visible_to_source: - if not renamed: - raise LibraryError(f'Conflicting name while adding source: {name!r}') - raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') - if visible != name: - rename_map[name] = visible - source_to_visible[name] = visible - visible_to_source[visible] = name + source_to_visible, rename_map = _plan_source_names( + self, + source_order, + self._entries, + rename_theirs = rename_theirs, + rename_when = rename_when, + ) + visible_to_source = {visible: source_name for source_name, visible in source_to_visible.items()} layer = _SourceLayer( library=view, diff --git a/masque/library.py b/masque/library.py index 8f8d842..c21305d 100644 --- a/masque/library.py +++ b/masque/library.py @@ -15,10 +15,11 @@ Classes include: library. Generated with `ILibraryView.abstract_view()`. """ from typing import Self, TYPE_CHECKING, Any, cast, TypeAlias, Protocol, Literal -from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable +from collections.abc import Container, Iterator, Mapping, MutableMapping, Sequence, Callable import logging import re import copy +from functools import wraps from pprint import pformat from collections import defaultdict from abc import ABCMeta, abstractmethod @@ -87,15 +88,11 @@ class CellProvenance: any. Imported source cells leave this as `None`. 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. """ requested_name: str kind: Literal['declared', 'helper', 'source'] owner_declared_name: str | None build_chain: tuple[str, ...] - renamed_from: str | None = None - source_name: str | None = None @dataclass(frozen=True) @@ -152,6 +149,47 @@ def _rename_patterns(lib: 'ILibraryView', name: str) -> str: return lib.get_name(SINGLE_USE_PREFIX + stem) +def _plan_source_names( + target: 'ILibraryView', + source_order: Sequence[str], + existing_names: Container[str], + *, + rename_theirs: Callable[['ILibraryView', str], str] | None = None, + rename_when: Literal['conflict', 'always'] = 'conflict', + ) -> tuple[dict[str, str], dict[str, str]]: + if rename_when not in ('conflict', 'always'): + raise ValueError(f'Unknown source rename mode: {rename_when!r}') + if rename_when == 'always' and rename_theirs is None: + raise TypeError('rename_theirs is required when rename_when="always"') + + source_to_visible: dict[str, str] = {} + visible_names: set[str] = set() + rename_map: dict[str, str] = {} + + for name in source_order: + visible = name + renamed = False + if rename_when == 'always': + assert rename_theirs is not None + visible = rename_theirs(target, name) + renamed = True + elif visible in existing_names or visible in visible_names: + if rename_theirs is None: + raise LibraryError(f'Conflicting name while adding source: {name!r}') + visible = rename_theirs(target, name) + renamed = True + if visible in existing_names or visible in visible_names: + if not renamed: + raise LibraryError(f'Conflicting name while adding source: {name!r}') + raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') + if visible != name: + rename_map[name] = visible + source_to_visible[name] = visible + visible_names.add(visible) + + return source_to_visible, rename_map + + class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ Interface for a read-only library. @@ -1453,23 +1491,6 @@ class Library(ILibrary): return tree, pat -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. """ @@ -1483,25 +1504,17 @@ class _BuildRecipe: return self -@dataclass(frozen=True) -class _SourceDeclaration: - """ - Imported source-backed names registered with a `BuildLibrary`. - - The declaration stores visible-name remapping. Underlying source cells stay - lazy until a build session materializes or copies them through. - """ - library: ILibraryView - source_to_visible: Mapping[str, str] - - -def cell(func: Callable[..., 'Pattern']) -> _CellFactory: +def cell(func: Callable[..., 'Pattern']) -> Callable[..., _BuildRecipe]: """ Wrap a plain pattern factory so calls return deferred build recipes. Use as either `cell(fn)(...)` or `@cell`. """ - return _CellFactory(func) + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> _BuildRecipe: + return _BuildRecipe(func=func, args=args, kwargs=kwargs) + + return wrapper class BuildCellsView: @@ -1555,7 +1568,7 @@ class BuildLibrary(ILibrary): self.cells = BuildCellsView(self) self._frozen = False self._declarations: dict[str, 'Pattern | _BuildRecipe'] = {} - self._sources: list[_SourceDeclaration] = [] + self._sources: list[tuple[ILibraryView, dict[str, str]]] = [] self._names: set[str] = set() self._order: list[str] = [] @@ -1694,8 +1707,8 @@ class BuildLibrary(ILibrary): source_index: int | None = None source_name: str | None = None - for idx, spec in enumerate(self._sources): - for candidate_source, candidate_visible in spec.source_to_visible.items(): + for idx, (_source, source_to_visible) in enumerate(self._sources): + for candidate_source, candidate_visible in source_to_visible.items(): if candidate_visible == old_name: source_index = idx source_name = candidate_source @@ -1708,16 +1721,13 @@ class BuildLibrary(ILibrary): 'cells may be renamed on a BuildLibrary.' ) - spec = self._sources[source_index] - source_to_visible = dict(spec.source_to_visible) + source_library, source_to_visible = self._sources[source_index] + source_to_visible = dict(source_to_visible) assert source_name is not None source_to_visible[source_name] = new_name - self._sources[source_index] = replace( - spec, - source_to_visible = source_to_visible, - ) + self._sources[source_index] = (source_library, source_to_visible) self._names.remove(old_name) self._names.add(new_name) self._order[self._order.index(old_name)] = new_name @@ -1761,48 +1771,23 @@ class BuildLibrary(ILibrary): Mapping of `{source_name: visible_name}` for imported names that were renamed while being added. """ - if rename_when not in ('conflict', 'always'): - raise ValueError(f'Unknown source rename mode: {rename_when!r}') - if rename_when == 'always' and rename_theirs is None: - raise TypeError('rename_theirs is required when rename_when="always"') if self._active_session() is not None: raise BuildError('BuildLibrary.add_source() is only available while authoring, not during validate() or build().') self._assert_editable() view = source if isinstance(source, ILibraryView) else LibraryView(source) source_order = tuple(view.source_order()) + source_to_visible, rename_map = _plan_source_names( + self, + source_order, + self._names, + rename_theirs = rename_theirs, + rename_when = rename_when, + ) - source_to_visible: dict[str, str] = {} - visible_names: set[str] = set() - rename_map: dict[str, str] = {} - new_names: list[str] = [] - - for name in source_order: - visible = name - renamed = False - if rename_when == 'always': - visible = cast('Callable[[ILibraryView, str], str]', rename_theirs)(self, name) - renamed = True - elif visible in self._names or visible in visible_names: - if rename_theirs is None: - raise LibraryError(f'Conflicting name while adding source: {name!r}') - visible = rename_theirs(self, name) - renamed = True - if visible in self._names or visible in visible_names: - if not renamed: - raise LibraryError(f'Conflicting name while adding source: {name!r}') - raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') - if visible != name: - rename_map[name] = visible - source_to_visible[name] = visible - visible_names.add(visible) - new_names.append(visible) - - self._sources.append(_SourceDeclaration( - library = view, - source_to_visible = dict(source_to_visible), - )) - for visible in new_names: + self._sources.append((view, dict(source_to_visible))) + for source_name in source_order: + visible = source_to_visible[source_name] self._names.add(visible) self._order.append(visible) return rename_map @@ -1902,9 +1887,9 @@ class _BuildSessionLibrary(ILibrary): self._install_sources() def _install_sources(self) -> None: - for spec in self._builder._sources: - source_order = spec.library.source_order() - expected_names = set(spec.source_to_visible) + for source_library, source_to_visible in self._builder._sources: + source_order = source_library.source_order() + expected_names = set(source_to_visible) actual_names = set(source_order) if actual_names != expected_names: added_names = sorted(actual_names - expected_names) @@ -1920,24 +1905,22 @@ class _BuildSessionLibrary(ILibrary): 'Do not structurally mutate source libraries between add_source() and build()/validate().' ) - def rename_source(_lib: ILibraryView, name: str, *, mapping: Mapping[str, str] = spec.source_to_visible) -> str: + def rename_source(_lib: ILibraryView, name: str, *, mapping: Mapping[str, str] = source_to_visible) -> str: return mapping[name] self._overlay.add_source( - spec.library, + source_library, rename_theirs = rename_source, rename_when = 'always', ) for source_name in source_order: - visible_name = spec.source_to_visible[source_name] + visible_name = source_to_visible[source_name] self._provenance[visible_name] = CellProvenance( requested_name = source_name, kind = 'source', owner_declared_name = None, build_chain = (), - renamed_from = source_name if visible_name != source_name else None, - source_name = source_name, ) def __iter__(self) -> Iterator[str]: @@ -1947,7 +1930,7 @@ class _BuildSessionLibrary(ILibrary): return len(self._names) def __contains__(self, key: object) -> bool: - return key in self._names or key in self._overlay + return key in self._names def _touch_name(self, key: str) -> None: if key not in self._names: @@ -1998,11 +1981,7 @@ class _BuildSessionLibrary(ILibrary): self._order[idx] = new_name provenance = self._provenance.pop(old_name) - requested_name = provenance.requested_name - self._provenance[new_name] = replace( - provenance, - renamed_from = requested_name if new_name != requested_name else None, - ) + self._provenance[new_name] = provenance return self def __getitem__(self, key: str) -> 'Pattern': @@ -2038,7 +2017,6 @@ class _BuildSessionLibrary(ILibrary): kind = kind, owner_declared_name = current if kind == 'helper' else key, build_chain = tuple(self._declared_stack), - renamed_from = None, ) def __delitem__(self, key: str) -> None: @@ -2072,7 +2050,6 @@ class _BuildSessionLibrary(ILibrary): 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 @@ -2085,14 +2062,12 @@ class _BuildSessionLibrary(ILibrary): kind: Literal['declared', 'helper'], owner_declared_name: str | None, build_chain: tuple[str, ...], - renamed_from: str | None, ) -> None: self._provenance[name] = CellProvenance( requested_name = requested_name, kind = kind, owner_declared_name = owner_declared_name, build_chain = build_chain, - renamed_from = renamed_from, ) def _wrap_error(self, name: str, exc: Exception) -> BuildError: diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index 2e7cb27..3a623c7 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -50,13 +50,13 @@ def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None _built, report = builder.build() helpers = [ - prov for prov in report.provenance.values() + (name, prov) for name, prov in report.provenance.items() if prov.owner_declared_name == "top" and prov.kind == "helper" ] assert "top" in _owned_by(report, "top") assert len(helpers) == 2 - assert any(prov.renamed_from == "_helper" for prov in helpers) + assert any(name != prov.requested_name for name, prov in helpers) def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None: @@ -152,6 +152,25 @@ def test_build_library_allows_helper_writes_via_pather() -> None: assert helper_prov.owner_declared_name == "top" +def test_build_library_contains_tracks_active_session_names() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + builder.add_source(Library({"src": Pattern()})) + + def make_top(lib: BuildLibrary) -> Pattern: + assert "leaf" in lib + assert "src" in lib + assert "_helper" not in lib + lib["_helper"] = Pattern() + assert "_helper" in lib + return Pattern() + + builder.cells.top = cell(make_top)(builder) + built, _report = builder.build() + + assert "_helper" in built + + def test_build_library_preserves_source_cells_and_records_source_provenance() -> None: source = Library({"src": Pattern()}) builder = BuildLibrary() @@ -184,7 +203,7 @@ def test_build_library_add_source_can_rename_every_source_cell() -> None: "parent": "mapped_parent", } assert "mapped_child" in built["mapped_parent"].refs - assert report.provenance["mapped_child"].source_name == "child" + assert report.provenance["mapped_child"].requested_name == "child" def test_build_library_rejects_source_cells_added_after_add_source() -> None: @@ -236,7 +255,7 @@ def test_build_library_can_rename_imported_source_cells_during_authoring() -> No assert "renamed_child" in built assert "child" not in built assert "renamed_child" in built["parent"].refs - assert report.provenance["renamed_child"].source_name == "child" + assert report.provenance["renamed_child"].requested_name == "child" def test_build_library_rejects_move_references_for_source_rename() -> None: @@ -276,7 +295,6 @@ def test_build_library_helper_rename_updates_provenance_owner() -> None: prov = report.provenance["final_helper"] assert prov.kind == "helper" assert prov.requested_name == "_helper" - assert prov.renamed_from == "_helper" def test_build_library_helper_delete_removes_provenance_and_ownership() -> None: @@ -314,7 +332,6 @@ def test_build_library_helper_rename_after_auto_rename_preserves_requested_name( 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: From afbbb458c8f6afe7bc1f1b06a5d7c7528acf5b4d Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 20 Jun 2026 00:15:42 -0700 Subject: [PATCH 6/8] [BuildLibrary / OverlayLibrary] further simplifications 2 --- masque/file/gdsii_lazy_core.py | 6 +-- masque/library.py | 86 ++++++++----------------------- masque/test/test_build_library.py | 23 +-------- 3 files changed, 26 insertions(+), 89 deletions(-) diff --git a/masque/file/gdsii_lazy_core.py b/masque/file/gdsii_lazy_core.py index 9cb1c85..88c4eed 100644 --- a/masque/file/gdsii_lazy_core.py +++ b/masque/file/gdsii_lazy_core.py @@ -32,7 +32,7 @@ from numpy.typing import NDArray from . import gdsii from .utils import tmpfile from ..error import LibraryError -from ..library import ILibrary, ILibraryView, LibraryView, _plan_source_names, dangling_mode_t +from ..library import ILibrary, ILibraryView, LibraryView, _plan_source_names, _source_rename_map, dangling_mode_t from ..pattern import Pattern, map_targets from ..utils import apply_transforms from ..utils.ports2data import data_to_ports @@ -319,7 +319,7 @@ class OverlayLibrary(ILibrary): source_order = list(view.source_order()) child_graph = view.child_graph(dangling='include') - source_to_visible, rename_map = _plan_source_names( + source_to_visible = _plan_source_names( self, source_order, self._entries, @@ -343,7 +343,7 @@ class OverlayLibrary(ILibrary): if visible_name not in self._order: self._order.append(visible_name) - return rename_map + return _source_rename_map(source_to_visible) def rename( self, diff --git a/masque/library.py b/masque/library.py index c21305d..8306f77 100644 --- a/masque/library.py +++ b/masque/library.py @@ -156,7 +156,7 @@ def _plan_source_names( *, rename_theirs: Callable[['ILibraryView', str], str] | None = None, rename_when: Literal['conflict', 'always'] = 'conflict', - ) -> tuple[dict[str, str], dict[str, str]]: + ) -> dict[str, str]: if rename_when not in ('conflict', 'always'): raise ValueError(f'Unknown source rename mode: {rename_when!r}') if rename_when == 'always' and rename_theirs is None: @@ -164,7 +164,6 @@ def _plan_source_names( source_to_visible: dict[str, str] = {} visible_names: set[str] = set() - rename_map: dict[str, str] = {} for name in source_order: visible = name @@ -182,12 +181,18 @@ def _plan_source_names( if not renamed: raise LibraryError(f'Conflicting name while adding source: {name!r}') raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') - if visible != name: - rename_map[name] = visible source_to_visible[name] = visible visible_names.add(visible) - return source_to_visible, rename_map + return source_to_visible + + +def _source_rename_map(source_to_visible: Mapping[str, str]) -> dict[str, str]: + return { + source_name: visible_name + for source_name, visible_name in source_to_visible.items() + if source_name != visible_name + } class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): @@ -1675,12 +1680,11 @@ class BuildLibrary(ILibrary): move_references: bool = False, ) -> Self: """ - Rename an imported source-backed visible name during authoring. + Rename a helper cell during an active build session. - 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. + During authoring, declared cells must be registered under their + intended final names and imported source cells must be renamed through + `add_source(...)`. """ session = self._active_session() if session is not None: @@ -1697,41 +1701,10 @@ class BuildLibrary(ILibrary): ) 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: int | None = None - source_name: str | None = None - for idx, (_source, source_to_visible) in enumerate(self._sources): - for candidate_source, candidate_visible in source_to_visible.items(): - if candidate_visible == old_name: - source_index = idx - source_name = candidate_source - break - if source_index is not None: - break - 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.' - ) - - source_library, source_to_visible = self._sources[source_index] - source_to_visible = dict(source_to_visible) - assert source_name is not None - - source_to_visible[source_name] = new_name - - self._sources[source_index] = (source_library, source_to_visible) - self._names.remove(old_name) - self._names.add(new_name) - self._order[self._order.index(old_name)] = new_name - return self + raise BuildError( + f'Cannot rename imported source cell "{old_name}" during authoring. ' + 'Choose visible source names with add_source(..., rename_theirs=..., rename_when=...).' + ) def abstract(self, name: str) -> Abstract: return self._require_active_session('abstract').abstract(name) @@ -1777,7 +1750,7 @@ class BuildLibrary(ILibrary): view = source if isinstance(source, ILibraryView) else LibraryView(source) source_order = tuple(view.source_order()) - source_to_visible, rename_map = _plan_source_names( + source_to_visible = _plan_source_names( self, source_order, self._names, @@ -1790,7 +1763,7 @@ class BuildLibrary(ILibrary): visible = source_to_visible[source_name] self._names.add(visible) self._order.append(visible) - return rename_map + return _source_rename_map(source_to_visible) def validate( self, @@ -2011,8 +1984,7 @@ class _BuildSessionLibrary(ILibrary): else: kind = 'helper' - self._record_provenance( - name = key, + self._provenance[key] = CellProvenance( requested_name = key, kind = kind, owner_declared_name = current if kind == 'helper' else key, @@ -2054,22 +2026,6 @@ class _BuildSessionLibrary(ILibrary): ) return rename_map - def _record_provenance( - self, - *, - name: str, - requested_name: str, - kind: Literal['declared', 'helper'], - owner_declared_name: str | None, - build_chain: tuple[str, ...], - ) -> None: - self._provenance[name] = CellProvenance( - requested_name = requested_name, - kind = kind, - owner_declared_name = owner_declared_name, - build_chain = build_chain, - ) - def _wrap_error(self, name: str, exc: Exception) -> BuildError: chain = tuple(self._declared_stack) msg = [f'Failed while building declared cell "{name}"'] diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index 3a623c7..eec2772 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -239,30 +239,11 @@ def test_build_library_rejects_add_source_during_build() -> None: builder.build() -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, report = builder.build() - - assert "renamed_child" in built - assert "child" not in built - assert "renamed_child" in built["parent"].refs - assert report.provenance["renamed_child"].requested_name == "child" - - -def test_build_library_rejects_move_references_for_source_rename() -> None: +def test_build_library_rejects_renaming_imported_source_cells_during_authoring() -> None: builder = BuildLibrary() builder.add_source(Library({"src": Pattern()})) - with pytest.raises(BuildError, match="move_references=True"): + with pytest.raises(BuildError, match="add_source"): builder.rename("src", "renamed_src", move_references=True) From 77699cafad49e1c9a4e6a8482385e5f01576373f Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 20 Jun 2026 14:41:54 -0700 Subject: [PATCH 7/8] [BuildLibrary] fix auto-rename during merge --- masque/library.py | 130 ++++++++++++++++++++---------- masque/test/test_build_library.py | 126 ++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 45 deletions(-) diff --git a/masque/library.py b/masque/library.py index 8306f77..bcc23ce 100644 --- a/masque/library.py +++ b/masque/library.py @@ -167,19 +167,14 @@ def _plan_source_names( for name in source_order: visible = name - renamed = False if rename_when == 'always': assert rename_theirs is not None visible = rename_theirs(target, name) - renamed = True elif visible in existing_names or visible in visible_names: if rename_theirs is None: raise LibraryError(f'Conflicting name while adding source: {name!r}') visible = rename_theirs(target, name) - renamed = True if visible in existing_names or visible in visible_names: - if not renamed: - raise LibraryError(f'Conflicting name while adding source: {name!r}') raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') source_to_visible[name] = visible visible_names.add(visible) @@ -1572,10 +1567,9 @@ class BuildLibrary(ILibrary): def __init__(self) -> None: self.cells = BuildCellsView(self) self._frozen = False - self._declarations: dict[str, 'Pattern | _BuildRecipe'] = {} + self._declarations: dict[str, Pattern | _BuildRecipe] = {} self._sources: list[tuple[ILibraryView, dict[str, str]]] = [] - self._names: set[str] = set() - self._order: list[str] = [] + self._names: dict[str, None] = {} def _active_session(self) -> '_BuildSessionLibrary | None': sessions = _ACTIVE_BUILD_SESSIONS.get() @@ -1600,7 +1594,7 @@ class BuildLibrary(ILibrary): session = self._active_session() if session is not None: return iter(session) - return iter(self._order) + return iter(self._names) def __len__(self) -> int: session = self._active_session() @@ -1620,7 +1614,7 @@ class BuildLibrary(ILibrary): def __setitem__( self, key: str, - value: 'Pattern | _BuildRecipe | Callable[[], Pattern]', + value: 'Pattern | _BuildRecipe', ) -> None: session = self._active_session() if session is not None: @@ -1639,8 +1633,7 @@ class BuildLibrary(ILibrary): declaration = value self._declarations[key] = declaration - self._names.add(key) - self._order.append(key) + self._names[key] = None def __delitem__(self, key: str) -> None: session = self._active_session() @@ -1652,8 +1645,7 @@ class BuildLibrary(ILibrary): if key not in self._declarations: raise KeyError(key) del self._declarations[key] - self._names.remove(key) - self._order.remove(key) + del self._names[key] def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: session = self._active_session() @@ -1668,10 +1660,77 @@ class BuildLibrary(ILibrary): rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns, mutate_other: bool = False, ) -> dict[str, str]: + from .pattern import map_targets # noqa: PLC0415 + 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) + + self._assert_editable() + + source_backed = isinstance(other, ILibraryView) and not isinstance(other, Library | LibraryView) + if source_backed: + if mutate_other: + raise BuildError('BuildLibrary.add(..., mutate_other=True) is not supported for source-backed inputs.') + return self.add_source( + other, + rename_theirs = rename_theirs, + rename_when = 'conflict', + ) + + source_order = tuple(other.keys()) + source_to_visible = _plan_source_names( + self, + source_order, + self._names, + rename_theirs = rename_theirs, + rename_when = 'conflict', + ) + rename_map = _source_rename_map(source_to_visible) + + if mutate_other: + temp = other + else: + temp = Library(copy.deepcopy(dict(other))) + + for source_name in source_order: + visible_name = source_to_visible[source_name] + pattern = temp[source_name] + if rename_map: + pattern.refs = map_targets( + pattern.refs, + lambda target: cast('dict[str | None, str | None]', rename_map).get(target, target), + ) + self[visible_name] = pattern + + return rename_map + + def __lshift__(self, other: TreeView) -> str: + session = self._active_session() + if session is not None: + return session << other + + self._assert_editable() + if len(other) == 1: + name = next(iter(other)) + elif isinstance(other, ILibraryView) and not isinstance(other, Library | LibraryView): + source_order = other.source_order() + child_graph = other.child_graph(dangling='include') + referenced = set().union(*child_graph.values()) if child_graph else set() + tops = [candidate for candidate in source_order if candidate not in referenced] + if len(tops) != 1: + raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}') + name = tops[0] + else: + return super().__lshift__(other) + + rename_map = self.add(other) + return rename_map.get(name, name) + + def __le__(self, other: Mapping[str, 'Pattern']) -> Abstract: + if self._active_session() is not None: + return super().__le__(other) + raise BuildError('BuildLibrary.__le__() is only available while validate() or build() is running.') def rename( self, @@ -1761,8 +1820,7 @@ class BuildLibrary(ILibrary): self._sources.append((view, dict(source_to_visible))) for source_name in source_order: visible = source_to_visible[source_name] - self._names.add(visible) - self._order.append(visible) + self._names[visible] = None return _source_rename_map(source_to_visible) def validate( @@ -1849,12 +1907,9 @@ class _BuildSessionLibrary(ILibrary): self._builder = builder self._overlay = OverlayLibrary() - self._states: dict[str, Literal['unbuilt', 'building', 'built']] = { - name: 'unbuilt' for name in builder._declarations - } + self._built: set[str] = set() self._declared_stack: list[str] = [] - self._names = set(builder._names) - self._order = list(builder._order) + self._names = dict(builder._names) self._provenance: dict[str, CellProvenance] = {} self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set) self._install_sources() @@ -1897,7 +1952,7 @@ class _BuildSessionLibrary(ILibrary): ) def __iter__(self) -> Iterator[str]: - return (name for name in self._order if name in self._names) + return iter(self._names) def __len__(self) -> int: return len(self._names) @@ -1905,11 +1960,6 @@ class _BuildSessionLibrary(ILibrary): def __contains__(self, key: object) -> bool: return key in self._names - 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 @@ -1947,11 +1997,10 @@ class _BuildSessionLibrary(ILibrary): 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 + self._names = { + new_name if name == old_name else name: None + for name in self._names + } provenance = self._provenance.pop(old_name) self._provenance[new_name] = provenance @@ -1976,7 +2025,7 @@ class _BuildSessionLibrary(ILibrary): pattern = value() if callable(value) else value self._overlay[key] = pattern - self._touch_name(key) + self._names.setdefault(key, None) kind: Literal['declared', 'helper'] if current is not None and key == current: @@ -2000,9 +2049,7 @@ class _BuildSessionLibrary(ILibrary): self._guard_mutable_output_name(key, operation='delete') if key in self._overlay: del self._overlay[key] - self._names.discard(key) - if key in self._order: - self._order.remove(key) + self._names.pop(key, None) self._provenance.pop(key, None) def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: @@ -2046,15 +2093,13 @@ class _BuildSessionLibrary(ILibrary): def _ensure_declared(self, name: str) -> None: from .pattern import Pattern # noqa: PLC0415 - state = self._states[name] - if state == 'built': + if name in self._built: return - if state == 'building': + if name in self._declared_stack: 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, _BuildRecipe): @@ -2073,9 +2118,8 @@ class _BuildSessionLibrary(ILibrary): ) else: self[name] = pattern - self._states[name] = 'built' + self._built.add(name) except Exception as exc: - self._states[name] = 'unbuilt' raise self._wrap_error(name, exc) from exc finally: self._declared_stack.pop() diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index eec2772..6a0712e 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -1,19 +1,47 @@ +from collections.abc import Iterator + import pytest from ..builder import Pather from ..error import BuildError -from ..library import BuildLibrary, Library, cell +from ..library import BuildLibrary, BuildReport, ILibraryView, Library, cell, dangling_mode_t from ..pattern import Pattern from ..ports import Port -def _owned_by(report, owner: str) -> set[str]: +def _owned_by(report: BuildReport, owner: str) -> set[str]: return { name for name, prov in report.provenance.items() if prov.owner_declared_name == owner } +class _MetadataSource(ILibraryView): + def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None: + self.mapping = mapping + self._child_graph = child_graph + self.loads = 0 + + def __getitem__(self, key: str) -> Pattern: + self.loads += 1 + return self.mapping[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def source_order(self) -> tuple[str, ...]: + return tuple(self.mapping) + + def child_graph(self, dangling: dangling_mode_t = 'error') -> dict[str, set[str]]: # noqa: ARG002 + return self._child_graph + + def test_build_library_traces_declared_dependencies_out_of_order() -> None: builder = BuildLibrary() @@ -59,6 +87,36 @@ def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None assert any(name != prov.requested_name for name, prov in helpers) +def test_build_library_authoring_tree_merge_renames_repeated_single_use_names() -> None: + builder = BuildLibrary() + tree = Library({"_helper": Pattern()}) + + name_a = builder << tree + name_b = builder << tree + built, report = builder.build() + + assert name_a == "_helper" + assert name_b != "_helper" + assert name_a in built + assert name_b in built + assert report.provenance[name_b].requested_name == name_b + + +def test_build_library_authoring_tree_merge_remaps_internal_refs() -> None: + builder = BuildLibrary() + builder["_helper"] = Pattern() + helper = Pattern() + top = Pattern() + top.ref("_helper") + + top_name = builder << Library({"_helper": helper, "top": top}) + built, _report = builder.build() + + assert top_name == "top" + assert "_helper" not in built[top_name].refs + assert any(name != "_helper" for name in built[top_name].refs) + + def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None: builder = BuildLibrary() builder["leaf"] = Pattern() @@ -206,6 +264,70 @@ def test_build_library_add_source_can_rename_every_source_cell() -> None: assert report.provenance["mapped_child"].requested_name == "child" +def test_build_library_authoring_tree_merge_keeps_source_view_lazy() -> None: + child = Pattern() + top = Pattern() + top.ref("child") + source = _MetadataSource( + {"child": child, "top": top}, + {"child": set(), "top": {"child"}}, + ) + + builder = BuildLibrary() + top_name = builder << source + built, _report = builder.build() + + assert top_name == "top" + assert "top" in built + assert source.loads == 0 + + +def test_build_library_authoring_source_tree_merge_returns_renamed_top() -> None: + existing = Pattern() + source_top = Pattern() + source = _MetadataSource( + {"_helper": source_top}, + {"_helper": set()}, + ) + + builder = BuildLibrary() + builder["_helper"] = existing + top_name = builder << source + built, _report = builder.build() + + assert top_name != "_helper" + assert top_name in built + assert source.loads == 0 + + +def test_build_library_authoring_source_tree_merge_remaps_renamed_child_on_materialization() -> None: + source_helper = Pattern() + source_top = Pattern() + source_top.ref("_helper") + source = _MetadataSource( + {"_helper": source_helper, "top": source_top}, + {"_helper": set(), "top": {"_helper"}}, + ) + + builder = BuildLibrary() + builder["_helper"] = Pattern() + top_name = builder << source + built, _report = builder.build(output="library") + + assert top_name == "top" + assert "_helper" not in built[top_name].refs + assert source.loads == 2 + + +def test_build_library_rejects_authoring_tree_le_before_mutating() -> None: + builder = BuildLibrary() + + with pytest.raises(BuildError, match="__le__"): + _abstract = builder <= Library({"leaf": Pattern()}) + + assert list(builder) == [] + + def test_build_library_rejects_source_cells_added_after_add_source() -> None: source = Library({"src": Pattern()}) builder = BuildLibrary() From 1e77863753e6a1e2e5a6d0d1c2aad56037580833 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 20 Jun 2026 16:22:24 -0700 Subject: [PATCH 8/8] [PortPather] add default_spacing (.at(..., spacing=...)) --- examples/tutorial/port_pather.py | 7 +-- masque/builder/pather.py | 26 ++++++++-- masque/test/test_pather_core.py | 82 ++++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py index ab942d7..48e948e 100644 --- a/examples/tutorial/port_pather.py +++ b/examples/tutorial/port_pather.py @@ -54,9 +54,10 @@ def main() -> None: (rpather.at(['GND', 'VCC']) .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South .retool(M1_tool) # Retools both GND and VCC - .trace(True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension - .trace(False, emin=1_000, spacing=1_200) # U-turn back South - .trace(False, emin=2_000, spacing=4_500) # U-turn back West + .set_spacing(1_200) # Default bundle spacing for later bends + .trace(True, emax=50_000) # Turn East, moves 50um extension + .trace(False, emin=1_000) # U-turn back South + .trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing ) # Retool VCC back to M2 and move both to x=-28k diff --git a/masque/builder/pather.py b/masque/builder/pather.py index b4c264d..8398e2e 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1173,30 +1173,50 @@ class Pather(PortList): self.pattern.flatten(self.library) return self - def at(self, portspec: str | Iterable[str]) -> 'PortPather': - return PortPather(portspec, self) + def at( + self, + portspec: str | Iterable[str], + *, + spacing: float | ArrayLike | None = None, + ) -> 'PortPather': + return PortPather(portspec, self, default_spacing=spacing) class PortPather: """ Port state manager for fluent pathing. """ - def __init__(self, ports: str | Iterable[str], pather: Pather) -> None: + def __init__( + self, + ports: str | Iterable[str], + pather: Pather, + *, + default_spacing: float | ArrayLike | None = None, + ) -> None: self.ports = [ports] if isinstance(ports, str) else list(ports) self.pather = pather + self.default_spacing = default_spacing def retool(self, tool: Tool) -> Self: self.pather.retool(tool, self.ports) return self + def set_spacing(self, spacing: float | ArrayLike | None) -> Self: + self.default_spacing = spacing + return self + @contextmanager def toolctx(self, tool: Tool) -> Iterator[Self]: with self.pather.toolctx(tool, keys=self.ports): yield self def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: + kw['spacing'] = self.default_spacing self.pather.trace(self.ports, ccw, length, **kw) return self def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: + kw['spacing'] = self.default_spacing self.pather.trace_to(self.ports, ccw, **kw) return self diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py index 396534e..1d67b90 100644 --- a/masque/test/test_pather_core.py +++ b/masque/test/test_pather_core.py @@ -1,13 +1,11 @@ -from typing import Any - import pytest import numpy from numpy import pi from numpy.testing import assert_allclose, assert_equal from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool, Tool -from masque.error import BuildError, PortError, PatternError +from masque.builder.tools import PathTool +from masque.error import BuildError, PortError @pytest.fixture @@ -117,6 +115,82 @@ def test_pather_bundle_trace() -> None: assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000) assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) +def test_portpather_default_spacing_matches_explicit_spacing() -> None: + lib_default = Library() + tool_default = PathTool(layer='M1', width=1000) + p_default = Pather(lib_default, tools=tool_default, auto_render=False) + p_default.pattern.ports['A'] = Port((0, 0), rotation=0) + p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) + + lib_explicit = Library() + tool_explicit = PathTool(layer='M1', width=1000) + p_explicit = Pather(lib_explicit, tools=tool_explicit, auto_render=False) + p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_default.at(['A', 'B'], spacing=2000).ccw(xmin=-20000) + p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) + + assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) + assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) + +def test_portpather_default_spacing_reused_and_overridden() -> None: + p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_default.pattern.ports['A'] = Port((0, 0), rotation=0) + p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + pp_default = p_default.at(['A', 'B'], spacing=2000) + pp_default.ccw(xmin=-20000) + pp_default.cw(emin=1000) + p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000).cw(emin=1000, spacing=2000) + + assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) + assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) + + p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_override.pattern.ports['A'] = Port((0, 0), rotation=0) + p_override.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_override.at(['A', 'B'], spacing=2000).ccw(xmin=-20000, spacing=3000) + p_override_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=3000) + + assert_allclose(p_override.pattern.ports['A'].offset, p_override_explicit.pattern.ports['A'].offset) + assert_allclose(p_override.pattern.ports['B'].offset, p_override_explicit.pattern.ports['B'].offset) + +def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p.at(['A', 'B'], spacing=2000).straight(xmin=-10000) + + assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) + assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) + +def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + p.pattern.ports['C'] = Port((0, 4000), rotation=0) + + pp = p.at(['A', 'B', 'C'], spacing=[2000, 3000]) + pp.deselect('C') + + with pytest.raises(BuildError, match='spacing must be scalar or have length 1'): + pp.ccw(xmin=-20000) + def test_pather_each_bound() -> None: lib = Library() tool = PathTool(layer='M1', width=1000)