From 42d291b1b64a5d664dccc5ba7f9ac93058e2e905 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 13 Jul 2026 22:21:05 -0700 Subject: [PATCH] [Pather] add route completion callback --- masque/builder/__init__.py | 1 + masque/builder/pather.py | 77 +++++++-- masque/test/test_pather_route_completion.py | 166 ++++++++++++++++++++ 3 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 masque/test/test_pather_route_completion.py diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index de1a0d1..cc0b2aa 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -57,6 +57,7 @@ for custom Tools. from .pather import ( Pather as Pather, PortPather as PortPather, + RouteCompletionCallback as RouteCompletionCallback, ) from .error import ( ToolContractError as ToolContractError, diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 85acca9..b773da8 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -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 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 import copy import logging @@ -56,6 +56,7 @@ from pprint import pformat from contextlib import contextmanager from itertools import chain from types import TracebackType +from types import MappingProxyType import numpy 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')) +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]: """Return explicitly supplied route arguments for planner validation.""" 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 starting at 'my_port'. If the `Pather` is used as a context manager, 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__ = ( '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 @@ -173,6 +202,9 @@ class Pather(PortList): than on the planner instance. """ + on_route_complete: RouteCompletionCallback | None + """Optional callback run once after each routing operation updates live state.""" + _dead: bool """ If True, geometry generation is skipped (for debugging) """ @@ -238,6 +270,7 @@ class Pather(PortList): render: RenderPolicy = 'auto', render_append: bool = True, planner: RoutingPlanner | None = None, + on_route_complete: RouteCompletionCallback | None = None, ) -> None: """ Args: @@ -259,6 +292,11 @@ class Pather(PortList): whether to append geometry or add a reference. planner: Optional stateless route-selection planner. If omitted, 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: raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}') @@ -271,6 +309,7 @@ class Pather(PortList): self.library = library self.pattern = pattern if pattern is not None else Pattern() self.planner = RoutingPlanner() if planner is None else planner + self.on_route_complete = on_route_complete self._paths = defaultdict(list) if ports is not None: @@ -500,6 +539,14 @@ class Pather(PortList): # # 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: """ 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}) for old_name, new_name in result.renames: self.rename_ports({old_name: new_name}) + self._notify_route_complete( + (action.portspec, action.final_port) for action in result.actions + ) render_immediately = ( self._render_policy == 'immediate' or (self._render_policy == 'auto' and self._context_depth == 0) @@ -539,7 +589,7 @@ class Pather(PortList): *, out_rot: float | None = None, out_ptype: str | None = None, - ) -> None: + ) -> Port: """ Move a dead Pather port without generating geometry. @@ -564,6 +614,7 @@ class Pather(PortList): self.pattern.ports[portspec] = out_port if plug_into is not None: self.plugged({portspec: plug_into}) + return out_port.copy() # # High-level Routing Methods @@ -639,7 +690,7 @@ class Pather(PortList): raise if length is not None and len(contexts) == 1: context = contexts[0] - self._apply_dead_fallback( + endpoint = self._apply_dead_fallback( context.portspec, length, 0, @@ -647,11 +698,13 @@ class Pather(PortList): context.port.ptype, out_ptype = out_ptype, ) + self._notify_route_complete(((context.portspec, endpoint),)) return self if bounds.get('each') is not None: each = bounds['each'] + endpoints: list[tuple[str, Port]] = [] for context in contexts: - self._apply_dead_fallback( + endpoint = self._apply_dead_fallback( context.portspec, each, 0, @@ -659,6 +712,8 @@ class Pather(PortList): context.port.ptype, out_ptype = out_ptype, ) + endpoints.append((context.portspec, endpoint)) + self._notify_route_complete(endpoints) return self raise self._apply_route_result(result) @@ -755,7 +810,7 @@ class Pather(PortList): raise _key, _value, length = resolved context = contexts[0] - self._apply_dead_fallback( + endpoint = self._apply_dead_fallback( context.portspec, length, 0, @@ -763,6 +818,7 @@ class Pather(PortList): context.port.ptype, out_ptype = out_ptype, ) + self._notify_route_complete(((context.portspec, endpoint),)) return self self._apply_route_result(result) return self @@ -962,7 +1018,7 @@ class Pather(PortList): if length is None: raise context = contexts[0] - self._apply_dead_fallback( + endpoint = self._apply_dead_fallback( context.portspec, length, 0, @@ -970,10 +1026,11 @@ class Pather(PortList): context.port.ptype, out_ptype = out_ptype, ) + self._notify_route_complete(((context.portspec, endpoint),)) return self fallback_length = length if length is not None else 0 context = contexts[0] - self._apply_dead_fallback( + endpoint = self._apply_dead_fallback( context.portspec, fallback_length, offset, @@ -982,6 +1039,7 @@ class Pather(PortList): out_rot = pi, out_ptype = out_ptype, ) + self._notify_route_complete(((context.portspec, endpoint),)) return self self._apply_route_result(result) return self @@ -1035,7 +1093,7 @@ class Pather(PortList): ): raise context = contexts[0] - self._apply_dead_fallback( + endpoint = self._apply_dead_fallback( context.portspec, length, offset, @@ -1044,6 +1102,7 @@ class Pather(PortList): out_rot = 0.0, out_ptype = out_ptype, ) + self._notify_route_complete(((context.portspec, endpoint),)) return self self._apply_route_result(result) return self diff --git a/masque/test/test_pather_route_completion.py b/masque/test/test_pather_route_completion.py new file mode 100644 index 0000000..36fbd69 --- /dev/null +++ b/masque/test/test_pather_route_completion.py @@ -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