[Pather] move render check from __del__ to __exit__ and use render= enum instead of auto_render

This commit is contained in:
Jan Petykiewicz 2026-07-09 12:28:05 -07:00
commit 9a39a436b2
13 changed files with 277 additions and 126 deletions

View file

@ -29,7 +29,7 @@ Contents
* Define a custom `Tool` that exposes primitive routing offers
* Use primitive offers to automatically transition between path types
- [renderpather](renderpather.py)
* Use `Pather(auto_render=False)` and `PathTool` to build a layout similar to the one in [pather](pather.py),
* Use `Pather(render='deferred')` and `PathTool` to build a layout similar to the one in [pather](pather.py),
but using `Path` shapes instead of `Polygon`s.
- [port_pather](port_pather.py)
* Use `PortPather` and the `.at()` syntax for more concise routing

View file

@ -13,7 +13,7 @@ def main() -> None:
library, M1_tool, M2_tool = prepare_tools()
# Create a deferred Pather and place some initial pads (same as Pather tutorial)
rpather = Pather(library, tools=M2_tool, auto_render=False)
rpather = Pather(library, tools=M2_tool, render='deferred')
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
@ -157,7 +157,7 @@ def main() -> None:
#
# Rendering and Saving
#
# Since we deferred auto-rendering, we must call .render() to generate the geometry.
# Since routing is deferred, we must call .render() to generate the geometry.
rpather.render()
library['PortPather_Tutorial'] = rpather.pattern

View file

@ -13,7 +13,7 @@ def main() -> None:
#
# To illustrate deferred routing with `Pather`, we use `PathTool` instead
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
# but when used with `Pather(auto_render=False)`, it can consolidate multiple routing steps into
# but when used with `Pather(render='deferred')`, it can consolidate multiple routing steps into
# a single `Path` shape.
#
# We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py`
@ -39,7 +39,7 @@ def main() -> None:
# and what port type to present.
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
rpather = Pather(tools=M2_ptool, library=library, auto_render=False)
rpather = Pather(tools=M2_ptool, library=library, render='deferred')
# As in the pather tutorial, we make some pads and labels...
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})

View file

@ -3,7 +3,7 @@ Unified Pattern assembly and routing (`Pather`).
`Pather` is the public object that owns layout state. It holds the working
Pattern, Library, per-port Tool assignments, pending `RenderStep`s, and routing
side effects such as plug consumption, port renames, and auto-render. The
side effects such as plug consumption, port renames, and automatic rendering. The
planner package is intentionally internal: custom route generators should
extend `Tool.primitive_offers()` and `Tool.render()` rather than depending on
planner classes or search details.
@ -16,7 +16,7 @@ Routing is split into four ownership phases:
`RenderStep.data`, and returns prepared actions,
- application: `Pather` appends the prepared steps to its pending queue,
replaces live output ports, consumes plug destinations, applies deferred
trace-thru renames, and batches auto-render around the whole route,
trace-thru renames, and batches immediate rendering around the whole route,
- rendering: pending steps are grouped by live port, Tool, and continuity before
`Tool.render()` builds geometry for each compatible batch.
@ -27,7 +27,7 @@ prepared actions are applied, later commit, plug, rename, render, or insertion
failures may leave partial output; that mutation boundary belongs to Pather,
not to Tool implementations or the route solver.
"""
from typing import Self, Any, overload
from typing import Self, Any, Literal, overload
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence
import copy
import logging
@ -36,6 +36,7 @@ from functools import wraps
from pprint import pformat
from contextlib import contextmanager
from itertools import chain
from types import TracebackType
import numpy
from numpy import pi
@ -64,6 +65,8 @@ from .logging import PatherLogger
logger = logging.getLogger(__name__)
RenderPolicy = Literal['auto', 'immediate', 'deferred', 'warn', 'error', 'ignore']
RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'warn', 'error', 'ignore')
class Pather(PortList):
@ -76,9 +79,9 @@ class Pather(PortList):
pattern, and a set of `Tool`s for generating routing segments.
Routing operations (`trace`, `jog`, `uturn`, etc.) select primitive offers
from the active `Tool` and compose them into deferred `RenderStep`s.
Set `auto_render=False` to defer geometry generation until an explicit call
to `render()`.
from the active `Tool` and compose them into `RenderStep`s. By default,
geometry is rendered after each route, unless the `Pather` is used as a
context manager.
Examples: Creating a Pather
===========================
@ -94,12 +97,12 @@ class Pather(PortList):
connects port 'A' of the current pattern to port 'C' of `subdevice`.
- `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend
starting at 'my_port'. Geometry is added immediately by default.
Set `auto_render=False` to defer and call `pather.render()` later.
starting at 'my_port'. If the `Pather` is used as a context manager,
geometry is generated on clean context exit.
"""
__slots__ = (
'pattern', 'library', 'tools', 'planner', '_paths',
'_dead', '_logger', '_auto_render', '_auto_render_append'
'_dead', '_logger', '_render_policy', '_render_append', '_context_depth'
)
pattern: Pattern
@ -128,8 +131,14 @@ class Pather(PortList):
_logger: PatherLogger
""" Handles diagnostic logging of operations """
_auto_render: bool
""" If True, routing operations call render() immediately """
_render_policy: RenderPolicy
""" Routing render behavior """
_render_append: bool
""" If True, automatic render calls append geometry instead of adding references """
_context_depth: int
""" Number of active context-manager entries """
_paths: defaultdict[str, list[RenderStep]]
""" Per-port pending render steps, consumed by `render()` """
@ -168,8 +177,8 @@ class Pather(PortList):
tools: Tool | MutableMapping[str | None, Tool] | None = None,
name: str | None = None,
debug: bool = False,
auto_render: bool = True,
auto_render_append: bool = True,
render: RenderPolicy = 'auto',
render_append: bool = True,
planner: RoutingPlanner | None = None,
) -> None:
"""
@ -181,16 +190,26 @@ class Pather(PortList):
tools: Tool(s) to use for routing segments.
name: If specified, `library[name]` is set to `self.pattern`.
debug: If True, enables detailed logging.
auto_render: If True, enables immediate rendering of routing steps.
auto_render_append: If `auto_render` is True, determines whether
to append geometry or add a reference.
render: Routing render policy. `'auto'` renders after each route
outside a context manager and defers until clean context exit
inside one. Use `'immediate'` to always render after each route,
`'deferred'` to keep paths pending until `render()` or clean
context exit, `'warn'` to log pending paths on clean context
exit, `'error'` to reject pending paths on clean context exit,
or `'ignore'` to leave pending paths silent.
render_append: If an automatic render is triggered, determines
whether to append geometry or add a reference.
planner: Optional stateless route-selection planner. If omitted,
a new `RoutingPlanner` is used.
"""
if render not in RENDER_POLICIES:
raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}')
self._dead = False
self._logger = PatherLogger(debug=debug)
self._auto_render = auto_render
self._auto_render_append = auto_render_append
self._render_policy = render
self._render_append = render_append
self._context_depth = 0
self.library = library
self.pattern = pattern if pattern is not None else Pattern()
self.planner = RoutingPlanner() if planner is None else planner
@ -213,9 +232,40 @@ class Pather(PortList):
if name is not None:
library[name] = self.pattern
def __del__(self) -> None:
if any(self._paths.values()):
logger.warning(f'Pather {self} had pending render steps', stack_info=True)
def __enter__(self) -> Self:
self._context_depth += 1
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool:
_ = exc_value, traceback
self._context_depth -= 1
if exc_type is not None or self._context_depth != 0 or not any(self._paths.values()):
return False
if self._render_policy in ('auto', 'deferred'):
self.render(append=self._render_append)
elif self._render_policy == 'warn':
logger.warning(
'Pather context exited with %s; call render() or use render="deferred"',
self._pending_render_summary(),
)
elif self._render_policy == 'error':
raise BuildError(f'Pather context exited with {self._pending_render_summary()}')
return False
def _pending_render_summary(self) -> str:
ports = [(portspec, len(steps)) for portspec, steps in self._paths.items() if steps]
port_count = len(ports)
step_count = sum(count for _portspec, count in ports)
return (
f'{step_count} pending render step{"s" if step_count != 1 else ""} '
f'on {port_count} port{"s" if port_count != 1 else ""}'
)
def __repr__(self) -> str:
s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
@ -397,30 +447,28 @@ class Pather(PortList):
Apply every action and deferred rename in a prepared route result.
Route actions may contain several primitive render steps and port
mutations. Temporarily disabling auto-render prevents each step from
becoming its own rendered cell.
mutations. Immediate rendering happens once after the whole prepared
result has been applied.
"""
saved_auto_render = self._auto_render
self._auto_render = False
try:
for action in result.actions:
if not action.render_steps:
raise BuildError('Prepared route action has no render steps')
for action in result.actions:
if not action.render_steps:
raise BuildError('Prepared route action has no render steps')
if not self._dead:
self._paths[action.portspec].extend(action.render_steps)
if not self._dead:
self._paths[action.portspec].extend(action.render_steps)
self.pattern.ports[action.portspec] = action.final_port.copy()
self.pattern.ports[action.portspec] = action.final_port.copy()
if action.plug_into is not None:
self.plugged({action.portspec: action.plug_into})
for old_name, new_name in result.renames:
self.rename_ports({old_name: new_name})
self._auto_render = saved_auto_render
if saved_auto_render and any(self._paths.values()):
self.render(append = self._auto_render_append)
finally:
self._auto_render = saved_auto_render
if action.plug_into is not None:
self.plugged({action.portspec: action.plug_into})
for old_name, new_name in result.renames:
self.rename_ports({old_name: new_name})
render_immediately = (
self._render_policy == 'immediate'
or (self._render_policy == 'auto' and self._context_depth == 0)
)
if render_immediately and any(self._paths.values()):
self.render(append=self._render_append)
def _apply_dead_fallback(
self,

View file

@ -560,7 +560,7 @@ class RenderStep:
"""
A single deferred routing operation.
`Pather(auto_render=False)` stores these records while routing and later
`Pather(render='deferred')` stores these records while routing and later
passes batches of compatible steps to `Tool.render()` when `Pather.render()`
is called.
"""

View file

@ -122,7 +122,7 @@ def test_autotool_straight_offer_supports_requested_output_transition(
) -> None:
_p, tool, lib = autotool_setup
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["start"] = Port((0, 0), pi, ptype="wire_m1")
p.straight("start", 10, out_ptype="wire_m2")
@ -158,7 +158,7 @@ def test_pather_straight_topology_allows_transition_offset_cancellation() -> Non
.add_transition(lib.abstract("trans_out"), "EXT", "CORE")
)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), pi, ptype="ext_in")
p.trace("A", None, length=10, out_ptype="ext_out")
@ -550,7 +550,7 @@ def test_autotool_s_offer_bbox_matches_rendered_sbend() -> None:
assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib)
pather = Pather(lib, tools=tool, auto_render=False)
pather = Pather(lib, tools=tool, render='deferred')
pather.ports["A"] = Port((0, 0), 0, ptype="ext")
pather.jog("A", 4)
@ -581,7 +581,7 @@ def test_autotool_s_offer_uses_absolute_jog_range_for_both_signs() -> None:
assert valid_positive
assert valid_negative
pather = Pather(Library(), tools=tool, auto_render=False)
pather = Pather(Library(), tools=tool, render='deferred')
pather.ports["A"] = Port((0, 0), 0, ptype="core")
pather.jog("A", -4)
assert_allclose(pather.ports["A"].offset, [-10, 4])
@ -623,7 +623,7 @@ def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None:
assert [offer.endpoint_at(-4).y for offer in offers if offer.jog_domain == (-4.0, -4.0)] == [-4]
for jog, expected_y in ((4, -4), (-4, 4)):
pather = Pather(Library(), tools=tool, auto_render=False)
pather = Pather(Library(), tools=tool)
pather.ports["A"] = Port((0, 0), 0, ptype="core")
pather.jog("A", jog)
assert_allclose(pather.ports["A"].offset, [-10, expected_y])
@ -669,7 +669,7 @@ def test_autotool_uturn_offer_mirror_exposes_both_signs() -> None:
def test_pather_autotool_uses_prebaked_uturn_offer() -> None:
tool, lib = make_uturn_tool()
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="core")
p.uturn("A", 4, length=10)
@ -695,7 +695,7 @@ def test_pather_autotool_omitted_uturn_composes_l_offers(
multi_bend_tool: tuple[AutoTool, Library],
) -> None:
tool, lib = multi_bend_tool
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire")
p.uturn("A", 20)
@ -714,7 +714,7 @@ def test_pather_autotool_explicit_uturn_composes_l_offers(
multi_bend_tool: tuple[AutoTool, Library],
) -> None:
tool, lib = multi_bend_tool
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire")
p.uturn("A", 20, length=10)
@ -739,7 +739,7 @@ def test_autotool_offer_bbox_rejects_invalid_parameter(multi_bend_tool: tuple[Au
def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, Library]) -> None:
tool, lib = multi_bend_tool
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire")
p.trace("A", True, length=15)
@ -760,7 +760,7 @@ def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None:
return make_straight(length, ptype="wire")
tool = AutoTool().add_straight("wire", make_marked_straight, "A", length_range=(0, 1e8))
p = Pather(Library(), tools=tool, auto_render=False)
p = Pather(Library(), tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire")
p.straight("A", 5, marker="route")
@ -790,7 +790,7 @@ def test_autotool_bend_offer_supports_requested_output_transition(ccw: bool) ->
.add_transition(lib.abstract("out_trans"), "EXT", "CORE")
)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="core")
p.trace("A", ccw, length=10, out_ptype="ext")
@ -824,7 +824,7 @@ def test_autotool_bend_offer_supports_bend_input_transition(ccw: bool) -> None:
.add_transition(lib.abstract("bend_trans"), "MID", "CORE")
)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="core")
p.trace("A", ccw, length=10, out_ptype="mid")
@ -857,7 +857,7 @@ def test_pather_accepts_bend_offer_with_zero_lateral_endpoint() -> None:
.add_transition(lib.abstract("bend_trans"), "MID", "CORE")
)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="core")
p.trace("A", True, length=10, out_ptype="mid")
@ -897,7 +897,7 @@ def test_autotool_bend_offer_supports_bend_and_output_transitions(ccw: bool) ->
.add_transition(lib.abstract("out_trans"), "EXT", "MID")
)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="core")
p.trace("A", ccw, length=12, out_ptype="ext")
@ -955,7 +955,7 @@ def test_pather_autotool_pure_sbend_with_transition_dx() -> None:
assert isinstance(s_data, AutoTool.GeneratedData)
assert s_offer.out_ptype == "core"
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="ext")
p.jog("A", 4)

View file

@ -70,7 +70,7 @@ def test_autotool_uturn() -> None:
.add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True)
)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), 0)
p.at('A').uturn(offset=-2000, length=1000)
@ -82,7 +82,7 @@ def test_autotool_uturn() -> None:
def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None:
tool, lib = multi_bend_tool
rp = Pather(lib, tools=tool, auto_render=False)
rp = Pather(lib, tools=tool)
rp.ports["A"] = Port((0,0), 0, ptype="wire")
rp.jog("A", 10, length=20)

View file

@ -117,7 +117,7 @@ class CountingPathTool(PathTool):
def test_pather_jog_failed_two_bend_route_is_atomic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, render='immediate')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='S-bend'):
@ -130,7 +130,7 @@ def test_pather_jog_failed_two_bend_route_is_atomic() -> None:
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, render='immediate')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 1.5, length=5)
@ -139,10 +139,10 @@ def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None
assert p.pattern.ports['A'].rotation == 0
assert len(p._paths['A']) == 0
def test_pather_auto_render_batches_multi_step_selected_route_once() -> None:
def test_pather_immediate_render_batches_multi_step_selected_route_once() -> None:
lib = Library()
tool = CountingPathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, auto_render=True)
p = Pather(lib, tools=tool, render='immediate')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 4, length=10)
@ -169,7 +169,7 @@ def test_pather_jog_length_solved_from_single_position_bound() -> None:
def test_pather_positional_bound_requires_port_rotation() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), auto_render=False)
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=None, ptype='wire')
with pytest.raises(BuildError, match='Ports must have rotation'):
@ -179,7 +179,7 @@ def test_pather_positional_bound_requires_port_rotation() -> None:
def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 2)
@ -195,7 +195,7 @@ def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
def test_pather_trace_omitted_length_uses_minimum_offer() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.trace('A', None)
@ -211,7 +211,7 @@ def test_pather_trace_omitted_length_uses_minimum_offer() -> None:
def test_pather_trace_to_without_bound_uses_single_port_trace_minimum() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.trace_to('A', False)
@ -229,7 +229,7 @@ def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
with pytest.raises(BuildError, match='exactly one positional bound'):
p.trace_to('A', None, **kwargs)
p = Pather(Library(), tools=tool)
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='length cannot be combined'):
p.trace_to('A', None, x=-5, length=3)
@ -261,7 +261,7 @@ def test_planner_constrained_bend_requires_jog() -> None:
def test_pather_trace_each_plans_all_ports_before_mutation() -> None:
tool = FirstPortOnlyTraceTool()
p = Pather(Library(), tools=tool, auto_render=False)
p = Pather(Library(), tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-2, 5), rotation=0, ptype='blocked')
@ -277,7 +277,7 @@ def test_pather_trace_each_plans_all_ports_before_mutation() -> None:
def test_pather_bundle_trace_plans_all_ports_before_mutation_or_render() -> None:
tool = FirstPortOnlyTraceTool()
p = Pather(Library(), tools=tool, auto_render=True)
p = Pather(Library(), tools=tool, render='immediate')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='blocked')
@ -343,7 +343,7 @@ def test_pather_route_commit_failure_is_atomic_for_multi_port_trace() -> None:
return tree
tool = CommitFailureTool()
p = Pather(Library(), tools=tool, auto_render=True)
p = Pather(Library(), tools=tool, render='immediate')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='bad')
@ -433,7 +433,7 @@ def test_pather_uturn_does_not_use_direct_planl_fallback() -> None:
jog = -1
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False)
p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='No legal primitive offer for omitted-length U-turn'):
@ -476,7 +476,7 @@ def test_pather_su_topology_rejects_out_ptype_sensitive_planl_jog() -> None:
ptype = out_ptype or in_ptype or 'wire'
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=OutPtypeSensitiveTool(), auto_render=False)
p = Pather(Library(), tools=OutPtypeSensitiveTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises((BuildError, NotImplementedError)):
@ -509,7 +509,7 @@ def test_pather_two_l_planl_only_uturn_is_not_supported() -> None:
jog = -1
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False)
p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises((BuildError, NotImplementedError)):
@ -543,7 +543,7 @@ def test_pather_two_l_planl_only_jog_is_not_supported() -> None:
jog = -1
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False)
p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises((BuildError, NotImplementedError)):

View file

@ -62,7 +62,7 @@ def test_builder_tool_symbol_exports() -> None:
def test_pather_pending_render_steps_are_private() -> None:
p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1), auto_render=False)
p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1))
assert not hasattr(p, 'paths')
assert hasattr(p, '_paths')
@ -81,7 +81,7 @@ def test_pather_accepts_and_reuses_planner_instance() -> None:
p = Pather(
Library(),
tools=PathTool(layer=(1, 0), width=1),
auto_render=False,
render='deferred',
planner=planner,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
@ -148,7 +148,7 @@ def test_pather_dead_ports() -> None:
def test_pather_trace_basic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
# Routing extends opposite the port's inward-facing rotation.
p.pattern.ports['A'] = Port((0, 0), rotation=0)
@ -164,7 +164,7 @@ def test_pather_trace_basic() -> None:
def test_pather_trace_to() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
@ -177,7 +177,7 @@ def test_pather_trace_to() -> None:
def test_pather_bundle_trace() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -194,13 +194,13 @@ def test_pather_bundle_trace() -> None:
def test_portpather_default_spacing_matches_explicit_spacing() -> None:
lib_default = Library()
tool_default = PathTool(layer='M1', width=1000)
p_default = Pather(lib_default, tools=tool_default, auto_render=False)
p_default = Pather(lib_default, tools=tool_default)
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
lib_explicit = Library()
tool_explicit = PathTool(layer='M1', width=1000)
p_explicit = Pather(lib_explicit, tools=tool_explicit, auto_render=False)
p_explicit = Pather(lib_explicit, tools=tool_explicit)
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -211,11 +211,11 @@ def test_portpather_default_spacing_matches_explicit_spacing() -> None:
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
def test_portpather_default_spacing_reused_and_overridden() -> None:
p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -227,11 +227,11 @@ def test_portpather_default_spacing_reused_and_overridden() -> None:
assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset)
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_override.pattern.ports['A'] = Port((0, 0), rotation=0)
p_override.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -244,7 +244,7 @@ def test_portpather_default_spacing_reused_and_overridden() -> None:
def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -256,7 +256,7 @@ def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None:
def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
p.pattern.ports['C'] = Port((0, 4000), rotation=0)
@ -270,7 +270,7 @@ def test_portpather_default_spacing_vector_revalidated_after_selection_change()
def test_pather_each_bound() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-1000, 2000), rotation=0)
@ -390,7 +390,7 @@ def test_rename() -> None:
def test_pather_dead_fallback_preserves_out_ptype() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
@ -424,7 +424,7 @@ def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None:
commit_planner=lambda length: {'length': length},
),)
p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire'), auto_render=False)
p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
@ -472,7 +472,7 @@ def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None:
),
)
p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire'), auto_render=False)
p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):

View file

@ -10,7 +10,7 @@ from masque.error import PortError, PatternError
def test_pather_place_treeview_resolves_once() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, render='deferred')
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
@ -24,7 +24,7 @@ def test_pather_place_treeview_resolves_once() -> None:
def test_pather_plug_treeview_resolves_once() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
@ -39,7 +39,7 @@ def test_pather_plug_treeview_resolves_once() -> None:
def test_pather_failed_plug_does_not_add_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.annotations = {'k': [1]}
p.pattern.ports['A'] = Port((0, 0), rotation=0)
@ -60,7 +60,7 @@ def test_pather_failed_plug_does_not_add_break_marker() -> None:
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)
@ -80,7 +80,7 @@ def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 0), rotation=0)
@ -107,7 +107,7 @@ def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
def test_pather_failed_plugged_does_not_add_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)

View file

@ -360,7 +360,7 @@ def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None:
_ = kind, in_ptype, out_ptype, kwargs
raise NotImplementedError
p = Pather(Library(), tools=NoOfferTool(), auto_render=False)
p = Pather(Library(), tools=NoOfferTool())
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
@ -381,7 +381,7 @@ def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None
_ = kind, in_ptype, out_ptype, kwargs
raise err_type('offer query failed')
p = Pather(Library(), tools=BrokenTool(), auto_render=False)
p = Pather(Library(), tools=BrokenTool())
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(err_type):
@ -413,7 +413,7 @@ def test_pather_selects_lowest_cost_offer() -> None:
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=0, **offer_callbacks(low)),
)
p = Pather(Library(), tools=MultiOfferTool(), auto_render=False)
p = Pather(Library(), tools=MultiOfferTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 7)
@ -460,7 +460,7 @@ def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving(
),
)
p = Pather(Library(), tools=RotationOfferTool(), auto_render=False)
p = Pather(Library(), tools=RotationOfferTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 7)
@ -503,7 +503,7 @@ def test_pather_commits_only_selected_offer() -> None:
return (make('expensive', 100), make('cheap', 0))
p = Pather(Library(), tools=RecordingTool(), auto_render=False)
p = Pather(Library(), tools=RecordingTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 5)
@ -553,7 +553,7 @@ def test_pather_routes_with_offer_only_s_and_u_tool() -> None:
),)
return ()
p = Pather(Library(), tools=OfferOnlyTool(), auto_render=False)
p = Pather(Library(), tools=OfferOnlyTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 3)
p.uturn('A', 4)

View file

@ -1,4 +1,5 @@
from typing import TYPE_CHECKING, cast
import logging
import pytest
import numpy
@ -20,10 +21,27 @@ if TYPE_CHECKING:
def deferred_render_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
rp = Pather(lib, tools=tool, auto_render=False)
rp = Pather(lib, tools=tool, render='deferred')
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return rp, tool, lib
def add_pending_straight(pather: Pather) -> None:
pather.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
pather.straight("start", 10)
def route_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
def fail_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
raise RuntimeError('body failed')
def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10).straight(10)
@ -101,7 +119,7 @@ def test_portpather_translate_only_affects_future_steps(deferred_render_setup: t
def test_deferred_render_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, auto_render=False)
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, render='deferred')
rp.set_dead()
rp.straight("in", -10)
@ -113,6 +131,91 @@ def test_deferred_render_dead_ports() -> None:
rp.render()
assert not rp.pattern.has_shapes()
def test_pather_default_auto_policy_renders_immediately_outside_context() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
p.straight("start", 10)
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_default_auto_policy_defers_until_clean_context_exit() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with Pather(lib, tools=tool) as p:
add_pending_straight(p)
assert not p.pattern.has_shapes()
assert len(p._paths["start"]) == 1
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_context_warn_policy_logs_pending_paths(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='warn') as p:
add_pending_straight(p)
records = [
record
for record in caplog.records
if 'Pather context exited with 1 pending render step on 1 port' in record.getMessage()
]
assert len(records) == 1
assert records[0].stack_info is None
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_error_policy_rejects_pending_paths() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(BuildError, match='1 pending render step on 1 port'):
route_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_ignore_policy_leaves_pending_paths_silent(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='ignore') as p:
add_pending_straight(p)
assert not caplog.records
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_policy_does_not_mask_body_exception() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(RuntimeError, match='body failed'):
fail_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_rejects_invalid_render_policy() -> None:
with pytest.raises(BuildError, match='Invalid render policy'):
Pather(Library(), tools=PathTool(layer=(1, 0), width=2), render='later') # type: ignore[arg-type]
def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10)
@ -174,7 +277,7 @@ def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
def test_deferred_render_uturn_fallback() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = Pather(lib, tools=tool, auto_render=False)
rp = Pather(lib, tools=tool, render='deferred')
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
rp.at('A').uturn(offset=10000, length=5000)
@ -213,7 +316,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
return tree
lib = Library()
p = Pather(lib, tools=FullTreeTool(), auto_render=False)
p = Pather(lib, tools=FullTreeTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -258,7 +361,7 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
return batch[0].data
lib = Library()
p = Pather(lib, tools=TraceTreeTool(), auto_render=False)
p = Pather(lib, tools=TraceTreeTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -293,7 +396,7 @@ def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
lib = Library()
lib['_seg'] = Pattern(annotations={'stale': [1]})
p = Pather(lib, tools=MissingSingleUseTool(), auto_render=False)
p = Pather(lib, tools=MissingSingleUseTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -329,7 +432,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
lib = Library()
lib['shared'] = Pattern(annotations={'shared': [1]})
p = Pather(lib, tools=SharedRefTool(), auto_render=False)
p = Pather(lib, tools=SharedRefTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -362,7 +465,7 @@ def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append:
return tree
lib = Library()
p = Pather(lib, tools=WrongEndpointTool(), auto_render=False)
p = Pather(lib, tools=WrongEndpointTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -396,7 +499,7 @@ def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> Non
return tree
lib = Library()
p = Pather(lib, tools=WrongPtypeTool(), auto_render=False)
p = Pather(lib, tools=WrongPtypeTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -409,7 +512,7 @@ def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> Non
def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = Pather(lib, tools=tool, auto_render=False)
rp = Pather(lib, tools=tool, render='deferred')
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
rp.at('A').straight(5000)

View file

@ -16,7 +16,7 @@ from masque.error import BuildError, PortError
def trace_into_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False)
p = Pather(lib, tools=tool, render='immediate', render_append=False)
return p, tool, lib
@ -71,7 +71,7 @@ def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> N
def test_pather_trace_into_shapes() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
@ -175,7 +175,7 @@ def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None:
pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire')
return tree
p = Pather(Library(), tools=TransitionTool(), auto_render=False)
p = Pather(Library(), tools=TransitionTool(), render='deferred')
p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire')
p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire')
@ -188,7 +188,7 @@ def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None:
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
p = Pather(lib, tools=tool, auto_render=False)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire')
p.set_dead()