[library / gdsii] cleanup using new library rework
This commit is contained in:
parent
ddb7742493
commit
7b589e4f44
14 changed files with 696 additions and 391 deletions
17
MIGRATION.md
17
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
|
||||
|
|
|
|||
|
|
@ -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())))
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
338
masque/test/test_tool_contract.py
Normal file
338
masque/test/test_tool_contract.py
Normal 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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue