[library / gdsii] cleanup using new library rework

This commit is contained in:
Jan Petykiewicz 2026-07-13 21:37:52 -07:00
commit 7b589e4f44
14 changed files with 696 additions and 391 deletions

View file

@ -606,6 +606,17 @@ unchanged until every borrowing view or overlay is finished, then close the
owning source explicitly. An eager `build(output='library')` result is detached
and does not need its sources afterward.
Lazy GDS sources no longer provide `with_ports_from_data()` or
`with_port_overrides()` convenience methods. Construct the generic view
directly instead: `PortsLibraryView(source, layers=...)` imports port data, and
`PortsLibraryView(source, ports=..., replace=...)` applies explicit overrides.
Generic borrowing views no longer expose GDS-specific `raw_struct_bytes()`,
`can_copy_raw_struct()`, or forwarded `library_info` attributes. Keep the
owning GDS source or the metadata returned by `readfile()` when direct access is
needed. `IBorrowing.source_cell()` now provides format-neutral per-cell
provenance; GDS writers use it internally to preserve safe raw copy-through.
Read-only `subtree()` results are now borrowed lazy views rather than eager
`LibraryView` snapshots. Creating one no longer loads its reachable patterns,
and the view preserves source ordering, hierarchy metadata, and lazy GDS
@ -681,8 +692,10 @@ erases the marker.
libraries and expose them through `borrowed_sources()`. Keep those sources open
and unchanged for the lifetime of the borrowing view. Owner libraries such as
`LazyLibrary` and the lazy GDS readers are materializable but do not implement
`IBorrowing`. GDS raw-structure copy-through remains a separate,
format-specific capability.
`IBorrowing`. Borrowing views may expose unchanged per-cell layout provenance
through `source_cell()`; format writers decide whether that provenance permits
raw copy-through. Raw GDS structure access remains confined to GDS sources and
writers.
`LibraryBuilder`, `OverlayLibrary`, and `PortsLibraryView` are new additive
library implementations. `LibraryBuilder` supports declarative `@cell` recipes

View file

@ -11,7 +11,7 @@ from typing import Any
from pprint import pformat
from masque import ILibrary, LibraryBuilder, Pather, Pattern, cell
from masque import ILibrary, LibraryBuilder, Pather, Pattern, PortsLibraryView, cell
from masque.file.gdsii import writefile
from masque.file.gdsii.lazy import readfile
@ -53,7 +53,7 @@ def main() -> None:
# imported on first materialization, but the raw source remains untouched
# until we build the final library.
gds_lib, _properties = readfile('circuit.gds')
builder.add_source(gds_lib.with_ports_from_data(layers=[(3, 0)], max_depth=1))
builder.add_source(PortsLibraryView(gds_lib, layers=[(3, 0)], max_depth=1))
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))

View file

@ -38,7 +38,7 @@ from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ...shapes import Polygon, Path, RectCollection
from ...repetition import Grid
from ...utils import layer_t, annotations_t
from ...library import Library, ILibrary
from ...library import Library
logger = logging.getLogger(__name__)
@ -58,6 +58,32 @@ def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
return numpy.rint(val).astype(numpy.int32)
def _write_header(
stream: IO[bytes],
meters_per_unit: float,
logical_units_per_unit: float,
library_name: str,
) -> None:
header = klamath.library.FileHeader(
name=library_name.encode('ASCII'),
user_units_per_db_unit=logical_units_per_unit,
meters_per_db_unit=meters_per_unit,
)
header.write(stream)
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
elements: list[klamath.elements.Element] = []
elements += _shapes_to_elements(pat.shapes)
elements += _labels_to_texts(pat.labels)
elements += _mrefs_to_grefs(pat.refs)
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
def _write_footer(stream: IO[bytes]) -> None:
records.ENDLIB.write(stream, None)
def write(
library: Mapping[str, Pattern],
stream: IO[bytes],
@ -99,29 +125,36 @@ def write(
library_name: Library name written into the GDSII file.
Default 'masque-klamath'.
"""
if not isinstance(library, ILibrary):
if isinstance(library, dict):
library = Library(library)
else:
library = Library(dict(library))
# Create library
header = klamath.library.FileHeader(
name=library_name.encode('ASCII'),
user_units_per_db_unit=logical_units_per_unit,
meters_per_db_unit=meters_per_unit,
)
header.write(stream)
_write_header(stream, meters_per_unit, logical_units_per_unit, library_name)
# Now create a structure for each pattern, and add in any Boundary and SREF elements
for name, pat in library.items():
elements: list[klamath.elements.Element] = []
elements += _shapes_to_elements(pat.shapes)
elements += _labels_to_texts(pat.labels)
elements += _mrefs_to_grefs(pat.refs)
_write_pattern_struct(stream, name, pat)
_write_footer(stream)
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
records.ENDLIB.write(stream, None)
def _writefile_impl(
writer: Callable[..., None],
library: Any,
filename: str | pathlib.Path,
*args: Any,
**kwargs: Any,
) -> None:
path = pathlib.Path(filename)
with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams
else:
stream = base_stream
try:
writer(library, stream, *args, **kwargs)
finally:
for ss in streams:
ss.close()
def writefile(
@ -141,21 +174,7 @@ def writefile(
*args: passed to `write()`
**kwargs: passed to `write()`
"""
path = pathlib.Path(filename)
with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams
else:
stream = base_stream
try:
write(library, stream, *args, **kwargs)
finally:
for ss in streams:
ss.close()
_writefile_impl(write, library, filename, *args, **kwargs)
def readfile(

View file

@ -18,7 +18,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import IO, TYPE_CHECKING, Any, cast
from collections import defaultdict
import gzip
import io
import logging
@ -35,19 +34,15 @@ from ...error import LibraryError
from ...library import (
ILibraryView,
IMaterializable,
PortsLibraryView,
dangling_mode_t,
)
from ...library.utils import _validate_dangling_mode
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Iterator, Sequence
import numpy
from numpy.typing import NDArray
from ...pattern import Pattern
from ...ports import Port
logger = logging.getLogger(__name__)
@ -205,98 +200,19 @@ class GdsLibrarySource(ILibraryView, IMaterializable):
return pat
def _raw_children(self, name: str) -> set[str]:
if name in self._cache:
return super()._raw_children(name)
return set(self._cells[name].children)
def child_graph(
def _raw_ref_transforms(
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
_validate_dangling_mode(dangling)
graph: dict[str, set[str]] = {}
for name in self._cell_order:
if name in self._cache:
graph[name] = {child for child, refs in self._cache[name].refs.items() if child is not None and refs}
else:
graph[name] = self._raw_children(name)
existing = set(graph)
dangling_refs = set().union(*(children - existing for children in graph.values()))
if dangling == 'error':
if dangling_refs:
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
return graph
if dangling == 'ignore':
return {name: {child for child in children if child in existing} for name, children in graph.items()}
for child in dangling_refs:
graph.setdefault(cast('str', child), set())
return graph
def subtree(
self,
tops: str | Sequence[str],
) -> ILibraryView:
return super().subtree(tops)
def with_ports_from_data(
self,
*,
layers: Sequence[tuple[int, int] | int],
max_depth: int = 0,
skip_subcells: bool = True,
ports: Mapping[str, Mapping[str, Port]] | None = None,
replace: bool = False,
) -> PortsLibraryView:
return PortsLibraryView(
self,
layers=layers,
max_depth=max_depth,
skip_subcells=skip_subcells,
ports=ports,
replace=replace,
)
def with_port_overrides(
self,
ports: Mapping[str, Mapping[str, Port]],
*,
replace: bool = False,
) -> PortsLibraryView:
return PortsLibraryView(
self,
ports=ports,
replace=replace,
)
def find_refs_local(
self,
name: str,
parent_graph: dict[str, set[str]] | None = None,
dangling: dangling_mode_t = 'error',
) -> dict[str, list[NDArray[numpy.float64]]]:
_validate_dangling_mode(dangling)
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
if parent_graph is None:
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
parent_graph = self.parent_graph(dangling=graph_mode)
if name not in self:
if name not in parent_graph:
return instances
if dangling == 'error':
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
if dangling == 'ignore':
return instances
for parent in parent_graph.get(name, set()):
if parent in self._cache:
for ref in self._cache[parent].refs.get(name, []):
instances[parent].append(ref.as_transforms())
continue
pat = self.materialize(parent, persist=False)
for ref in pat.refs.get(name, []):
instances[parent].append(ref.as_transforms())
return instances
parent: str,
target: str,
) -> list[NDArray[numpy.float64]]:
if parent in self._cache:
return super()._raw_ref_transforms(parent, target)
pat = self.materialize(parent, persist=False)
return [ref.as_transforms() for ref in pat.refs.get(target, ())]
def close(self) -> None:
self._source.close()

View file

@ -7,8 +7,7 @@ keeps its current behavior and performance profile.
from __future__ import annotations
from dataclasses import dataclass
from typing import IO, TYPE_CHECKING, Any, cast
from collections import defaultdict
from typing import IO, TYPE_CHECKING, Any
import gzip
import logging
import mmap
@ -23,19 +22,15 @@ from ...library import (
ILibraryView,
IMaterializable,
LibraryView,
PortsLibraryView,
dangling_mode_t,
)
from ...library.utils import _validate_dangling_mode
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Iterator, Sequence
from numpy.typing import NDArray
import pyarrow
from ...pattern import Pattern
from ...ports import Port
logger = logging.getLogger(__name__)
@ -304,6 +299,8 @@ class ArrowLibrary(ILibraryView, IMaterializable):
return self._materialize_patterns((name,), persist=persist)[name]
def _raw_children(self, name: str) -> set[str]:
if name in self._cache:
return super()._raw_children(name)
return set(self._payload.cells[name].children)
def _collect_raw_transforms(self, cell: _CellScan, target_id: int) -> list[NDArray[numpy.float64]]:
@ -343,67 +340,6 @@ class ArrowLibrary(ILibraryView, IMaterializable):
))
return rows
def child_graph(
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
_validate_dangling_mode(dangling)
graph: dict[str, set[str]] = {}
for name in self._payload.cell_order:
if name in self._cache:
graph[name] = {child for child, refs in self._cache[name].refs.items() if child is not None and refs}
else:
graph[name] = self._raw_children(name)
existing = set(graph)
dangling_refs = set().union(*(children - existing for children in graph.values()))
if dangling == 'error':
if dangling_refs:
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
return graph
if dangling == 'ignore':
return {name: {child for child in children if child in existing} for name, children in graph.items()}
for child in dangling_refs:
graph.setdefault(cast('str', child), set())
return graph
def subtree(
self,
tops: str | Sequence[str],
) -> ILibraryView:
return super().subtree(tops)
def with_ports_from_data(
self,
*,
layers: Sequence[tuple[int, int] | int],
max_depth: int = 0,
skip_subcells: bool = True,
ports: Mapping[str, Mapping[str, Port]] | None = None,
replace: bool = False,
) -> PortsLibraryView:
return PortsLibraryView(
self,
layers=layers,
max_depth=max_depth,
skip_subcells=skip_subcells,
ports=ports,
replace=replace,
)
def with_port_overrides(
self,
ports: Mapping[str, Mapping[str, Port]],
*,
replace: bool = False,
) -> PortsLibraryView:
return PortsLibraryView(
self,
ports=ports,
replace=replace,
)
def close(self) -> None:
data = self._source.data
if isinstance(data, mmap.mmap):
@ -418,39 +354,17 @@ class ArrowLibrary(ILibraryView, IMaterializable):
def __exit__(self, *_args: object) -> None:
self.close()
def find_refs_local(
def _raw_ref_transforms(
self,
name: str,
parent_graph: dict[str, set[str]] | None = None,
dangling: dangling_mode_t = 'error',
) -> dict[str, list[NDArray[numpy.float64]]]:
_validate_dangling_mode(dangling)
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
if parent_graph is None:
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
parent_graph = self.parent_graph(dangling=graph_mode)
if name not in self:
if name not in parent_graph:
return instances
if dangling == 'error':
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
if dangling == 'ignore':
return instances
target_id = self._payload.cells.get(name)
for parent in parent_graph.get(name, set()):
if parent in self._cache:
for ref in self._cache[parent].refs.get(name, []):
instances[parent].append(ref.as_transforms())
continue
if target_id is None or parent not in self._payload.cells:
continue
rows = self._collect_raw_transforms(self._payload.cells[parent], target_id.cell_id)
if rows:
instances[parent].extend(rows)
return instances
parent: str,
target: str,
) -> list[NDArray[numpy.float64]]:
if parent in self._cache:
return super()._raw_ref_transforms(parent, target)
target_cell = self._payload.cells.get(target)
if target_cell is None or parent not in self._payload.cells:
return []
return self._collect_raw_transforms(self._payload.cells[parent], target_cell.cell_id)
def readfile(

View file

@ -8,21 +8,16 @@ or remapped.
"""
from __future__ import annotations
from typing import IO, TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
import gzip
from typing import IO, TYPE_CHECKING, Any, Protocol, runtime_checkable
import logging
import pathlib
import klamath
from . import klamath as gdsii
from ..utils import tmpfile
from ...error import LibraryError
from ...library import IBorrowing, ILibraryView
from ...library.overlay import _materialize_detached_pattern
from ...library import IBorrowing, ILibraryView, IMaterializable
if TYPE_CHECKING:
from collections.abc import Mapping
import pathlib
from ...pattern import Pattern
@ -40,13 +35,42 @@ class _GdsInfoSource(Protocol):
class _GdsRawCellSource(Protocol):
"""GDS-specific raw-structure copy-through capability."""
def source_order(self) -> tuple[str, ...]: ...
def can_copy_raw_struct(self, name: str) -> bool: ...
def raw_struct_bytes(self, name: str) -> bytes: ...
def _resolve_raw_struct(
library: ILibraryView,
name: str,
) -> tuple[_GdsRawCellSource, str] | None:
"""Resolve an unchanged visible cell to a copyable raw GDS structure."""
current = library
current_name = name
seen: set[tuple[int, str]] = set()
while True:
key = (id(current), current_name)
if key in seen:
return None
seen.add(key)
if isinstance(current, _GdsRawCellSource):
if current.can_copy_raw_struct(current_name):
return current, current_name
return None
if not isinstance(current, IBorrowing):
return None
source_cell = current.source_cell(current_name)
if source_cell is None:
return None
source, source_name = source_cell
if source_name != current_name:
return None
current = source
current_name = source_name
def _get_write_info(
library: Mapping[str, Pattern] | ILibraryView,
*,
@ -85,14 +109,6 @@ def _get_write_info(
return meters_per_unit, logical_units_per_unit, library_name
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
elements: list[klamath.elements.Element] = []
elements += gdsii._shapes_to_elements(pat.shapes)
elements += gdsii._labels_to_texts(pat.labels)
elements += gdsii._mrefs_to_grefs(pat.refs)
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
def write(
library: Mapping[str, Pattern] | ILibraryView,
stream: IO[bytes],
@ -108,23 +124,24 @@ def write(
library_name=library_name,
)
header = klamath.library.FileHeader(
name=library_name.encode('ASCII'),
user_units_per_db_unit=logical_units_per_unit,
meters_per_db_unit=meters_per_unit,
)
header.write(stream)
gdsii._write_header(stream, meters_per_unit, logical_units_per_unit, library_name)
if isinstance(library, _GdsRawCellSource):
for name in library.source_order():
if library.can_copy_raw_struct(name):
stream.write(library.raw_struct_bytes(name))
else:
_write_pattern_struct(stream, name, _materialize_detached_pattern(cast('ILibraryView', library), name))
klamath.records.ENDLIB.write(stream, None)
return
names = library.source_order() if isinstance(library, ILibraryView) else tuple(library)
for name in names:
if isinstance(library, ILibraryView):
raw_struct = _resolve_raw_struct(library, name)
if raw_struct is not None:
raw_source, source_name = raw_struct
stream.write(raw_source.raw_struct_bytes(source_name))
continue
gdsii.write(cast('Mapping[str, Pattern]', library), stream, meters_per_unit, logical_units_per_unit, library_name)
if isinstance(library, IMaterializable):
pat = library.materialize(name, persist=False)
else:
pat = library[name]
gdsii._write_pattern_struct(stream, name, pat)
gdsii._write_footer(stream)
def writefile(
@ -135,24 +152,11 @@ def writefile(
logical_units_per_unit: float | None = None,
library_name: str | None = None,
) -> None:
path = pathlib.Path(filename)
with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams
else:
stream = base_stream
try:
write(
library,
stream,
meters_per_unit=meters_per_unit,
logical_units_per_unit=logical_units_per_unit,
library_name=library_name,
)
finally:
for ss in streams:
ss.close()
gdsii._writefile_impl(
write,
library,
filename,
meters_per_unit=meters_per_unit,
logical_units_per_unit=logical_units_per_unit,
library_name=library_name,
)

View file

@ -378,12 +378,20 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta):
dangling_list = sorted(dangling)
return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list))
def _raw_children(self, name: str) -> set[str]:
"""Return direct, non-empty named references for one pattern."""
return {
child
for child, refs in self[name].refs.items()
if child is not None and refs
}
def _raw_child_graph(self) -> tuple[dict[str, set[str]], set[str]]:
existing = set(self.keys())
graph: dict[str, set[str]] = {}
dangling: set[str] = set()
for name, pat in self.items():
children = {child for child, refs in pat.refs.items() if child is not None and refs}
for name in self:
children = self._raw_children(name)
graph[name] = children
dangling |= children - existing
return graph, dangling
@ -626,11 +634,18 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta):
for parent in parent_graph.get(name, set()):
if parent not in self: # parent_graph may be a for a superset of self
continue
for ref in self[parent].refs[name]:
instances[parent].append(ref.as_transforms())
instances[parent].extend(self._raw_ref_transforms(parent, name))
return instances
def _raw_ref_transforms(
self,
parent: str,
target: str,
) -> list[NDArray[numpy.float64]]:
"""Return local transforms from one parent to one target."""
return [ref.as_transforms() for ref in self[parent].refs.get(target, ())]
def find_refs_global(
self,
name: str,

View file

@ -40,3 +40,12 @@ class IBorrowing(ABC):
@abstractmethod
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
"""Return the source views directly borrowed by this library."""
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None: # noqa: ARG002
"""
Return a direct source cell with unchanged layout data, if available.
The source may use a different name, which is returned alongside it.
Port metadata may differ because ports are not layout-file content.
"""
return None

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from pprint import pformat
from typing import TYPE_CHECKING, Any, Self, cast
from typing import TYPE_CHECKING, Self
from ..error import LibraryError
from .base import ILibrary, ILibraryView
@ -70,9 +70,6 @@ class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing):
name: set(child_graph.get(name, set()))
for name in self._order
}
if hasattr(source, 'library_info'):
self.library_info = cast('dict[str, Any]', source.library_info)
def __getitem__(self, key: str) -> Pattern:
if key not in self._names:
raise KeyError(key)
@ -90,6 +87,11 @@ class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing):
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
return (self._source,)
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
if name not in self._names:
return None
return self._source, name
def source_order(self) -> tuple[str, ...]:
return self._order
@ -134,21 +136,6 @@ class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing):
refs = self._source.find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
return {parent: transforms for parent, transforms in refs.items() if parent in self._names}
def raw_struct_bytes(self, name: str) -> bytes:
if name not in self._names:
raise KeyError(name)
reader = getattr(self._source, 'raw_struct_bytes', None)
if not callable(reader):
raise TypeError('raw_struct_bytes')
return cast('bytes', reader(name))
def can_copy_raw_struct(self, name: str) -> bool:
if name not in self._names:
return False
can_copy = getattr(self._source, 'can_copy_raw_struct', None)
return bool(callable(can_copy) and can_copy(name))
class Library(ILibrary):
"""
Default implementation for a writeable library.

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, Self, cast
from typing import TYPE_CHECKING, Literal, Self, cast
import copy
from ..error import LibraryError
@ -53,9 +53,9 @@ class PortsLibraryView(ILibraryView, IMaterializable, IBorrowing):
The view borrows its source: callers must keep the source open for the
lifetime of the view and close the source themselves.
Graph queries, source ordering, and copy-through capabilities are delegated
to the wrapped source whenever possible, while `__getitem__` and
`materialize_many()` return port-imported patterns.
Graph queries and source ordering are delegated to the wrapped source,
while `source_cell()` exposes unchanged layout provenance and `__getitem__`
and `materialize_many()` return port-imported patterns.
"""
def __init__(
@ -79,9 +79,6 @@ class PortsLibraryView(ILibraryView, IMaterializable, IBorrowing):
self._replace = replace
self._cache: dict[str, Pattern] = {}
self._lookups_in_progress: list[str] = []
if hasattr(source, 'library_info'):
self.library_info = cast('dict[str, Any]', source.library_info)
def __getitem__(self, key: str) -> Pattern:
return self.materialize(key, persist=True)
@ -139,6 +136,11 @@ class PortsLibraryView(ILibraryView, IMaterializable, IBorrowing):
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
return (self._source,)
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
if name not in self._source or name in self._cache:
return None
return self._source, name
def child_graph(
self,
dangling: dangling_mode_t = 'error',
@ -158,21 +160,6 @@ class PortsLibraryView(ILibraryView, IMaterializable, IBorrowing):
return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling))
return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
def raw_struct_bytes(self, name: str) -> bytes:
reader = getattr(self._source, 'raw_struct_bytes', None)
if not callable(reader):
raise TypeError('raw_struct_bytes')
return cast('bytes', reader(name))
def can_copy_raw_struct(self, name: str) -> bool:
if name in self._cache:
return False
can_copy = getattr(self._source, 'can_copy_raw_struct', None)
if not callable(can_copy):
return False
return bool(can_copy(name))
class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
"""
Mutable overlay over one or more source libraries.
@ -434,23 +421,12 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
return tuple(layer.library for layer in self._layers)
def can_copy_raw_struct(self, name: str) -> bool:
entry = self._entries.get(name)
if not isinstance(entry, _SourceEntry) or name != entry.source_name:
return False
layer = self._layers[entry.layer_index]
can_copy = getattr(layer.library, 'can_copy_raw_struct', None)
if not callable(can_copy) or not can_copy(entry.source_name):
return False
children = layer.child_graph.get(entry.source_name, set())
return all(self._effective_target(layer, child) == child for child in children)
def raw_struct_bytes(self, name: str) -> bytes:
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
entry = self._entries.get(name)
if not isinstance(entry, _SourceEntry):
raise TypeError('raw_struct_bytes')
return None
layer = self._layers[entry.layer_index]
reader = getattr(layer.library, 'raw_struct_bytes', None)
if not callable(reader):
raise TypeError('raw_struct_bytes')
return cast('bytes', reader(entry.source_name))
children = layer.child_graph.get(entry.source_name, set())
if any(self._effective_target(layer, child) != child for child in children):
return None
return layer.library, entry.source_name

View file

@ -10,7 +10,7 @@ from ..file.gdsii import lazy as gdsii_lazy
from ..error import LibraryError
from ..pattern import Pattern
from ..ports import Port
from ..library import IBorrowing, IMaterializable, Library, OverlayLibrary
from ..library import IBorrowing, IMaterializable, LazyLibrary, Library, OverlayLibrary, PortsLibraryView
def _make_lazy_port_library() -> Library:
@ -39,6 +39,43 @@ def test_gdsii_lazy_write_ignores_non_mapping_library_info() -> None:
gdsii_lazy.write(lib, io.BytesIO())
def test_gdsii_lazy_write_plain_library_matches_eager_writer() -> None:
lib = _make_lazy_port_library()
eager_stream = io.BytesIO()
lazy_stream = io.BytesIO()
gdsii.write(lib, eager_stream, meters_per_unit=1e-9, library_name='writer-match')
gdsii_lazy.write(
lib,
lazy_stream,
meters_per_unit=1e-9,
logical_units_per_unit=1,
library_name='writer-match',
)
assert lazy_stream.getvalue() == eager_stream.getvalue()
def test_gdsii_lazy_write_materializes_transiently() -> None:
lib = LazyLibrary()
lib['top'] = Pattern()
stream = io.BytesIO()
gdsii_lazy.write(
lib,
stream,
meters_per_unit=1e-9,
logical_units_per_unit=1,
library_name='transient',
)
assert not lib.cache
stream.seek(0)
roundtrip, info = gdsii.read(stream)
assert set(roundtrip) == {'top'}
assert info['name'] == 'transient'
def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_source.gds'
src = _make_lazy_port_library()
@ -73,6 +110,17 @@ def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_pat
assert set(lib._cache) == {'child'}
def test_gdsii_lazy_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_cached_graph.gds'
gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9)
lib, _ = gdsii_lazy.readfile(gds_file)
del lib['child'].refs['leaf']
assert lib.child_graph(dangling='ignore')['child'] == set()
assert lib.find_refs_local('leaf') == {}
def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_subtree_source.gds'
src = _make_lazy_port_library()
@ -87,6 +135,7 @@ def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path
assert isinstance(subtree, IMaterializable)
assert isinstance(subtree, IBorrowing)
assert subtree.source_order() == ('leaf', 'child', 'top')
assert not hasattr(subtree, 'library_info')
assert not raw._cache
out_file = tmp_path / 'lazy_subtree_out.gds'
@ -127,7 +176,8 @@ def test_gdsii_lazy_ports_view_keeps_raw_source_unmodified(tmp_path: Path) -> No
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
processed = PortsLibraryView(raw, layers=[(10, 0)], max_depth=2)
assert not hasattr(processed, 'library_info')
top = processed['top']
assert set(top.ports) == {'A'}
@ -145,7 +195,7 @@ def test_gdsii_lazy_ports_view_detaches_previously_cached_source(tmp_path: Path)
raw, _ = gdsii_lazy.readfile(gds_file)
raw_top = raw['top']
processed = raw.with_port_overrides({
processed = PortsLibraryView(raw, ports={
'top': {
'P': Port((1, 2), rotation=0, ptype='wire'),
},
@ -166,7 +216,7 @@ def test_gdsii_lazy_port_overrides_without_data_stay_lazy(tmp_path: Path) -> Non
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overrides')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = raw.with_port_overrides({
processed = PortsLibraryView(raw, ports={
'top': {
'P': Port((1, 2), rotation=0, ptype='wire'),
},
@ -189,7 +239,8 @@ def test_gdsii_lazy_port_overrides_apply_after_extraction(tmp_path: Path) -> Non
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-override-extracted')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = raw.with_ports_from_data(
processed = PortsLibraryView(
raw,
layers=[(10, 0)],
max_depth=2,
ports={
@ -217,7 +268,8 @@ def test_gdsii_lazy_port_overrides_replace_extracted_ports(tmp_path: Path) -> No
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-replace-ports')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = raw.with_ports_from_data(
processed = PortsLibraryView(
raw,
layers=[(10, 0)],
max_depth=2,
ports={
@ -240,7 +292,7 @@ def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: P
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
processed = PortsLibraryView(raw, layers=[(10, 0)], max_depth=2)
overlay = OverlayLibrary()
overlay.add_source(processed)
@ -258,7 +310,7 @@ def test_gdsii_lazy_overlay_add_source_sees_port_overrides(tmp_path: Path) -> No
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay-override')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = raw.with_port_overrides({
processed = PortsLibraryView(raw, ports={
'top': {
'P': Port((1, 2), rotation=0, ptype='wire'),
},
@ -310,7 +362,7 @@ def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path:
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
processed = PortsLibraryView(raw, layers=[(10, 0)], max_depth=2)
out_file = tmp_path / 'lazy_roundtrip_out.gds'
gdsii_lazy.writefile(processed, out_file)

View file

@ -10,11 +10,12 @@ import pytest
pytest.importorskip('pyarrow')
from .. import PatternError
from ..library import IBorrowing, IMaterializable, Library, OverlayLibrary
from ..library import IBorrowing, IMaterializable, Library, OverlayLibrary, PortsLibraryView
from ..pattern import Pattern
from ..repetition import Grid
from ..file import gdsii
from ..file.gdsii import lazy_arrow as gdsii_lazy_arrow
from ..file.gdsii import lazy_write as gdsii_lazy_write
from ..file.gdsii.perf import write_fixture
@ -160,6 +161,17 @@ def test_gdsii_lazy_arrow_local_and_global_refs(tmp_path: Path) -> None:
assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5
def test_gdsii_lazy_arrow_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_arrow_cached_graph.gds'
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9)
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
del lib['mid'].refs['leaf']
assert lib.child_graph(dangling='ignore')['mid'] == set()
assert lib.find_refs_local('leaf') == {}
def test_gdsii_lazy_arrow_ref_queries_match_eager_reader(tmp_path: Path) -> None:
gds_file = tmp_path / 'complex_refs.gds'
src = _make_complex_ref_library()
@ -223,18 +235,66 @@ def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> Non
assert out_file.read_bytes() == gds_file.read_bytes()
def test_gdsii_lazy_arrow_processed_cell_edit_disables_raw_copy(tmp_path: Path) -> None:
def test_gdsii_raw_copy_resolves_generic_borrowing_views(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gds_file = tmp_path / 'provenance_source.gds'
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='provenance')
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
ports = PortsLibraryView(raw)
subtree = ports.subtree('top')
overlay = OverlayLibrary()
overlay.add_source(subtree)
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
out_file = tmp_path / 'provenance_out.gds'
gdsii_lazy_arrow.writefile(overlay, out_file)
assert copied == ['leaf', 'mid', 'top']
assert out_file.read_bytes() == gds_file.read_bytes()
renamed = OverlayLibrary()
renamed.add_source(raw)
renamed.rename('top', 'renamed_top')
assert gdsii_lazy_write._resolve_raw_struct(renamed, 'renamed_top') is None
remapped = OverlayLibrary()
remapped.add_source(raw)
remapped.rename('leaf', 'renamed_leaf', move_references=True)
assert gdsii_lazy_write._resolve_raw_struct(remapped, 'mid') is None
def test_gdsii_lazy_arrow_processed_cell_edit_disables_raw_copy(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'processed_edit_source.gds'
src = _make_small_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='processed-edit')
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
processed = raw.with_port_overrides({})
processed = PortsLibraryView(raw)
processed['top'].polygon((7, 0), vertices=[[0, 0], [4, 0], [0, 4]])
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
out_file = tmp_path / 'processed_edit_out.gds'
gdsii_lazy_arrow.writefile(processed, out_file)
assert 'top' not in copied
roundtrip, _ = gdsii.readfile(out_file)
assert len(roundtrip['top'].shapes[(7, 0)]) == 1

View file

@ -1,8 +1,8 @@
import pytest
from collections.abc import Iterator, Mapping, MutableMapping
from collections.abc import Mapping, MutableMapping
from typing import cast, TYPE_CHECKING
from numpy.testing import assert_allclose
from ..library import IBorrowing, INameView, IMaterializable, ILibraryView, Library, LibraryView, LazyLibrary, OverlayLibrary, PortsLibraryView
from ..library import IBorrowing, INameView, IMaterializable, Library, LibraryView, LazyLibrary, OverlayLibrary, PortsLibraryView
from ..pattern import Pattern
from ..error import LibraryError, PatternError
from ..ports import Port
@ -673,41 +673,43 @@ def test_library_materialization_and_borrowing_capabilities() -> None:
assert not isinstance(plain_view, IMaterializable | IBorrowing)
class _RawCopyView(ILibraryView):
def __init__(self) -> None:
self.mapping = {"top": Pattern()}
def test_borrowed_source_cell_tracks_persistent_materialization() -> None:
source = Library({"top": Pattern()})
source.library_info = {"name": "not-forwarded"} # type: ignore[attr-defined]
processed = PortsLibraryView(source)
def __getitem__(self, key: str) -> Pattern:
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 raw_struct_bytes(self, name: str) -> bytes:
return name.encode()
def can_copy_raw_struct(self, name: str) -> bool:
return name in self.mapping
def test_ports_view_raw_copy_eligibility_tracks_persistent_materialization() -> None:
processed = PortsLibraryView(_RawCopyView())
assert processed.can_copy_raw_struct("top")
assert processed.source_cell("top") == (source, "top")
assert not hasattr(processed, "library_info")
assert not hasattr(processed, "raw_struct_bytes")
_ = processed.materialize_many(("top",), persist=False)
assert processed.can_copy_raw_struct("top")
assert processed.source_cell("top") == (source, "top")
subtree = processed.subtree("top")
assert subtree.can_copy_raw_struct("top")
assert subtree.source_cell("top") == (processed, "top")
assert not hasattr(subtree, "library_info")
_ = subtree["top"]
assert not processed.can_copy_raw_struct("top")
assert not subtree.can_copy_raw_struct("top")
assert processed.source_cell("top") is None
assert subtree.source_cell("top") == (processed, "top")
def test_overlay_source_cell_tracks_names_references_and_materialization() -> None:
source = Library({"leaf": Pattern(), "parent": Pattern()})
source["parent"].ref("leaf")
overlay = OverlayLibrary()
overlay.add_source(source)
assert overlay.source_cell("parent") == (source, "parent")
overlay.rename("parent", "renamed_parent")
assert overlay.source_cell("renamed_parent") == (source, "parent")
overlay.rename("leaf", "renamed_leaf", move_references=True)
assert overlay.source_cell("renamed_parent") is None
materialized = OverlayLibrary()
materialized.add_source(source)
_ = materialized["parent"]
assert materialized.source_cell("parent") is None
@pytest.mark.parametrize("materialize_parent", [False, True])

View file

@ -0,0 +1,338 @@
from typing import Any
import numpy
import pytest
from numpy import pi
from masque.builder import (
AutoTool,
BendOffer,
PathTool,
PrimitiveKind,
PrimitiveOffer,
RenderStep,
SOffer,
StraightOffer,
Tool,
ToolContractCase,
ToolContractError,
UOffer,
validate_tool_contract,
)
from masque.error import BuildError
from masque.library import ILibrary, Library, SINGLE_USE_PREFIX
from masque.pattern import Pattern
from masque.ports import Port
def make_straight(length: float, *, ptype: str = 'wire') -> Pattern:
return Pattern(ports={
'A': Port((0, 0), 0, ptype=ptype),
'B': Port((length, 0), pi, ptype=ptype),
})
class EmptyTool(Tool):
def primitive_offers(
self,
kind: PrimitiveKind,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
return ()
def render(
self,
batch: tuple[RenderStep, ...],
*,
port_names: tuple[str, str] = ('A', 'B'),
) -> ILibrary:
_ = batch
tree, pattern = Library.mktree('empty_tool')
pattern.add_port_pair(names=port_names)
return tree
@pytest.mark.parametrize(
('offer', 'kind', 'opcode'),
[
(StraightOffer('wire', 'wire'), 'straight', 'L'),
(BendOffer('wire', 'wire'), 'bend', 'L'),
(SOffer('wire', 'wire'), 's', 'S'),
(UOffer('wire', 'wire'), 'u', 'U'),
],
)
def test_offer_kind_is_canonical_and_opcode_is_derived(
offer: StraightOffer | BendOffer | SOffer | UOffer,
kind: str,
opcode: str,
) -> None:
assert offer.kind == kind
assert offer.opcode == opcode
def test_render_step_stores_kind_and_derives_opcode() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
port = Port((0, 0), 0, ptype='wire')
step = RenderStep('straight', tool, port, port, None)
plug = RenderStep('plug', None, port, port, None)
assert step.kind == 'straight'
assert step.opcode == 'L'
assert step.transformed(numpy.zeros(2), 0, numpy.zeros(2)).kind == 'straight'
assert step.mirrored(0).kind == 'straight'
assert plug.opcode == 'P'
with pytest.raises(BuildError, match='Unrecognized RenderStep kind'):
RenderStep('L', tool, port, port, None) # type: ignore[arg-type]
with pytest.raises(BuildError, match='requires tool=None'):
RenderStep('plug', tool, port, port, None)
def test_standard_offer_endpoint_callback_runs_once_per_solver_evaluation() -> None:
endpoint_calls: list[float] = []
received_endpoints: list[Port] = []
def endpoint(length: float) -> Port:
endpoint_calls.append(length)
return Port((length, 0), pi, ptype='wire')
def cost(length: float, out_port: Port) -> float:
_ = length
received_endpoints.append(out_port)
return out_port.x
class OneOfferTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
cost=cost,
endpoint_planner=endpoint,
commit_planner=lambda length: length,
),)
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=OneOfferTool(),
render='deferred',
)
pather.straight('A', 5)
assert endpoint_calls.count(5) == 1
assert len(received_endpoints) >= 1
def test_custom_cost_at_override_remains_authoritative() -> None:
calls: list[float] = []
class CustomCostOffer(StraightOffer):
def cost_at(self, parameter: float) -> float:
calls.append(parameter)
return 0
offer = CustomCostOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wire'),
commit_planner=lambda length: length,
)
class CustomCostTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (offer,) if kind == 'straight' else ()
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=CustomCostTool(),
render='deferred',
)
pather.straight('A', 5)
assert 5 in calls
def test_solver_rejects_offer_kind_mismatch() -> None:
class WrongKindTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (SOffer.generated(
'wire',
lambda jog: Port((1, jog), pi, ptype='wire'),
lambda jog: jog,
),)
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=WrongKindTool(),
render='deferred',
)
with pytest.raises(ToolContractError, match='returned.*s.*offer'):
pather.straight('A', 5)
def test_validate_tool_contract_accepts_pathtool() -> None:
tool = PathTool(layer='M1', width=2, ptype='wire')
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
ToolContractCase('bend', in_ptype='wire', ccw=False, check_bbox=True),
ToolContractCase('bend', in_ptype='wire', ccw=True, check_bbox=True),
ToolContractCase('s', in_ptype='wire', check_bbox=True),
ToolContractCase('u', in_ptype='wire', require_offers=False),
))
def test_validate_tool_contract_accepts_autotool_with_explicit_probe() -> None:
tool = AutoTool().add_straight(
make_straight,
'wire',
'A',
length_range=(1, 10),
)
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', probe_parameters=(7,)),
))
def test_validate_tool_contract_empty_offer_policy() -> None:
tool = EmptyTool()
validate_tool_contract(tool, (ToolContractCase('u', require_offers=False),))
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(tool, (ToolContractCase('u'),))
assert all(isinstance(err, ToolContractError) for err in exc_info.value.exceptions)
assert any('no offers' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_rejects_unmatched_explicit_probe() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
with pytest.raises(ExceptionGroup, match='Tool contract validation') as exc_info:
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)),
))
assert any('outside every discovered offer domain' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_aggregates_independent_violations() -> None:
class BrokenTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length + 1, 0), 0, ptype='wrong'),
commit_planner=lambda length: length,
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(BrokenTool(), (
ToolContractCase('straight', in_ptype='wire', label='broken straight'),
))
errors = exc_info.value.exceptions
assert len(errors) > 1
assert all(isinstance(err, ToolContractError) for err in errors)
assert all('broken straight' in str(err) for err in errors)
def test_validate_tool_contract_detects_repeated_discovery_changes() -> None:
class ChangingTool(EmptyTool):
calls = 0
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
self.calls += 1
shift = float(self.calls - 1)
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length + shift, 0), pi, ptype='wire'),
commit_planner=lambda length: length,
),)
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(ChangingTool(), (
ToolContractCase('straight', in_ptype='wire'),
))
assert any('changed after repeated discovery' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_checks_render_ports_and_single_use_refs() -> None:
class BrokenRenderTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
tree = Library()
pattern = Pattern(ports={port_names[0]: Port((0, 0), 0, ptype='wire')})
pattern.ref(SINGLE_USE_PREFIX + 'missing')
tree['top'] = pattern
return tree
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(BrokenRenderTool(), (
ToolContractCase('straight', in_ptype='wire'),
))
messages = [str(err) for err in exc_info.value.exceptions]
assert any('missing single-use refs' in message for message in messages)
assert any('missing ports' in message for message in messages)
def test_validate_tool_contract_bbox_is_opt_in() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
validate_tool_contract(tool, (ToolContractCase('straight', in_ptype='wire'),))
# AutoTool supplies bbox support, so use a minimal custom offer without it.
class NoBBoxTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
return tree
validate_tool_contract(NoBBoxTool(), (ToolContractCase('straight', in_ptype='wire'),))
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(NoBBoxTool(), (
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
))
assert any('bbox_at()' in str(err) for err in exc_info.value.exceptions)
@pytest.mark.parametrize(
'kwargs',
[
{'kind': 'straight', 'ccw': True},
{'kind': 'bend'},
{'kind': 'straight', 'probe_parameters': (numpy.inf,)},
{'kind': 'straight', 'tool_options': {'ccw': True}},
],
)
def test_tool_contract_case_validates_configuration(kwargs: dict[str, Any]) -> None:
with pytest.raises(ValueError, match='ccw|requires|finite|reserved'):
ToolContractCase(**kwargs) # type: ignore[arg-type]