Compare commits

..

No commits in common. "b44c962e079882f119a6b524660349134fa1ae63" and "c2ef3e42174cea0fb7c3c35625189d95aff97169" have entirely different histories.

7 changed files with 25 additions and 227 deletions

View file

@ -75,8 +75,7 @@ def preflight(
raise PatternError('Non-numeric layers found:' + pformat(named_layers)) raise PatternError('Non-numeric layers found:' + pformat(named_layers))
if prune_empty_patterns: if prune_empty_patterns:
prune_dangling = 'error' if allow_dangling_refs is False else 'ignore' pruned = lib.prune_empty()
pruned = lib.prune_empty(dangling=prune_dangling)
if pruned: if pruned:
logger.info(f'Preflight pruned {len(pruned)} empty patterns') logger.info(f'Preflight pruned {len(pruned)} empty patterns')
logger.debug('Pruned: ' + pformat(pruned)) logger.debug('Pruned: ' + pformat(pruned))

View file

@ -59,9 +59,6 @@ TreeView: TypeAlias = Mapping[str, 'Pattern']
Tree: TypeAlias = MutableMapping[str, 'Pattern'] Tree: TypeAlias = MutableMapping[str, 'Pattern']
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """ """ A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
""" How helpers should handle refs whose targets are not present in the library. """
SINGLE_USE_PREFIX = '_' SINGLE_USE_PREFIX = '_'
""" """
@ -421,21 +418,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
""" """
return self[self.top()] return self[self.top()]
@staticmethod
def _dangling_refs_error(dangling: set[str], context: str) -> LibraryError:
dangling_list = sorted(dangling)
return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list))
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}
graph[name] = children
dangling |= children - existing
return graph, dangling
def dfs( def dfs(
self, self,
pattern: 'Pattern', pattern: 'Pattern',
@ -541,88 +523,46 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
return self return self
def child_graph( def child_graph(self) -> dict[str, set[str | None]]:
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
""" """
Return a mapping from pattern name to a set of all child patterns Return a mapping from pattern name to a set of all child patterns
(patterns it references). (patterns it references).
Only non-empty ref lists with non-`None` targets are treated as graph edges.
Args:
dangling: How refs to missing targets are handled. `'error'` raises,
`'ignore'` drops those edges, and `'include'` exposes them as
synthetic leaf nodes.
Returns: Returns:
Mapping from pattern name to a set of all pattern names it references. Mapping from pattern name to a set of all pattern names it references.
""" """
graph, dangling_refs = self._raw_child_graph() graph = {name: set(pat.refs.keys()) for name, pat in self.items()}
if dangling == 'error':
if dangling_refs:
raise self._dangling_refs_error(dangling_refs, 'building child graph')
return graph
if dangling == 'ignore':
existing = set(graph)
return {name: {child for child in children if child in existing} for name, children in graph.items()}
for target in dangling_refs:
graph.setdefault(target, set())
return graph return graph
def parent_graph( def parent_graph(self) -> dict[str, set[str]]:
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
""" """
Return a mapping from pattern name to a set of all parent patterns Return a mapping from pattern name to a set of all parent patterns
(patterns which reference it). (patterns which reference it).
Args:
dangling: How refs to missing targets are handled. `'error'` raises,
`'ignore'` drops those targets, and `'include'` adds them as
synthetic keys whose values are their existing parents.
Returns: Returns:
Mapping from pattern name to a set of all patterns which reference it. Mapping from pattern name to a set of all patterns which reference it.
""" """
child_graph, dangling_refs = self._raw_child_graph() igraph: dict[str, set[str]] = {name: set() for name in self}
if dangling == 'error' and dangling_refs: for name, pat in self.items():
raise self._dangling_refs_error(dangling_refs, 'building parent graph') for child, reflist in pat.refs.items():
if reflist and child is not None:
existing = set(child_graph) igraph[child].add(name)
igraph: dict[str, set[str]] = {name: set() for name in existing}
for parent, children in child_graph.items():
for child in children:
if child in existing:
igraph[child].add(parent)
elif dangling == 'include':
igraph.setdefault(child, set()).add(parent)
return igraph return igraph
def child_order( def child_order(self) -> list[str]:
self,
dangling: dangling_mode_t = 'error',
) -> list[str]:
""" """
Return a topologically sorted list of graph node names. Return a topologically sorted list of all contained pattern names.
Child (referenced) patterns will appear before their parents. Child (referenced) patterns will appear before their parents.
Args:
dangling: Passed to `child_graph()`.
Return: Return:
Topologically sorted list of pattern names. Topologically sorted list of pattern names.
""" """
return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order())) return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order()))
def find_refs_local( def find_refs_local(
self, self,
name: str, name: str,
parent_graph: dict[str, set[str]] | None = None, parent_graph: dict[str, set[str]] | None = None,
dangling: dangling_mode_t = 'error',
) -> dict[str, list[NDArray[numpy.float64]]]: ) -> dict[str, list[NDArray[numpy.float64]]]:
""" """
Find the location and orientation of all refs pointing to `name`. Find the location and orientation of all refs pointing to `name`.
@ -635,8 +575,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
The provided graph may be for a superset of `self` (i.e. it may The provided graph may be for a superset of `self` (i.e. it may
contain additional patterns which are not present in self; they contain additional patterns which are not present in self; they
will be ignored). will be ignored).
dangling: How refs to missing targets are handled if `parent_graph`
is not provided. `'include'` also allows querying missing names.
Returns: Returns:
Mapping of {parent_name: transform_list}, where transform_list Mapping of {parent_name: transform_list}, where transform_list
@ -645,18 +583,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
""" """
instances = defaultdict(list) instances = defaultdict(list)
if parent_graph is None: if parent_graph is None:
graph_mode = 'ignore' if dangling == 'ignore' else 'include' parent_graph = self.parent_graph()
parent_graph = self.parent_graph(dangling=graph_mode) for parent in parent_graph[name]:
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 not in self: # parent_graph may be a for a superset of self if parent not in self: # parent_graph may be a for a superset of self
continue continue
for ref in self[parent].refs[name]: for ref in self[parent].refs[name]:
@ -669,7 +597,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
name: str, name: str,
order: list[str] | None = None, order: list[str] | None = None,
parent_graph: dict[str, set[str]] | None = None, parent_graph: dict[str, set[str]] | None = None,
dangling: dangling_mode_t = 'error',
) -> dict[tuple[str, ...], NDArray[numpy.float64]]: ) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
""" """
Find the absolute (top-level) location and orientation of all refs (including Find the absolute (top-level) location and orientation of all refs (including
@ -686,28 +613,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
The provided graph may be for a superset of `self` (i.e. it may The provided graph may be for a superset of `self` (i.e. it may
contain additional patterns which are not present in self; they contain additional patterns which are not present in self; they
will be ignored). will be ignored).
dangling: How refs to missing targets are handled if `order` or
`parent_graph` are not provided. `'include'` also allows
querying missing names.
Returns: Returns:
Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form
`(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray `(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
""" """
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
if order is None:
order = self.child_order(dangling=graph_mode)
if parent_graph is None:
parent_graph = self.parent_graph(dangling=graph_mode)
if name not in self: if name not in self:
if name not in parent_graph:
return {}
if dangling == 'error':
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
if dangling == 'ignore':
return {} return {}
if order is None:
order = self.child_order()
if parent_graph is None:
parent_graph = self.parent_graph()
self_keys = set(self.keys()) self_keys = set(self.keys())
@ -716,16 +633,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
NDArray[numpy.float64] NDArray[numpy.float64]
]]] ]]]
transforms = defaultdict(list) transforms = defaultdict(list)
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items():
transforms[parent] = [((name,), numpy.concatenate(vals))] transforms[parent] = [((name,), numpy.concatenate(vals))]
for next_name in order: for next_name in order:
if next_name not in transforms: if next_name not in transforms:
continue continue
if not parent_graph.get(next_name, set()) & self_keys: if not parent_graph[next_name] & self_keys:
continue continue
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) outers = self.find_refs_local(next_name, parent_graph=parent_graph)
inners = transforms.pop(next_name) inners = transforms.pop(next_name)
for parent, outer in outers.items(): for parent, outer in outers.items():
for path, inner in inners: for path, inner in inners:
@ -1202,19 +1119,17 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
def prune_empty( def prune_empty(
self, self,
repeat: bool = True, repeat: bool = True,
dangling: dangling_mode_t = 'error',
) -> set[str]: ) -> set[str]:
""" """
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`). Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
Args: Args:
repeat: Also recursively delete any patterns which only contain(ed) empty patterns. repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
dangling: Passed to `parent_graph()`.
Returns: Returns:
A set containing the names of all deleted patterns A set containing the names of all deleted patterns
""" """
parent_graph = self.parent_graph(dangling=dangling) parent_graph = self.parent_graph()
empty = {name for name, pat in self.items() if pat.is_empty()} empty = {name for name, pat in self.items() if pat.is_empty()}
trimmed = set() trimmed = set()
while empty: while empty:

View file

@ -1411,9 +1411,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
other_copy.translate_elements(offset) other_copy.translate_elements(offset)
self.append(other_copy) self.append(other_copy)
else: else:
if isinstance(other, Pattern): assert not isinstance(other, Pattern)
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
ref = Ref(mirrored=mirrored) ref = Ref(mirrored=mirrored)
ref.rotate_around(pivot, rotation) ref.rotate_around(pivot, rotation)
ref.translate(offset) ref.translate(offset)

View file

@ -1,12 +1,10 @@
import pytest import pytest
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
from numpy.testing import assert_allclose
from ..library import Library, LazyLibrary from ..library import Library, LazyLibrary
from ..pattern import Pattern from ..pattern import Pattern
from ..error import LibraryError, PatternError from ..error import LibraryError, PatternError
from ..ports import Port from ..ports import Port
from ..repetition import Grid from ..repetition import Grid
from ..file.utils import preflight
if TYPE_CHECKING: if TYPE_CHECKING:
from ..shapes import Polygon from ..shapes import Polygon
@ -43,79 +41,6 @@ def test_library_dangling() -> None:
assert lib.dangling_refs() == {"missing"} assert lib.dangling_refs() == {"missing"}
def test_library_dangling_graph_modes() -> None:
lib = Library()
lib["parent"] = Pattern()
lib["parent"].ref("missing")
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.child_graph()
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.parent_graph()
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.child_order()
assert lib.child_graph(dangling="ignore") == {"parent": set()}
assert lib.parent_graph(dangling="ignore") == {"parent": set()}
assert lib.child_order(dangling="ignore") == ["parent"]
assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()}
assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}}
assert lib.child_order(dangling="include") == ["missing", "parent"]
def test_find_refs_with_dangling_modes() -> None:
lib = Library()
lib["target"] = Pattern()
mid = Pattern()
mid.ref("target", offset=(2, 0))
lib["mid"] = mid
top = Pattern()
top.ref("mid", offset=(5, 0))
top.ref("missing", offset=(9, 0))
lib["top"] = top
assert lib.find_refs_local("missing", dangling="ignore") == {}
assert lib.find_refs_global("missing", dangling="ignore") == {}
local_missing = lib.find_refs_local("missing", dangling="include")
assert set(local_missing) == {"top"}
assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]])
global_missing = lib.find_refs_global("missing", dangling="include")
assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]])
with pytest.raises(LibraryError, match="missing"):
lib.find_refs_local("missing")
with pytest.raises(LibraryError, match="missing"):
lib.find_refs_global("missing")
global_target = lib.find_refs_global("target")
assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]])
def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None:
def make_lib() -> Library:
lib = Library()
lib["empty"] = Pattern()
lib["top"] = Pattern()
lib["top"].ref("missing")
return lib
caplog.set_level("WARNING")
warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True)
assert "empty" not in warned
assert any("Dangling refs found" in record.message for record in caplog.records)
allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True)
assert "empty" not in allowed
with pytest.raises(LibraryError, match="Dangling refs found"):
preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True)
def test_library_flatten() -> None: def test_library_flatten() -> None:
lib = Library() lib = Library()
child = Pattern() child = Pattern()

View file

@ -126,14 +126,6 @@ def test_pattern_flatten_repeated_ref_with_ports_raises() -> None:
parent.flatten({"child": child}, flatten_ports=True) parent.flatten({"child": child}, flatten_ports=True)
def test_pattern_place_requires_abstract_for_reference() -> None:
parent = Pattern()
child = Pattern()
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
parent.place(child)
def test_pattern_interface() -> None: def test_pattern_interface() -> None:
source = Pattern() source = Pattern()
source.ports["A"] = Port((10, 20), 0, ptype="test") source.ports["A"] = Port((10, 20), 0, ptype="test")

View file

@ -2,7 +2,7 @@ import numpy
from numpy.testing import assert_equal, assert_allclose from numpy.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, DeferredDict from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms
def test_remove_duplicate_vertices() -> None: def test_remove_duplicate_vertices() -> None:
@ -86,21 +86,3 @@ def test_apply_transforms_advanced() -> None:
# 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10) # 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10)
# 3. add outer offset (0, 0) -> (0, 10) # 3. add outer offset (0, 0) -> (0, 10)
assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10) assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10)
def test_deferred_dict_accessors_resolve_values_once() -> None:
calls = 0
def make_value() -> int:
nonlocal calls
calls += 1
return 7
deferred = DeferredDict[str, int]()
deferred["x"] = make_value
assert deferred.get("missing", 9) == 9
assert deferred.get("x") == 7
assert list(deferred.values()) == [7]
assert list(deferred.items()) == [("x", 7)]
assert calls == 1

View file

@ -1,5 +1,5 @@
from typing import TypeVar, Generic from typing import TypeVar, Generic
from collections.abc import Callable, Iterator from collections.abc import Callable
from functools import lru_cache from functools import lru_cache
@ -41,19 +41,6 @@ class DeferredDict(dict, Generic[Key, Value]):
def __getitem__(self, key: Key) -> Value: def __getitem__(self, key: Key) -> Value:
return dict.__getitem__(self, key)() return dict.__getitem__(self, key)()
def get(self, key: Key, default: Value | None = None) -> Value | None:
if key not in self:
return default
return self[key]
def items(self) -> Iterator[tuple[Key, Value]]:
for key in self.keys():
yield key, self[key]
def values(self) -> Iterator[Value]:
for key in self.keys():
yield self[key]
def update(self, *args, **kwargs) -> None: def update(self, *args, **kwargs) -> None:
""" """
Update the DeferredDict. If a value is callable, it is used as a generator. Update the DeferredDict. If a value is callable, it is used as a generator.