[Pather] add route completion callback

This commit is contained in:
Jan Petykiewicz 2026-07-13 22:21:05 -07:00
commit 42d291b1b6
3 changed files with 235 additions and 9 deletions

View file

@ -57,6 +57,7 @@ for custom Tools.
from .pather import ( from .pather import (
Pather as Pather, Pather as Pather,
PortPather as PortPather, PortPather as PortPather,
RouteCompletionCallback as RouteCompletionCallback,
) )
from .error import ( from .error import (
ToolContractError as ToolContractError, ToolContractError as ToolContractError,

View file

@ -46,7 +46,7 @@ Rendering is not transactional across Pattern and Library mutations. A render
exception is terminal for that Pather: callers may catch it for reporting or exception is terminal for that Pather: callers may catch it for reporting or
cleanup, but must not retry rendering or continue routing with the same object. cleanup, but must not retry rendering or continue routing with the same object.
""" """
from typing import Self, Any, Literal, overload from typing import Self, Any, Literal, Protocol, overload
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence
import copy import copy
import logging import logging
@ -56,6 +56,7 @@ from pprint import pformat
from contextlib import contextmanager from contextlib import contextmanager
from itertools import chain from itertools import chain
from types import TracebackType from types import TracebackType
from types import MappingProxyType
import numpy import numpy
from numpy import pi from numpy import pi
@ -92,6 +93,19 @@ RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'w
RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw')) RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
class RouteCompletionCallback(Protocol):
"""Callback invoked after one routing operation updates live Pather state."""
def __call__(
self,
pather: 'Pather',
endpoints: Mapping[str, Port],
/,
) -> None:
"""Handle copied final endpoints keyed by their original routed names."""
...
def _present_route_args(**kwargs: Any) -> dict[str, Any]: def _present_route_args(**kwargs: Any) -> dict[str, Any]:
"""Return explicitly supplied route arguments for planner validation.""" """Return explicitly supplied route arguments for planner validation."""
return {key: value for key, value in kwargs.items() if value is not None} return {key: value for key, value in kwargs.items() if value is not None}
@ -144,10 +158,25 @@ class Pather(PortList):
- `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend - `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend
starting at 'my_port'. If the `Pather` is used as a context manager, starting at 'my_port'. If the `Pather` is used as a context manager,
geometry is generated on clean context exit. geometry is generated on clean context exit.
Examples: Route completion
==========================
Route completion callbacks can add labels or other endpoint annotations
without wrapping the individual routing methods::
def label_route_endpoints(pather, endpoints):
for name, port in endpoints.items():
pather.label('PORT_LABELS', string=name, offset=port.offset)
pather = Pather(
library,
tools=my_tool,
on_route_complete=label_route_endpoints,
)
""" """
__slots__ = ( __slots__ = (
'pattern', 'library', 'tools', 'planner', '_paths', 'pattern', 'library', 'tools', 'planner', '_paths',
'_dead', '_logger', '_render_policy', '_render_append', '_context_depth' 'on_route_complete', '_dead', '_logger', '_render_policy', '_render_append', '_context_depth'
) )
pattern: Pattern pattern: Pattern
@ -173,6 +202,9 @@ class Pather(PortList):
than on the planner instance. than on the planner instance.
""" """
on_route_complete: RouteCompletionCallback | None
"""Optional callback run once after each routing operation updates live state."""
_dead: bool _dead: bool
""" If True, geometry generation is skipped (for debugging) """ """ If True, geometry generation is skipped (for debugging) """
@ -238,6 +270,7 @@ class Pather(PortList):
render: RenderPolicy = 'auto', render: RenderPolicy = 'auto',
render_append: bool = True, render_append: bool = True,
planner: RoutingPlanner | None = None, planner: RoutingPlanner | None = None,
on_route_complete: RouteCompletionCallback | None = None,
) -> None: ) -> None:
""" """
Args: Args:
@ -259,6 +292,11 @@ class Pather(PortList):
whether to append geometry or add a reference. whether to append geometry or add a reference.
planner: Optional stateless route-selection planner. If omitted, planner: Optional stateless route-selection planner. If omitted,
a new `RoutingPlanner` is used. a new `RoutingPlanner` is used.
on_route_complete: Optional callback invoked once per routing
operation after port updates, plugs, and renames, but before
automatic rendering. It receives this Pather and a read-only,
request-ordered mapping from original routed names to copied
final Ports. Callback exceptions propagate without rollback.
""" """
if render not in RENDER_POLICIES: if render not in RENDER_POLICIES:
raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}') raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}')
@ -271,6 +309,7 @@ class Pather(PortList):
self.library = library self.library = library
self.pattern = pattern if pattern is not None else Pattern() self.pattern = pattern if pattern is not None else Pattern()
self.planner = RoutingPlanner() if planner is None else planner self.planner = RoutingPlanner() if planner is None else planner
self.on_route_complete = on_route_complete
self._paths = defaultdict(list) self._paths = defaultdict(list)
if ports is not None: if ports is not None:
@ -500,6 +539,14 @@ class Pather(PortList):
# #
# Routing Logic (Deferred / Incremental) # Routing Logic (Deferred / Incremental)
# #
def _notify_route_complete(self, endpoints: Iterable[tuple[str, Port]]) -> None:
"""Invoke the configured route callback with isolated endpoint snapshots."""
callback = self.on_route_complete
if callback is None:
return
snapshots = MappingProxyType({name: port.copy() for name, port in endpoints})
callback(self, snapshots)
def _apply_route_result(self, result: PreparedRouteResult) -> None: def _apply_route_result(self, result: PreparedRouteResult) -> None:
""" """
Apply every action and deferred rename in a prepared route result. Apply every action and deferred rename in a prepared route result.
@ -521,6 +568,9 @@ class Pather(PortList):
self.plugged({action.portspec: action.plug_into}) self.plugged({action.portspec: action.plug_into})
for old_name, new_name in result.renames: for old_name, new_name in result.renames:
self.rename_ports({old_name: new_name}) self.rename_ports({old_name: new_name})
self._notify_route_complete(
(action.portspec, action.final_port) for action in result.actions
)
render_immediately = ( render_immediately = (
self._render_policy == 'immediate' self._render_policy == 'immediate'
or (self._render_policy == 'auto' and self._context_depth == 0) or (self._render_policy == 'auto' and self._context_depth == 0)
@ -539,7 +589,7 @@ class Pather(PortList):
*, *,
out_rot: float | None = None, out_rot: float | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
) -> None: ) -> Port:
""" """
Move a dead Pather port without generating geometry. Move a dead Pather port without generating geometry.
@ -564,6 +614,7 @@ class Pather(PortList):
self.pattern.ports[portspec] = out_port self.pattern.ports[portspec] = out_port
if plug_into is not None: if plug_into is not None:
self.plugged({portspec: plug_into}) self.plugged({portspec: plug_into})
return out_port.copy()
# #
# High-level Routing Methods # High-level Routing Methods
@ -639,7 +690,7 @@ class Pather(PortList):
raise raise
if length is not None and len(contexts) == 1: if length is not None and len(contexts) == 1:
context = contexts[0] context = contexts[0]
self._apply_dead_fallback( endpoint = self._apply_dead_fallback(
context.portspec, context.portspec,
length, length,
0, 0,
@ -647,11 +698,13 @@ class Pather(PortList):
context.port.ptype, context.port.ptype,
out_ptype = out_ptype, out_ptype = out_ptype,
) )
self._notify_route_complete(((context.portspec, endpoint),))
return self return self
if bounds.get('each') is not None: if bounds.get('each') is not None:
each = bounds['each'] each = bounds['each']
endpoints: list[tuple[str, Port]] = []
for context in contexts: for context in contexts:
self._apply_dead_fallback( endpoint = self._apply_dead_fallback(
context.portspec, context.portspec,
each, each,
0, 0,
@ -659,6 +712,8 @@ class Pather(PortList):
context.port.ptype, context.port.ptype,
out_ptype = out_ptype, out_ptype = out_ptype,
) )
endpoints.append((context.portspec, endpoint))
self._notify_route_complete(endpoints)
return self return self
raise raise
self._apply_route_result(result) self._apply_route_result(result)
@ -755,7 +810,7 @@ class Pather(PortList):
raise raise
_key, _value, length = resolved _key, _value, length = resolved
context = contexts[0] context = contexts[0]
self._apply_dead_fallback( endpoint = self._apply_dead_fallback(
context.portspec, context.portspec,
length, length,
0, 0,
@ -763,6 +818,7 @@ class Pather(PortList):
context.port.ptype, context.port.ptype,
out_ptype = out_ptype, out_ptype = out_ptype,
) )
self._notify_route_complete(((context.portspec, endpoint),))
return self return self
self._apply_route_result(result) self._apply_route_result(result)
return self return self
@ -962,7 +1018,7 @@ class Pather(PortList):
if length is None: if length is None:
raise raise
context = contexts[0] context = contexts[0]
self._apply_dead_fallback( endpoint = self._apply_dead_fallback(
context.portspec, context.portspec,
length, length,
0, 0,
@ -970,10 +1026,11 @@ class Pather(PortList):
context.port.ptype, context.port.ptype,
out_ptype = out_ptype, out_ptype = out_ptype,
) )
self._notify_route_complete(((context.portspec, endpoint),))
return self return self
fallback_length = length if length is not None else 0 fallback_length = length if length is not None else 0
context = contexts[0] context = contexts[0]
self._apply_dead_fallback( endpoint = self._apply_dead_fallback(
context.portspec, context.portspec,
fallback_length, fallback_length,
offset, offset,
@ -982,6 +1039,7 @@ class Pather(PortList):
out_rot = pi, out_rot = pi,
out_ptype = out_ptype, out_ptype = out_ptype,
) )
self._notify_route_complete(((context.portspec, endpoint),))
return self return self
self._apply_route_result(result) self._apply_route_result(result)
return self return self
@ -1035,7 +1093,7 @@ class Pather(PortList):
): ):
raise raise
context = contexts[0] context = contexts[0]
self._apply_dead_fallback( endpoint = self._apply_dead_fallback(
context.portspec, context.portspec,
length, length,
offset, offset,
@ -1044,6 +1102,7 @@ class Pather(PortList):
out_rot = 0.0, out_rot = 0.0,
out_ptype = out_ptype, out_ptype = out_ptype,
) )
self._notify_route_complete(((context.portspec, endpoint),))
return self return self
self._apply_route_result(result) self._apply_route_result(result)
return self return self

View file

@ -0,0 +1,166 @@
from collections.abc import Mapping, Sequence
from typing import Any, Literal
import numpy
import pytest
from masque import Library, Pather, Port
from masque.builder import PathTool, RenderStep, RouteCompletionCallback, Tool
from masque.error import BuildError
def test_route_completion_can_label_before_automatic_render() -> None:
calls = 0
def label_endpoints(pather: Pather, endpoints: Mapping[str, Port]) -> None:
nonlocal calls
calls += 1
assert not pather.pattern.has_shapes()
assert numpy.allclose(pather.ports['A'].offset, endpoints['A'].offset)
for name, port in endpoints.items():
pather.label('LABELS', string=name, offset=port.offset)
callback: RouteCompletionCallback = label_endpoints
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
on_route_complete=callback,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.at('A').straight(5)
assert calls == 1
assert p.pattern.has_shapes()
assert len(p.pattern.labels['LABELS']) == 1
assert p.pattern.labels['LABELS'][0].string == 'A'
assert numpy.allclose(p.pattern.labels['LABELS'][0].offset, p.ports['A'].offset)
def test_bundle_route_completion_is_ordered_read_only_and_isolated() -> None:
calls: list[Mapping[str, Port]] = []
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
_ = pather
calls.append(endpoints)
with pytest.raises(TypeError):
endpoints['extra'] = Port((0, 0)) # type: ignore[index]
endpoints['B'].translate((100, 100))
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
on_route_complete=record,
)
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.trace(['A', 'B'], None, each=5)
assert len(calls) == 1
assert list(calls[0]) == ['A', 'B']
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
assert numpy.allclose(p.ports['B'].offset, (-5, 2))
def test_trace_into_completion_retains_consumed_source_endpoint() -> None:
calls: list[Mapping[str, Port]] = []
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
assert set(pather.ports) == {'src'}
assert numpy.allclose(pather.ports['src'].offset, (20, 0))
calls.append(endpoints)
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
on_route_complete=record,
)
p.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
p.ports['dst'] = Port((-10, 0), rotation=numpy.pi, ptype='wire')
p.ports['thru'] = Port((20, 0), rotation=0, ptype='wire')
p.trace_into('src', 'dst', thru='thru')
assert len(calls) == 1
assert list(calls[0]) == ['src']
assert numpy.allclose(calls[0]['src'].offset, (-10, 0))
def test_route_completion_exception_propagates_before_render() -> None:
def fail(pather: Pather, endpoints: Mapping[str, Port]) -> None:
_ = pather, endpoints
raise RuntimeError('completion failed')
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
on_route_complete=fail,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(RuntimeError, match='completion failed'):
p.straight('A', 5)
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
assert p._paths['A']
assert not p.pattern.has_shapes()
class NoRouteTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
**kwargs: Any,
) -> tuple[()]:
_ = kind, kwargs
return ()
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs: Any,
) -> Library:
_ = batch, port_names, kwargs
return Library()
def test_dead_bundle_fallback_invokes_route_completion_once() -> None:
calls: list[Mapping[str, Port]] = []
p = Pather(
Library(),
tools=NoRouteTool(),
on_route_complete=lambda _pather, endpoints: calls.append(endpoints),
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
p.set_dead()
p.trace(['B', 'A'], None, each=5)
assert len(calls) == 1
assert list(calls[0]) == ['B', 'A']
assert numpy.allclose(calls[0]['B'].offset, (-5, 2))
assert numpy.allclose(calls[0]['A'].offset, (-5, 0))
assert not p.pattern.has_shapes()
def test_failed_route_does_not_invoke_route_completion() -> None:
calls = 0
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
nonlocal calls
_ = pather, endpoints
calls += 1
p = Pather(Library(), tools=NoRouteTool(), on_route_complete=record)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError):
p.straight('A', 5)
assert calls == 0