From 7b589e4f447cd034ef95c8f30d95d83759f14b79 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 13 Jul 2026 21:37:52 -0700 Subject: [PATCH] [library / gdsii] cleanup using new library rework --- MIGRATION.md | 17 +- examples/tutorial/library.py | 4 +- masque/file/gdsii/klamath.py | 89 ++++--- masque/file/gdsii/lazy.py | 106 +-------- masque/file/gdsii/lazy_arrow.py | 114 ++------- masque/file/gdsii/lazy_write.py | 112 ++++----- masque/library/base.py | 23 +- masque/library/capabilities.py | 9 + masque/library/mapping.py | 25 +- masque/library/overlay.py | 54 ++--- masque/test/test_gdsii_lazy.py | 70 +++++- masque/test/test_gdsii_lazy_arrow.py | 66 +++++- masque/test/test_library.py | 66 +++--- masque/test/test_tool_contract.py | 338 +++++++++++++++++++++++++++ 14 files changed, 699 insertions(+), 394 deletions(-) create mode 100644 masque/test/test_tool_contract.py diff --git a/MIGRATION.md b/MIGRATION.md index c2a4b16..f15ba91 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index da582fe..2c9de45 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -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()))) diff --git a/masque/file/gdsii/klamath.py b/masque/file/gdsii/klamath.py index 13d9e81..2337417 100644 --- a/masque/file/gdsii/klamath.py +++ b/masque/file/gdsii/klamath.py @@ -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( diff --git a/masque/file/gdsii/lazy.py b/masque/file/gdsii/lazy.py index 75092ad..798eb26 100644 --- a/masque/file/gdsii/lazy.py +++ b/masque/file/gdsii/lazy.py @@ -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() diff --git a/masque/file/gdsii/lazy_arrow.py b/masque/file/gdsii/lazy_arrow.py index 98b741f..5b57354 100644 --- a/masque/file/gdsii/lazy_arrow.py +++ b/masque/file/gdsii/lazy_arrow.py @@ -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( diff --git a/masque/file/gdsii/lazy_write.py b/masque/file/gdsii/lazy_write.py index a9e40bd..1299e21 100644 --- a/masque/file/gdsii/lazy_write.py +++ b/masque/file/gdsii/lazy_write.py @@ -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, + ) diff --git a/masque/library/base.py b/masque/library/base.py index 7e8be6c..422135e 100644 --- a/masque/library/base.py +++ b/masque/library/base.py @@ -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, diff --git a/masque/library/capabilities.py b/masque/library/capabilities.py index 257393f..bf85262 100644 --- a/masque/library/capabilities.py +++ b/masque/library/capabilities.py @@ -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 diff --git a/masque/library/mapping.py b/masque/library/mapping.py index 8fb10dd..d8b5666 100644 --- a/masque/library/mapping.py +++ b/masque/library/mapping.py @@ -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. diff --git a/masque/library/overlay.py b/masque/library/overlay.py index 519393e..089d953 100644 --- a/masque/library/overlay.py +++ b/masque/library/overlay.py @@ -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 diff --git a/masque/test/test_gdsii_lazy.py b/masque/test/test_gdsii_lazy.py index 243b499..8b4b7c4 100644 --- a/masque/test/test_gdsii_lazy.py +++ b/masque/test/test_gdsii_lazy.py @@ -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) diff --git a/masque/test/test_gdsii_lazy_arrow.py b/masque/test/test_gdsii_lazy_arrow.py index b7419d8..bbe8d78 100644 --- a/masque/test/test_gdsii_lazy_arrow.py +++ b/masque/test/test_gdsii_lazy_arrow.py @@ -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 diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 157b0a1..e939ae7 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -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]) diff --git a/masque/test/test_tool_contract.py b/masque/test/test_tool_contract.py new file mode 100644 index 0000000..275f052 --- /dev/null +++ b/masque/test/test_tool_contract.py @@ -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]