Compare commits

..

8 commits

10 changed files with 641 additions and 473 deletions

View file

@ -22,7 +22,7 @@ Contents
- [library](library.py) - [library](library.py)
* Continue from `devices.py` by declaring a mixed library with `BuildLibrary` * Continue from `devices.py` by declaring a mixed library with `BuildLibrary`
* Import source-backed GDS cells and register python-generated recipes together * 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()` * Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
- [pather](pather.py) - [pather](pather.py)
* Use `Pather` to route individual wires and wire bundles * Use `Pather` to route individual wires and wire bundles

View file

@ -81,8 +81,9 @@ def main() -> None:
# Build the declaration set into a normal library. # 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('Built library contains:\n' + pformat(list(built.keys())))
print('Build dependency graph:\n' + pformat(report.dependency_graph))
# #
# Continue designing against the built library. # Continue designing against the built library.

View file

@ -54,9 +54,10 @@ def main() -> None:
(rpather.at(['GND', 'VCC']) (rpather.at(['GND', 'VCC'])
.trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
.retool(M1_tool) # Retools both GND and VCC .retool(M1_tool) # Retools both GND and VCC
.trace(True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension .set_spacing(1_200) # Default bundle spacing for later bends
.trace(False, emin=1_000, spacing=1_200) # U-turn back South .trace(True, emax=50_000) # Turn East, moves 50um extension
.trace(False, emin=2_000, spacing=4_500) # U-turn back West .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 # Retool VCC back to M2 and move both to x=-28k

View file

@ -63,7 +63,6 @@ from .library import (
ILibrary as ILibrary, ILibrary as ILibrary,
LibraryView as LibraryView, LibraryView as LibraryView,
Library as Library, Library as Library,
BuiltLibrary as BuiltLibrary,
BuildLibrary as BuildLibrary, BuildLibrary as BuildLibrary,
BuildReport as BuildReport, BuildReport as BuildReport,
CellProvenance as CellProvenance, CellProvenance as CellProvenance,

View file

@ -1173,30 +1173,50 @@ class Pather(PortList):
self.pattern.flatten(self.library) self.pattern.flatten(self.library)
return self return self
def at(self, portspec: str | Iterable[str]) -> 'PortPather': def at(
return PortPather(portspec, self) self,
portspec: str | Iterable[str],
*,
spacing: float | ArrayLike | None = None,
) -> 'PortPather':
return PortPather(portspec, self, default_spacing=spacing)
class PortPather: class PortPather:
""" Port state manager for fluent pathing. """ """ 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.ports = [ports] if isinstance(ports, str) else list(ports)
self.pather = pather self.pather = pather
self.default_spacing = default_spacing
def retool(self, tool: Tool) -> Self: def retool(self, tool: Tool) -> Self:
self.pather.retool(tool, self.ports) self.pather.retool(tool, self.ports)
return self return self
def set_spacing(self, spacing: float | ArrayLike | None) -> Self:
self.default_spacing = spacing
return self
@contextmanager @contextmanager
def toolctx(self, tool: Tool) -> Iterator[Self]: def toolctx(self, tool: Tool) -> Iterator[Self]:
with self.pather.toolctx(tool, keys=self.ports): with self.pather.toolctx(tool, keys=self.ports):
yield self yield self
def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> 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) self.pather.trace(self.ports, ccw, length, **kw)
return self return self
def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> 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) self.pather.trace_to(self.ports, ccw, **kw)
return self return self

View file

@ -17,7 +17,7 @@ Both the classic and Arrow-backed lazy GDS readers rely on these helpers.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import IO, Any, cast from typing import IO, Any, Literal, cast
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Iterator, Mapping, Sequence from collections.abc import Callable, Iterator, Mapping, Sequence
import copy import copy
@ -32,7 +32,7 @@ from numpy.typing import NDArray
from . import gdsii from . import gdsii
from .utils import tmpfile from .utils import tmpfile
from ..error import LibraryError from ..error import LibraryError
from ..library import ILibrary, ILibraryView, LibraryView, 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 ..pattern import Pattern, map_targets
from ..utils import apply_transforms from ..utils import apply_transforms
from ..utils.ports2data import data_to_ports from ..utils.ports2data import data_to_ports
@ -303,26 +303,30 @@ class OverlayLibrary(ILibrary):
source: Mapping[str, Pattern] | ILibraryView, source: Mapping[str, Pattern] | ILibraryView,
*, *,
rename_theirs: Callable[[ILibraryView, str], str] | None = None, rename_theirs: Callable[[ILibraryView, str], str] | None = None,
rename_when: Literal['conflict', 'always'] = 'conflict',
) -> dict[str, str]: ) -> 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`.
"""
view = _coerce_library_view(source) view = _coerce_library_view(source)
source_order = list(view.source_order()) source_order = list(view.source_order())
child_graph = view.child_graph(dangling='include') child_graph = view.child_graph(dangling='include')
source_to_visible: dict[str, str] = {} source_to_visible = _plan_source_names(
visible_to_source: dict[str, str] = {} self,
rename_map: dict[str, str] = {} source_order,
self._entries,
for name in source_order: rename_theirs = rename_theirs,
visible = name rename_when = rename_when,
if visible in self._entries or visible in visible_to_source: )
if rename_theirs is None: visible_to_source = {visible: source_name for source_name, visible in source_to_visible.items()}
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}')
rename_map[name] = visible
source_to_visible[name] = visible
visible_to_source[visible] = name
layer = _SourceLayer( layer = _SourceLayer(
library=view, library=view,
@ -339,7 +343,7 @@ class OverlayLibrary(ILibrary):
if visible_name not in self._order: if visible_name not in self._order:
self._order.append(visible_name) self._order.append(visible_name)
return rename_map return _source_rename_map(source_to_visible)
def rename( def rename(
self, self,
@ -549,20 +553,6 @@ class OverlayLibrary(ILibrary):
return tuple(name for name in self._order if name in self._entries) 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]]: def _iter_library_infos(library: Mapping[str, Pattern] | ILibraryView) -> Iterator[dict[str, Any]]:
info = getattr(library, 'library_info', None) info = getattr(library, 'library_info', None)
if isinstance(info, dict): if isinstance(info, dict):

View file

@ -15,10 +15,11 @@ Classes include:
library. Generated with `ILibraryView.abstract_view()`. library. Generated with `ILibraryView.abstract_view()`.
""" """
from typing import Self, TYPE_CHECKING, Any, cast, TypeAlias, Protocol, Literal 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 logging
import re import re
import copy import copy
from functools import wraps
from pprint import pformat from pprint import pformat
from collections import defaultdict from collections import defaultdict
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
@ -69,9 +70,6 @@ Tree: TypeAlias = MutableMapping[str, 'Pattern']
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include'] dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
""" How helpers should handle refs whose targets are not present in the library. """ """ 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) @dataclass(frozen=True)
class CellProvenance: class CellProvenance:
@ -83,29 +81,18 @@ class CellProvenance:
chosen. chosen.
Attributes: Attributes:
final_name: Name exposed by the completed library.
requested_name: First name requested for this cell during the build. requested_name: First name requested for this cell during the build.
kind: Whether the cell came from a declaration, helper emission, or an kind: Whether the cell came from a declaration, helper emission, or an
imported source library. imported source library.
owner_declared_name: Declared cell responsible for this output cell, if owner_declared_name: Declared cell responsible for this output cell, if
any. Imported source cells leave this as `None`. 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 build_chain: Declared-cell dependency chain that was active when the
cell was emitted. 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 requested_name: str
kind: Literal['declared', 'helper', 'source'] kind: Literal['declared', 'helper', 'source']
owner_declared_name: str | None owner_declared_name: str | None
emitted_via: emitted_via_t
build_chain: tuple[str, ...] build_chain: tuple[str, ...]
renamed_from: str | None = None
source_name: str | None = None
source_metadata: dict[str, Any] | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@ -121,15 +108,11 @@ class BuildReport:
requested_roots: Roots explicitly requested for the run. A full requested_roots: Roots explicitly requested for the run. A full
`build()` uses all declared cells. `build()` uses all declared cells.
provenance: Mapping from final output name to provenance metadata. 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 dependency_graph: Declared-cell dependency graph discovered through
library-mediated reads and explicit recipe hints. library-mediated reads and explicit recipe hints.
""" """
requested_roots: tuple[str, ...] requested_roots: tuple[str, ...]
provenance: Mapping[str, CellProvenance] provenance: Mapping[str, CellProvenance]
owned_cells: Mapping[str, tuple[str, ...]]
dependency_graph: Mapping[str, frozenset[str]] dependency_graph: Mapping[str, frozenset[str]]
@ -166,6 +149,47 @@ def _rename_patterns(lib: 'ILibraryView', name: str) -> str:
return lib.get_name(SINGLE_USE_PREFIX + stem) 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',
) -> 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()
for name in source_order:
visible = name
if rename_when == 'always':
assert rename_theirs is not None
visible = rename_theirs(target, name)
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)
if visible in existing_names or visible in visible_names:
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
source_to_visible[name] = visible
visible_names.add(visible)
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): class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
""" """
Interface for a read-only library. Interface for a read-only library.
@ -598,12 +622,12 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
for ref_transform in ref_transforms: for ref_transform in ref_transforms:
self.dfs( self.dfs(
pattern=self[target], pattern = self[target],
visit_before=visit_before, visit_before = visit_before,
visit_after=visit_after, visit_after = visit_after,
hierarchy=hierarchy + (target,), hierarchy = hierarchy + (target,),
transform=ref_transform, transform = ref_transform,
memo=memo, memo = memo,
) )
if visit_after is not None: if visit_after is not None:
@ -1467,42 +1491,6 @@ class Library(ILibrary):
return tree, pat return tree, pat
class BuiltLibrary(Library):
"""
Eager library returned by `BuildLibrary.build(output='library')`.
This is a normal materialized `Library` with one additional attribute,
`build_report`, which records how the library was assembled from
declarations, helper emissions, and imported source-backed cells.
"""
def __init__(
self,
mapping: MutableMapping[str, 'Pattern'] | None = None,
*,
build_report: BuildReport | None = None,
) -> None:
super().__init__(mapping=mapping)
self.build_report = build_report
class _CellFactory:
"""
Adapter that turns a plain pattern factory into a deferred recipe factory.
Calling the wrapper captures arguments and returns a `_BuildRecipe`
instead of executing the function immediately.
"""
def __init__(self, func: Callable[..., 'Pattern']) -> None:
self.func = func
self.__name__ = getattr(func, '__name__', type(self).__name__)
self.__doc__ = getattr(func, '__doc__')
def __call__(self, *args: Any, **kwargs: Any) -> '_BuildRecipe':
return _BuildRecipe(func=self.func, args=args, kwargs=kwargs)
@dataclass @dataclass
class _BuildRecipe: class _BuildRecipe:
""" Captured deferred call to a pattern factory. """ """ Captured deferred call to a pattern factory. """
@ -1516,41 +1504,17 @@ class _BuildRecipe:
return self return self
@dataclass(frozen=True) def cell(func: Callable[..., 'Pattern']) -> Callable[..., _BuildRecipe]:
class _PatternDeclaration:
""" Declared cell backed by an already-built `Pattern`. """
pattern: 'Pattern'
@dataclass(frozen=True)
class _RecipeDeclaration:
""" Declared cell backed by a deferred recipe. """
recipe: _BuildRecipe
@dataclass(frozen=True)
class _SourceDeclaration:
"""
Imported source-backed names registered with a `BuildLibrary`.
The declaration stores visible-name remapping plus pre-scanned graph
metadata. Underlying source cells stay lazy until a build session
materializes or copies them through.
"""
library: ILibraryView
source_to_visible: Mapping[str, str]
visible_to_source: Mapping[str, str]
child_graph: Mapping[str, set[str]]
order: tuple[str, ...]
def cell(func: Callable[..., 'Pattern']) -> _CellFactory:
""" """
Wrap a plain pattern factory so calls return deferred build recipes. Wrap a plain pattern factory so calls return deferred build recipes.
Use as either `cell(fn)(...)` or `@cell`. 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: class BuildCellsView:
@ -1596,19 +1560,16 @@ class BuildLibrary(ILibrary):
The builder itself is not a normal readable library during authoring. The builder itself is not a normal readable library during authoring.
Instead, `validate()` and `build()` create a temporary build-session library Instead, `validate()` and `build()` create a temporary build-session library
that recipes can read from and write helper cells into while dependencies that recipes can read from and write helper cells into while dependencies
are resolved. `build()` then freezes the builder on success and returns a are resolved. `build()` then freezes the builder on success and returns the
normal library-like object carrying a `build_report`. built library plus a `BuildReport`.
""" """
def __init__(self, *, check_on_register: bool = False) -> None: def __init__(self) -> None:
self.check_on_register = check_on_register
self.cells = BuildCellsView(self) self.cells = BuildCellsView(self)
self.last_build_report: BuildReport | None = None
self._frozen = False self._frozen = False
self._declarations: dict[str, _PatternDeclaration | _RecipeDeclaration] = {} self._declarations: dict[str, Pattern | _BuildRecipe] = {}
self._sources: list[_SourceDeclaration] = [] self._sources: list[tuple[ILibraryView, dict[str, str]]] = []
self._names: set[str] = set() self._names: dict[str, None] = {}
self._order: list[str] = []
def _active_session(self) -> '_BuildSessionLibrary | None': def _active_session(self) -> '_BuildSessionLibrary | None':
sessions = _ACTIVE_BUILD_SESSIONS.get() sessions = _ACTIVE_BUILD_SESSIONS.get()
@ -1622,7 +1583,7 @@ class BuildLibrary(ILibrary):
raise BuildError( raise BuildError(
f'BuildLibrary.{operation}() is only available while validate() or build() is running. ' f'BuildLibrary.{operation}() is only available while validate() or build() is running. '
'Use the built output library for reads.' 'Use the built output library for reads.'
) )
return session return session
def _assert_editable(self) -> None: def _assert_editable(self) -> None:
@ -1633,7 +1594,7 @@ class BuildLibrary(ILibrary):
session = self._active_session() session = self._active_session()
if session is not None: if session is not None:
return iter(session) return iter(session)
return iter(self._order) return iter(self._names)
def __len__(self) -> int: def __len__(self) -> int:
session = self._active_session() session = self._active_session()
@ -1653,7 +1614,7 @@ class BuildLibrary(ILibrary):
def __setitem__( def __setitem__(
self, self,
key: str, key: str,
value: 'Pattern | _BuildRecipe | Callable[[], Pattern]', value: 'Pattern | _BuildRecipe',
) -> None: ) -> None:
session = self._active_session() session = self._active_session()
if session is not None: if session is not None:
@ -1664,26 +1625,15 @@ class BuildLibrary(ILibrary):
if key in self._names: if key in self._names:
raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!') raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!')
declaration: _PatternDeclaration | _RecipeDeclaration
if isinstance(value, _BuildRecipe): if isinstance(value, _BuildRecipe):
declaration = _RecipeDeclaration(value) declaration = value
else: else:
if callable(value): if callable(value):
raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.') raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.')
declaration = _PatternDeclaration(value) declaration = value
self._declarations[key] = declaration self._declarations[key] = declaration
self._names.add(key) self._names[key] = None
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: def __delitem__(self, key: str) -> None:
session = self._active_session() session = self._active_session()
@ -1695,8 +1645,7 @@ class BuildLibrary(ILibrary):
if key not in self._declarations: if key not in self._declarations:
raise KeyError(key) raise KeyError(key)
del self._declarations[key] del self._declarations[key]
self._names.remove(key) del self._names[key]
self._order.remove(key)
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
session = self._active_session() session = self._active_session()
@ -1711,10 +1660,77 @@ class BuildLibrary(ILibrary):
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns, rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
mutate_other: bool = False, mutate_other: bool = False,
) -> dict[str, str]: ) -> dict[str, str]:
from .pattern import map_targets # noqa: PLC0415
session = self._active_session() session = self._active_session()
if session is not None: if session is not None:
return session.add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) 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( def rename(
self, self,
@ -1723,12 +1739,11 @@ class BuildLibrary(ILibrary):
move_references: bool = False, move_references: bool = False,
) -> Self: ) -> 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. During authoring, declared cells must be registered under their
Declared/generated cells must be registered under their intended final intended final names and imported source cells must be renamed through
names. `move_references=True` is intentionally unsupported here because `add_source(...)`.
deferred recipes and declaration internals cannot be rewritten safely.
""" """
session = self._active_session() session = self._active_session()
if session is not None: if session is not None:
@ -1742,49 +1757,14 @@ class BuildLibrary(ILibrary):
raise BuildError( raise BuildError(
f'Cannot rename declared build cell "{old_name}" during authoring. ' f'Cannot rename declared build cell "{old_name}" during authoring. '
'Register it under the intended final name instead.' 'Register it under the intended final name instead.'
) )
if old_name not in self._names: if old_name not in self._names:
raise LibraryError(f'"{old_name}" does not exist in the builder.') raise LibraryError(f'"{old_name}" does not exist in the builder.')
if new_name in self._names: raise BuildError(
raise LibraryError(f'"{new_name}" already exists in the builder.') f'Cannot rename imported source cell "{old_name}" during authoring. '
if move_references: 'Choose visible source names with add_source(..., rename_theirs=..., rename_when=...).'
raise BuildError(
'BuildLibrary.rename(..., move_references=True) is not supported for imported source cells. '
'Builder-level renames only change the visible imported name.'
) )
source_index = next(
(idx for idx, spec in enumerate(self._sources) if old_name in spec.visible_to_source),
None,
)
if source_index is None:
raise BuildError(
f'Cannot rename "{old_name}" during authoring because only imported source-backed '
'cells may be renamed on a BuildLibrary.'
)
spec = self._sources[source_index]
source_name = spec.visible_to_source[old_name]
source_to_visible = dict(spec.source_to_visible)
visible_to_source = dict(spec.visible_to_source)
order = list(spec.order)
source_to_visible[source_name] = new_name
del visible_to_source[old_name]
visible_to_source[new_name] = source_name
order[order.index(old_name)] = new_name
self._sources[source_index] = replace(
spec,
source_to_visible=source_to_visible,
visible_to_source=visible_to_source,
order=tuple(order),
)
self._names.remove(old_name)
self._names.add(new_name)
self._order[self._order.index(old_name)] = new_name
return self
def abstract(self, name: str) -> Abstract: def abstract(self, name: str) -> Abstract:
return self._require_active_session('abstract').abstract(name) return self._require_active_session('abstract').abstract(name)
@ -1800,54 +1780,48 @@ class BuildLibrary(ILibrary):
source: Mapping[str, 'Pattern'] | ILibraryView, source: Mapping[str, 'Pattern'] | ILibraryView,
*, *,
rename_theirs: Callable[[ILibraryView, str], str] | None = None, rename_theirs: Callable[[ILibraryView, str], str] | None = None,
rename_when: Literal['conflict', 'always'] = 'conflict',
) -> dict[str, str]: ) -> dict[str, str]:
""" """
Register an imported source-backed library with the builder. Register an imported source-backed library with the builder.
The source is not materialized immediately. Instead, its names and The source is not materialized immediately. Its names are scanned once
child graph are scanned once and stored as an import declaration. The to reserve visible builder names, then the source is read again when a
source may be renamed on entry to avoid collisions with existing 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. 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: Returns:
Mapping of `{source_name: visible_name}` for imported names that Mapping of `{source_name: visible_name}` for imported names that
were renamed while being added. 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() self._assert_editable()
view = source if isinstance(source, ILibraryView) else LibraryView(source) view = source if isinstance(source, ILibraryView) else LibraryView(source)
source_order = tuple(view.source_order()) source_order = tuple(view.source_order())
child_graph = view.child_graph(dangling='include') source_to_visible = _plan_source_names(
self,
source_order,
self._names,
rename_theirs = rename_theirs,
rename_when = rename_when,
)
source_to_visible: dict[str, str] = {} self._sources.append((view, dict(source_to_visible)))
visible_to_source: dict[str, str] = {} for source_name in source_order:
rename_map: dict[str, str] = {} visible = source_to_visible[source_name]
new_names: list[str] = [] self._names[visible] = None
return _source_rename_map(source_to_visible)
for name in source_order:
visible = name
if visible in self._names or visible in visible_to_source:
if rename_theirs is None:
raise LibraryError(f'Conflicting name while adding source: {name!r}')
visible = rename_theirs(self, name)
if visible in self._names or visible in visible_to_source:
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
rename_map[name] = visible
source_to_visible[name] = visible
visible_to_source[visible] = name
new_names.append(visible)
self._sources.append(_SourceDeclaration(
library=view,
source_to_visible=dict(source_to_visible),
visible_to_source=dict(visible_to_source),
child_graph={name: set(children) for name, children in child_graph.items()},
order=tuple(source_to_visible[name] for name in source_order),
))
for visible in new_names:
self._names.add(visible)
self._order.append(visible)
return rename_map
def validate( def validate(
self, self,
@ -1862,8 +1836,7 @@ class BuildLibrary(ILibrary):
execution path used by `build()`. Any generated library is discarded execution path used by `build()`. Any generated library is discarded
after validation completes. after validation completes.
""" """
report, _output = self._run_build(names=names, output='overlay', allow_dangling=allow_dangling, persist_output=False) _session, report = self._run_build(names=names, allow_dangling=allow_dangling)
self.last_build_report = report
return report return report
def build( def build(
@ -1871,9 +1844,9 @@ class BuildLibrary(ILibrary):
*, *,
output: Literal['overlay', 'library'] = 'overlay', output: Literal['overlay', 'library'] = 'overlay',
allow_dangling: bool = False, 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: Args:
output: `'overlay'` preserves imported source-backed cells where output: `'overlay'` preserves imported source-backed cells where
@ -1882,20 +1855,23 @@ class BuildLibrary(ILibrary):
allow_dangling: If `False`, fail the build when the completed allow_dangling: If `False`, fail the build when the completed
library still contains dangling references. library still contains dangling references.
""" """
if output not in ('overlay', 'library'):
raise ValueError(f'Unknown build output mode: {output!r}')
self._assert_editable() 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._frozen = True
self.last_build_report = report return built_output, report
return built_output
def _run_build( def _run_build(
self, self,
*, *,
names: Sequence[str] | None, names: Sequence[str] | None,
output: Literal['overlay', 'library'],
allow_dangling: bool, allow_dangling: bool,
persist_output: bool, ) -> tuple['_BuildSessionLibrary', BuildReport]:
) -> tuple[BuildReport, BuiltLibrary | ILibrary | None]:
roots = tuple(dict.fromkeys(names if names is not None else self._declarations.keys())) 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] unknown = [name for name in roots if name not in self._names]
if unknown: if unknown:
@ -1909,19 +1885,11 @@ class BuildLibrary(ILibrary):
session.materialize_many(roots) session.materialize_many(roots)
if not allow_dangling: if not allow_dangling:
session.child_graph(dangling='error') 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: finally:
_ACTIVE_BUILD_SESSIONS.reset(token) _ACTIVE_BUILD_SESSIONS.reset(token)
report = session.build_report(roots) report = session.build_report(roots)
if built_output is not None: return session, report
built_output.build_report = report
return report, built_output
class _BuildSessionLibrary(ILibrary): class _BuildSessionLibrary(ILibrary):
@ -1935,71 +1903,62 @@ class _BuildSessionLibrary(ILibrary):
""" """
def __init__(self, builder: BuildLibrary) -> None: def __init__(self, builder: BuildLibrary) -> None:
from .file.gdsii_lazy_core import BuiltOverlayLibrary, _SourceEntry, _SourceLayer # noqa: PLC0415 from .file.gdsii_lazy_core import OverlayLibrary # noqa: PLC0415
self._builder = builder self._builder = builder
self._overlay = BuiltOverlayLibrary() self._overlay = OverlayLibrary()
self._source_entry_type = _SourceEntry self._built: set[str] = set()
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._declared_stack: list[str] = []
self._emission_stack: list[str] = [] self._names = dict(builder._names)
self._emission_via_stack: list[emitted_via_t] = []
self._names = set(builder._names)
self._order = list(builder._order)
self._provenance: dict[str, CellProvenance] = {} self._provenance: dict[str, CellProvenance] = {}
self._owned_cells: defaultdict[str, list[str]] = defaultdict(list)
self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set) self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set)
self._install_sources() self._install_sources()
def _install_sources(self) -> None: def _install_sources(self) -> None:
for spec in self._builder._sources: for source_library, source_to_visible in self._builder._sources:
layer = self._source_layer_type( source_order = source_library.source_order()
library=spec.library, expected_names = set(source_to_visible)
source_to_visible=dict(spec.source_to_visible), actual_names = set(source_order)
visible_to_source=dict(spec.visible_to_source), if actual_names != expected_names:
child_graph={name: set(children) for name, children in spec.child_graph.items()}, added_names = sorted(actual_names - expected_names)
order=list(spec.order), removed_names = sorted(expected_names - actual_names)
) detail = []
layer_index = len(self._overlay._layers) if added_names:
self._overlay._layers.append(layer) detail.append(f'added={added_names}')
source_info = getattr(spec.library, 'library_info', None) if removed_names:
source_meta = dict(source_info) if isinstance(source_info, dict) else None 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().'
)
for source_name, visible_name in spec.source_to_visible.items(): def rename_source(_lib: ILibraryView, name: str, *, mapping: Mapping[str, str] = source_to_visible) -> str:
self._overlay._entries[visible_name] = self._source_entry_type( return mapping[name]
layer_index=layer_index,
source_name=source_name, self._overlay.add_source(
) source_library,
if visible_name not in self._overlay._order: rename_theirs = rename_source,
self._overlay._order.append(visible_name) rename_when = 'always',
)
for source_name in source_order:
visible_name = source_to_visible[source_name]
self._provenance[visible_name] = CellProvenance( self._provenance[visible_name] = CellProvenance(
final_name=visible_name, requested_name = source_name,
requested_name=source_name, kind = 'source',
kind='source', owner_declared_name = None,
owner_declared_name=None, build_chain = (),
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]: 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: def __len__(self) -> int:
return len(self._names) return len(self._names)
def __contains__(self, key: object) -> bool: 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:
self._names.add(key)
self._order.append(key)
def _current_declared(self) -> str | None: def _current_declared(self) -> str | None:
if not self._declared_stack: if not self._declared_stack:
@ -2020,14 +1979,6 @@ class _BuildSessionLibrary(ILibrary):
if provenance is not None and provenance.kind == 'source': if provenance is not None and provenance.kind == 'source':
raise BuildError(f'Cannot {operation} imported source cell "{key}" during an active build session.') 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( def rename(
self, self,
old_name: str, old_name: str,
@ -2046,26 +1997,13 @@ class _BuildSessionLibrary(ILibrary):
raise LibraryError(f'"{new_name}" already exists in the library.') raise LibraryError(f'"{new_name}" already exists in the library.')
self._overlay.rename(old_name, new_name, move_references=move_references) self._overlay.rename(old_name, new_name, move_references=move_references)
self._names.discard(old_name) self._names = {
self._names.add(new_name) new_name if name == old_name else name: None
if old_name in self._order: for name in self._names
idx = self._order.index(old_name) }
self._order[idx] = new_name
provenance = self._provenance.pop(old_name) provenance = self._provenance.pop(old_name)
requested_name = provenance.requested_name self._provenance[new_name] = provenance
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 return self
def __getitem__(self, key: str) -> 'Pattern': def __getitem__(self, key: str) -> 'Pattern':
@ -2087,31 +2025,20 @@ class _BuildSessionLibrary(ILibrary):
pattern = value() if callable(value) else value pattern = value() if callable(value) else value
self._overlay[key] = pattern self._overlay[key] = pattern
self._touch_name(key) self._names.setdefault(key, None)
kind: Literal['declared', 'helper'] 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: if current is not None and key == current:
kind = 'declared' kind = 'declared'
via = 'declaration'
else: else:
kind = 'helper' kind = 'helper'
if not self._emission_via_stack:
via = 'helper_write'
self._emission_stack.append(key) self._provenance[key] = CellProvenance(
try: requested_name = key,
self._record_provenance( kind = kind,
final_name=key, owner_declared_name = current if kind == 'helper' else key,
requested_name=key, build_chain = tuple(self._declared_stack),
kind=kind,
owner_declared_name=current if kind == 'helper' else key,
emitted_via=via,
build_chain=tuple(self._declared_stack),
renamed_from=None,
) )
finally:
self._emission_stack.pop()
def __delitem__(self, key: str) -> None: def __delitem__(self, key: str) -> None:
if key not in self._overlay: if key not in self._overlay:
@ -2120,15 +2047,10 @@ class _BuildSessionLibrary(ILibrary):
raise KeyError(key) raise KeyError(key)
self._guard_mutable_output_name(key, operation='delete') self._guard_mutable_output_name(key, operation='delete')
provenance = self._provenance.get(key)
if key in self._overlay: if key in self._overlay:
del self._overlay[key] del self._overlay[key]
self._names.discard(key) self._names.pop(key, None)
if key in self._order:
self._order.remove(key)
self._provenance.pop(key, None) 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: def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
self[key_self] = copy.deepcopy(other[key_other]) self[key_self] = copy.deepcopy(other[key_other])
@ -2139,52 +2061,21 @@ class _BuildSessionLibrary(ILibrary):
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns, rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
mutate_other: bool = False, mutate_other: bool = False,
) -> dict[str, str]: ) -> dict[str, str]:
self._emission_via_stack.append('tree_merge') rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
try:
rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
finally:
self._emission_via_stack.pop()
current = self._current_declared() current = self._current_declared()
for old_name, new_name in rename_map.items(): for old_name, new_name in rename_map.items():
if new_name in self._provenance: if new_name in self._provenance:
self._provenance[new_name] = replace( self._provenance[new_name] = replace(
self._provenance[new_name], self._provenance[new_name],
requested_name=old_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,
owner_declared_name=current if current is not None else self._provenance[new_name].owner_declared_name, )
)
return rename_map return rename_map
def _record_provenance(
self,
*,
final_name: str,
requested_name: str,
kind: Literal['declared', 'helper'],
owner_declared_name: str | None,
emitted_via: emitted_via_t,
build_chain: tuple[str, ...],
renamed_from: str | None,
) -> None:
self._provenance[final_name] = CellProvenance(
final_name=final_name,
requested_name=requested_name,
kind=kind,
owner_declared_name=owner_declared_name,
emitted_via=emitted_via,
build_chain=build_chain,
renamed_from=renamed_from,
)
if owner_declared_name is not None and final_name not in self._owned_cells[owner_declared_name]:
self._owned_cells[owner_declared_name].append(final_name)
def _wrap_error(self, name: str, exc: Exception) -> BuildError: 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) chain = tuple(self._declared_stack)
msg = [f'Failed while building declared cell "{name}"'] 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: if chain:
msg.append(f'Dependency chain: {" -> ".join(chain)}') msg.append(f'Dependency chain: {" -> ".join(chain)}')
msg.append(f'Cause: {exc}') msg.append(f'Cause: {exc}')
@ -2202,36 +2093,33 @@ class _BuildSessionLibrary(ILibrary):
def _ensure_declared(self, name: str) -> None: def _ensure_declared(self, name: str) -> None:
from .pattern import Pattern # noqa: PLC0415 from .pattern import Pattern # noqa: PLC0415
state = self._states[name] if name in self._built:
if state == 'built':
return return
if state == 'building': if name in self._declared_stack:
chain = ' -> '.join(self._declared_stack + [name]) chain = ' -> '.join(self._declared_stack + [name])
raise BuildError(f'Cycle detected while building declared cells: {chain}') raise BuildError(f'Cycle detected while building declared cells: {chain}')
declaration = self._builder._declarations[name] declaration = self._builder._declarations[name]
self._states[name] = 'building'
self._declared_stack.append(name) self._declared_stack.append(name)
try: try:
if isinstance(declaration, _PatternDeclaration): if isinstance(declaration, _BuildRecipe):
pattern = declaration.pattern.deepcopy() for dep in declaration.explicit_dependencies:
else:
for dep in declaration.recipe.explicit_dependencies:
self._ensure_named(dep) 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): if not isinstance(pattern, Pattern):
raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern')
else:
pattern = declaration.deepcopy()
if name in self._overlay: if name in self._overlay:
if self._overlay[name] is not pattern: if self._overlay[name] is not pattern:
raise BuildError( raise BuildError(
f'Recipe for "{name}" wrote a different pattern into the session under its own name.' f'Recipe for "{name}" wrote a different pattern into the session under its own name.'
) )
else: else:
self[name] = pattern self[name] = pattern
self._states[name] = 'built' self._built.add(name)
except Exception as exc: except Exception as exc:
self._states[name] = 'unbuilt'
raise self._wrap_error(name, exc) from exc raise self._wrap_error(name, exc) from exc
finally: finally:
self._declared_stack.pop() self._declared_stack.pop()
@ -2260,24 +2148,19 @@ class _BuildSessionLibrary(ILibrary):
name: frozenset(self._dependency_graph.get(name, set())) name: frozenset(self._dependency_graph.get(name, set()))
for name in self._builder._declarations for name in self._builder._declarations
if name in self._dependency_graph or name in requested_roots 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( return BuildReport(
requested_roots=tuple(dict.fromkeys(requested_roots)), requested_roots = tuple(dict.fromkeys(requested_roots)),
provenance=dict(self._provenance), provenance = dict(self._provenance),
owned_cells=owned_cells, dependency_graph = dependency_graph,
dependency_graph=dependency_graph, )
)
def to_overlay(self) -> ILibrary: def to_overlay(self) -> ILibrary:
return self._overlay 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()} mapping = {name: self._overlay[name] for name in self._overlay.source_order()}
return BuiltLibrary(mapping) return Library(mapping)
class LazyLibrary(ILibrary): class LazyLibrary(ILibrary):

View file

@ -1,12 +1,47 @@
from collections.abc import Iterator
import pytest import pytest
from ..builder import Pather from ..builder import Pather
from ..error import BuildError from ..error import BuildError
from ..library import BuildLibrary, BuiltLibrary, Library, cell from ..library import BuildLibrary, BuildReport, ILibraryView, Library, cell, dangling_mode_t
from ..pattern import Pattern from ..pattern import Pattern
from ..ports import Port from ..ports import Port
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: def test_build_library_traces_declared_dependencies_out_of_order() -> None:
builder = BuildLibrary() builder = BuildLibrary()
@ -19,12 +54,12 @@ def test_build_library_traces_declared_dependencies_out_of_order() -> None:
builder.cells.parent = cell(make_parent)(builder) builder.cells.parent = cell(make_parent)(builder)
builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) builder["child"] = Pattern(ports={"p": Port((0, 0), 0)})
built = builder.build() built, report = builder.build()
assert "parent" in built assert "parent" in built
assert "child" in built assert "child" in built
assert built.build_report.dependency_graph["parent"] == frozenset({"child"}) assert report.dependency_graph["parent"] == frozenset({"child"})
assert built.build_report.provenance["parent"].kind == "declared" assert report.provenance["parent"].kind == "declared"
def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None: def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None:
@ -40,18 +75,46 @@ def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None
return top return top
builder.cells.top = cell(make_top)(builder) builder.cells.top = cell(make_top)(builder)
built = builder.build() _built, report = builder.build()
report = built.build_report
helpers = [ 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" 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 len(helpers) == 2
assert all(prov.emitted_via == "tree_merge" for prov in helpers) assert any(name != prov.requested_name for name, prov in helpers)
assert any(prov.renamed_from == "_helper" for 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: def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None:
@ -64,10 +127,10 @@ def test_build_library_requires_build_session_for_reads_and_freezes_after_build(
with pytest.raises(BuildError, match="write-only"): with pytest.raises(BuildError, match="write-only"):
_ = builder.cells.leaf _ = builder.cells.leaf
built = builder.build(output="library") built, report = builder.build(output="library")
assert isinstance(built, BuiltLibrary) assert isinstance(built, Library)
assert built.build_report.requested_roots == ("leaf",) assert report.requested_roots == ("leaf",)
with pytest.raises(BuildError, match="frozen"): with pytest.raises(BuildError, match="frozen"):
builder["later"] = Pattern() builder["later"] = Pattern()
@ -95,21 +158,6 @@ def test_build_library_validate_is_retryable_after_failure() -> None:
assert report.dependency_graph["parent"] == frozenset({"child"}) 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: def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None:
builder = BuildLibrary() builder = BuildLibrary()
builder["child"] = Pattern() builder["child"] = Pattern()
@ -134,6 +182,14 @@ def test_build_library_validate_rejects_removed_output_argument() -> None:
builder.validate(output="library") # type: ignore[call-arg] 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: def test_build_library_allows_helper_writes_via_pather() -> None:
builder = BuildLibrary() builder = BuildLibrary()
builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)}) builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)})
@ -147,50 +203,169 @@ def test_build_library_allows_helper_writes_via_pather() -> None:
return top return top
builder.cells.top = cell(make_top)(builder) 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.kind == "helper"
assert helper_prov.owner_declared_name == "top" 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: def test_build_library_preserves_source_cells_and_records_source_provenance() -> None:
source = Library({"src": Pattern()}) source = Library({"src": Pattern()})
builder = BuildLibrary() builder = BuildLibrary()
builder.add_source(source) builder.add_source(source)
builder.cells.top = cell(lambda: Pattern())() builder.cells.top = cell(lambda: Pattern())()
built = builder.build() built, report = builder.build()
assert "src" in built assert "src" in built
assert built.build_report.provenance["src"].kind == "source" assert report.provenance["src"].kind == "source"
assert built.build_report.provenance["src"].emitted_via == "source_import"
def test_build_library_can_rename_imported_source_cells_during_authoring() -> None: def test_build_library_add_source_can_rename_every_source_cell() -> None:
source = Library() source = Library()
source["child"] = Pattern() source["child"] = Pattern()
parent = Pattern() parent = Pattern()
parent.ref("child") parent.ref("child")
source["parent"] = parent 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"].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() builder = BuildLibrary()
builder.add_source(source) builder.add_source(source)
builder.rename("child", "renamed_child") source["late"] = Pattern()
built = builder.build() with pytest.raises(BuildError, match="Do not structurally mutate source libraries"):
builder.build()
assert "renamed_child" in built
assert "child" not in built
assert "renamed_child" in built["parent"].refs
assert built.build_report.provenance["renamed_child"].source_name == "child"
def test_build_library_rejects_move_references_for_source_rename() -> None: 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()
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_rejects_renaming_imported_source_cells_during_authoring() -> None:
builder = BuildLibrary() builder = BuildLibrary()
builder.add_source(Library({"src": Pattern()})) 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) builder.rename("src", "renamed_src", move_references=True)
@ -202,7 +377,7 @@ def test_build_library_rejects_renaming_declared_cells_during_authoring() -> Non
builder.rename("declared", "renamed_declared") 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() builder = BuildLibrary()
def make_top(lib: BuildLibrary) -> Pattern: def make_top(lib: BuildLibrary) -> Pattern:
@ -213,18 +388,16 @@ def test_build_library_helper_rename_updates_provenance_and_owned_cells() -> Non
return top return top
builder.cells.top = cell(make_top)(builder) builder.cells.top = cell(make_top)(builder)
built = builder.build() built, report = builder.build()
report = built.build_report
assert "final_helper" in built assert "final_helper" in built
assert "_helper" not in built assert "_helper" not in built
assert "final_helper" in report.owned_cells["top"] owned = _owned_by(report, "top")
assert "_helper" not in report.owned_cells["top"] assert "final_helper" in owned
assert "_helper" not in owned
prov = report.provenance["final_helper"] prov = report.provenance["final_helper"]
assert prov.kind == "helper" assert prov.kind == "helper"
assert prov.requested_name == "_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: def test_build_library_helper_delete_removes_provenance_and_ownership() -> None:
@ -236,12 +409,11 @@ def test_build_library_helper_delete_removes_provenance_and_ownership() -> None:
return Pattern() return Pattern()
builder.cells.top = cell(make_top)(builder) builder.cells.top = cell(make_top)(builder)
built = builder.build() built, report = builder.build()
report = built.build_report
assert "_helper" not in built assert "_helper" not in built
assert "_helper" not in report.provenance 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: def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None:
@ -258,13 +430,11 @@ def test_build_library_helper_rename_after_auto_rename_preserves_requested_name(
return top return top
builder.cells.top = cell(make_top)(builder) builder.cells.top = cell(make_top)(builder)
built = builder.build() built, report = builder.build()
report = built.build_report
assert "final_helper" in built assert "final_helper" in built
prov = report.provenance["final_helper"] prov = report.provenance["final_helper"]
assert prov.requested_name == "_helper" assert prov.requested_name == "_helper"
assert prov.renamed_from == "_helper"
def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None: def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None:

View file

@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
import numpy import numpy
import pytest
from numpy.testing import assert_allclose from numpy.testing import assert_allclose
from ..file import gdsii, gdsii_lazy 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'} 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: def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_roundtrip.gds' gds_file = tmp_path / 'lazy_roundtrip.gds'
src = _make_lazy_port_library() src = _make_lazy_port_library()

View file

@ -1,13 +1,11 @@
from typing import Any
import pytest import pytest
import numpy import numpy
from numpy import pi from numpy import pi
from numpy.testing import assert_allclose, assert_equal from numpy.testing import assert_allclose, assert_equal
from masque import Pather, Library, Pattern, Port from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool, Tool from masque.builder.tools import PathTool
from masque.error import BuildError, PortError, PatternError from masque.error import BuildError, PortError
@pytest.fixture @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['A'].offset[0], -20000)
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) 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: def test_pather_each_bound() -> None:
lib = Library() lib = Library()
tool = PathTool(layer='M1', width=1000) tool = PathTool(layer='M1', width=1000)