[Library] improve handling of dangling refs

This commit is contained in:
Jan Petykiewicz 2026-03-30 22:10:26 -07:00
commit 20bd0640e1
3 changed files with 183 additions and 22 deletions

View file

@ -75,7 +75,8 @@ 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:
pruned = lib.prune_empty() prune_dangling = 'error' if allow_dangling_refs is False else 'ignore'
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,6 +59,9 @@ 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 = '_'
""" """
@ -418,6 +421,21 @@ 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',
@ -523,46 +541,88 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
return self return self
def child_graph(self) -> dict[str, set[str | None]]: def child_graph(
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 = {name: set(pat.refs.keys()) for name, pat in self.items()} graph, dangling_refs = self._raw_child_graph()
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(self) -> dict[str, set[str]]: def parent_graph(
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.
""" """
igraph: dict[str, set[str]] = {name: set() for name in self} child_graph, dangling_refs = self._raw_child_graph()
for name, pat in self.items(): if dangling == 'error' and dangling_refs:
for child, reflist in pat.refs.items(): raise self._dangling_refs_error(dangling_refs, 'building parent graph')
if reflist and child is not None:
igraph[child].add(name) existing = set(child_graph)
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(self) -> list[str]: def child_order(
self,
dangling: dangling_mode_t = 'error',
) -> list[str]:
""" """
Return a topologically sorted list of all contained pattern names. Return a topologically sorted list of graph node 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()).static_order())) return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).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`.
@ -575,6 +635,8 @@ 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
@ -583,8 +645,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
""" """
instances = defaultdict(list) instances = defaultdict(list)
if parent_graph is None: if parent_graph is None:
parent_graph = self.parent_graph() graph_mode = 'ignore' if dangling == 'ignore' else 'include'
for parent in parent_graph[name]: 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 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]:
@ -597,6 +669,7 @@ 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
@ -613,18 +686,28 @@ 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)`.
""" """
if name not in self: graph_mode = 'ignore' if dangling == 'ignore' else 'include'
return {}
if order is None: if order is None:
order = self.child_order() order = self.child_order(dangling=graph_mode)
if parent_graph is None: if parent_graph is None:
parent_graph = self.parent_graph() parent_graph = self.parent_graph(dangling=graph_mode)
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 {}
self_keys = set(self.keys()) self_keys = set(self.keys())
@ -633,16 +716,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).items(): for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).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[next_name] & self_keys: if not parent_graph.get(next_name, set()) & self_keys:
continue continue
outers = self.find_refs_local(next_name, parent_graph=parent_graph) outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
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:
@ -1119,17 +1202,19 @@ 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() parent_graph = self.parent_graph(dangling=dangling)
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

@ -1,10 +1,12 @@
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
@ -41,6 +43,79 @@ 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()