Compare commits

..

8 commits

10 changed files with 641 additions and 473 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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
@ -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, _source_rename_map, dangling_mode_t
from ..pattern import Pattern, map_targets
from ..utils import apply_transforms
from ..utils.ports2data import data_to_ports
@ -303,26 +303,30 @@ 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`.
"""
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
if 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}')
rename_map[name] = visible
source_to_visible[name] = visible
visible_to_source[visible] = name
source_to_visible = _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,
@ -339,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,
@ -549,20 +553,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):

View file

@ -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
@ -69,9 +70,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 +81,18 @@ 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 +108,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]]
@ -166,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',
) -> 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):
"""
Interface for a read-only library.
@ -1467,42 +1491,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.
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. """
@ -1516,41 +1504,17 @@ 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:
"""
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:
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:
@ -1596,19 +1560,16 @@ 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
def __init__(self) -> None:
self.cells = BuildCellsView(self)
self.last_build_report: BuildReport | None = None
self._frozen = False
self._declarations: dict[str, _PatternDeclaration | _RecipeDeclaration] = {}
self._sources: list[_SourceDeclaration] = []
self._names: set[str] = set()
self._order: list[str] = []
self._declarations: dict[str, Pattern | _BuildRecipe] = {}
self._sources: list[tuple[ILibraryView, dict[str, str]]] = []
self._names: dict[str, None] = {}
def _active_session(self) -> '_BuildSessionLibrary | None':
sessions = _ACTIVE_BUILD_SESSIONS.get()
@ -1633,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()
@ -1653,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:
@ -1664,26 +1625,15 @@ 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)
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
self._names[key] = None
def __delitem__(self, key: str) -> None:
session = self._active_session()
@ -1695,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()
@ -1711,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,
@ -1723,12 +1739,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:
@ -1745,46 +1760,11 @@ 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.'
f'Cannot rename imported source cell "{old_name}" during authoring. '
'Choose visible source names with add_source(..., rename_theirs=..., rename_when=...).'
)
source_index = next(
(idx for idx, spec in enumerate(self._sources) if old_name in spec.visible_to_source),
None,
)
if source_index is None:
raise BuildError(
f'Cannot rename "{old_name}" during authoring because only imported source-backed '
'cells may be renamed on a BuildLibrary.'
)
spec = self._sources[source_index]
source_name = spec.visible_to_source[old_name]
source_to_visible = dict(spec.source_to_visible)
visible_to_source = dict(spec.visible_to_source)
order = list(spec.order)
source_to_visible[source_name] = new_name
del visible_to_source[old_name]
visible_to_source[new_name] = source_name
order[order.index(old_name)] = new_name
self._sources[source_index] = replace(
spec,
source_to_visible=source_to_visible,
visible_to_source=visible_to_source,
order=tuple(order),
)
self._names.remove(old_name)
self._names.add(new_name)
self._order[self._order.index(old_name)] = new_name
return self
def abstract(self, name: str) -> Abstract:
return self._require_active_session('abstract').abstract(name)
@ -1800,54 +1780,48 @@ 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 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 = _plan_source_names(
self,
source_order,
self._names,
rename_theirs = rename_theirs,
rename_when = rename_when,
)
source_to_visible: dict[str, str] = {}
visible_to_source: dict[str, str] = {}
rename_map: dict[str, str] = {}
new_names: list[str] = []
for name in source_order:
visible = name
if visible in self._names or visible in visible_to_source:
if rename_theirs is None:
raise LibraryError(f'Conflicting name while adding source: {name!r}')
visible = rename_theirs(self, name)
if visible in self._names or visible in visible_to_source:
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
rename_map[name] = visible
source_to_visible[name] = visible
visible_to_source[visible] = name
new_names.append(visible)
self._sources.append(_SourceDeclaration(
library=view,
source_to_visible=dict(source_to_visible),
visible_to_source=dict(visible_to_source),
child_graph={name: set(children) for name, children in child_graph.items()},
order=tuple(source_to_visible[name] for name in source_order),
))
for visible in new_names:
self._names.add(visible)
self._order.append(visible)
return rename_map
self._sources.append((view, dict(source_to_visible)))
for source_name in source_order:
visible = source_to_visible[source_name]
self._names[visible] = None
return _source_rename_map(source_to_visible)
def validate(
self,
@ -1862,8 +1836,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 +1844,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 +1855,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 +1885,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,71 +1903,62 @@ 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 # noqa: PLC0415
self._builder = builder
self._overlay = BuiltOverlayLibrary()
self._source_entry_type = _SourceEntry
self._source_layer_type = _SourceLayer
self._states: dict[str, Literal['unbuilt', 'building', 'built']] = {
name: 'unbuilt' for name in builder._declarations
}
self._overlay = OverlayLibrary()
self._built: set[str] = set()
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._names = dict(builder._names)
self._provenance: dict[str, CellProvenance] = {}
self._owned_cells: defaultdict[str, list[str]] = defaultdict(list)
self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set)
self._install_sources()
def _install_sources(self) -> None:
for spec in self._builder._sources:
layer = self._source_layer_type(
library=spec.library,
source_to_visible=dict(spec.source_to_visible),
visible_to_source=dict(spec.visible_to_source),
child_graph={name: set(children) for name, children in spec.child_graph.items()},
order=list(spec.order),
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)
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().'
)
layer_index = len(self._overlay._layers)
self._overlay._layers.append(layer)
source_info = getattr(spec.library, 'library_info', None)
source_meta = dict(source_info) if isinstance(source_info, dict) else None
for source_name, visible_name in spec.source_to_visible.items():
self._overlay._entries[visible_name] = self._source_entry_type(
layer_index=layer_index,
source_name=source_name,
def rename_source(_lib: ILibraryView, name: str, *, mapping: Mapping[str, str] = source_to_visible) -> str:
return mapping[name]
self._overlay.add_source(
source_library,
rename_theirs = rename_source,
rename_when = 'always',
)
if visible_name not in self._overlay._order:
self._overlay._order.append(visible_name)
for source_name in source_order:
visible_name = source_to_visible[source_name]
self._provenance[visible_name] = CellProvenance(
final_name=visible_name,
requested_name = source_name,
kind = 'source',
owner_declared_name = None,
emitted_via='source_import',
build_chain = (),
renamed_from=source_name if visible_name != source_name else None,
source_name=source_name,
source_metadata=source_meta,
)
def __iter__(self) -> Iterator[str]:
return (name for name in self._order if name in self._names)
return iter(self._names)
def __len__(self) -> int:
return len(self._names)
def __contains__(self, key: object) -> bool:
return key in self._names or key in self._overlay
def _touch_name(self, key: str) -> None:
if key not in self._names:
self._names.add(key)
self._order.append(key)
return key in self._names
def _current_declared(self) -> str | None:
if not self._declared_stack:
@ -2020,14 +1979,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,
@ -2046,26 +1997,13 @@ 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)
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]
]
self._provenance[new_name] = provenance
return self
def __getitem__(self, key: str) -> 'Pattern':
@ -2087,31 +2025,20 @@ 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']
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,
self._provenance[key] = CellProvenance(
requested_name = key,
kind = kind,
owner_declared_name = current if kind == 'helper' else key,
emitted_via=via,
build_chain = tuple(self._declared_stack),
renamed_from=None,
)
finally:
self._emission_stack.pop()
def __delitem__(self, key: str) -> None:
if key not in self._overlay:
@ -2120,15 +2047,10 @@ 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._names.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:
self[key_self] = copy.deepcopy(other[key_other])
@ -2139,11 +2061,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()
current = self._current_declared()
for old_name, new_name in rename_map.items():
@ -2151,40 +2069,13 @@ 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
def _record_provenance(
self,
*,
final_name: str,
requested_name: str,
kind: Literal['declared', 'helper'],
owner_declared_name: str | None,
emitted_via: emitted_via_t,
build_chain: tuple[str, ...],
renamed_from: str | None,
) -> None:
self._provenance[final_name] = CellProvenance(
final_name=final_name,
requested_name=requested_name,
kind=kind,
owner_declared_name=owner_declared_name,
emitted_via=emitted_via,
build_chain=build_chain,
renamed_from=renamed_from,
)
if owner_declared_name is not None and final_name not in self._owned_cells[owner_declared_name]:
self._owned_cells[owner_declared_name].append(final_name)
def _wrap_error(self, name: str, exc: Exception) -> BuildError:
helper = self._emission_stack[-1] if self._emission_stack else None
chain = tuple(self._declared_stack)
msg = [f'Failed while building declared cell "{name}"']
if helper is not None and helper != name:
msg.append(f'while materializing helper/output "{helper}"')
if chain:
msg.append(f'Dependency chain: {" -> ".join(chain)}')
msg.append(f'Cause: {exc}')
@ -2202,25 +2093,23 @@ 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, _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:
@ -2229,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()
@ -2261,23 +2149,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):

View file

@ -1,12 +1,47 @@
from collections.abc import Iterator
import pytest
from ..builder import Pather
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 ..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:
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["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,18 +75,46 @@ 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()
(name, prov) for name, prov in report.provenance.items()
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)
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:
@ -64,10 +127,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()
@ -95,21 +158,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()
@ -134,6 +182,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,50 +203,169 @@ 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"
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()
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_can_rename_imported_source_cells_during_authoring() -> None:
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"].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.add_source(source)
builder.rename("child", "renamed_child")
source["late"] = Pattern()
built = builder.build()
assert "renamed_child" in built
assert "child" not in built
assert "renamed_child" in built["parent"].refs
assert built.build_report.provenance["renamed_child"].source_name == "child"
with pytest.raises(BuildError, match="Do not structurally mutate source libraries"):
builder.build()
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.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)
@ -202,7 +377,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 +388,16 @@ 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 +409,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,13 +430,11 @@ 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"]
assert prov.requested_name == "_helper"
assert prov.renamed_from == "_helper"
def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None:

View file

@ -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()

View file

@ -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)