[Pather / AutoTool] rework route kwargs into tool_options, and actualyl pass them through AutoTool

This commit is contained in:
Jan Petykiewicz 2026-07-12 20:32:59 -07:00
commit 67afee7704
11 changed files with 865 additions and 130 deletions

View file

@ -800,7 +800,7 @@ def test_autotool_s_offer_bbox_matches_rendered_sbend() -> None:
.add_sbend(make_wide_sbend, "core", "A", "B", jog_range=(0, 1e8))
.add_transition(lib.abstract("xin"), "EXT", "CORE")
)
offer, _out_port, _data = selected_offer(tool, "s", 4, length=15, in_ptype="core")
offer, _out_port, _data = selected_offer(tool, "s", 4, in_ptype="core")
assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib)
@ -1006,7 +1006,7 @@ def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, L
assert bend_step.data.abstract.name == "b1"
def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None:
def test_autotool_generated_primitives_snapshot_route_options() -> None:
markers: list[str | None] = []
def make_marked_straight(length: float, marker: str | None = None) -> Pattern:
@ -1017,10 +1017,90 @@ def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None:
p = Pather(Library(), tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire")
p.straight("A", 5, marker="route")
first_options = {'marker': 'first'}
p.straight("A", 5, tool_options=first_options)
first_options['marker'] = 'mutated'
p.straight("A", 6, tool_options={'marker': 'second'})
first_data, second_data = (step.data for step in p._paths['A'])
assert isinstance(first_data, AutoTool.GeneratedData)
assert isinstance(second_data, AutoTool.GeneratedData)
assert dict(first_data.tool_options) == {'marker': 'first'}
assert dict(second_data.tool_options) == {'marker': 'second'}
p.render()
assert markers == [None]
assert markers == ['first', 'second']
def test_autotool_route_options_do_not_attach_to_reusable_bends(
multi_bend_tool: tuple[AutoTool, Library],
) -> None:
tool, _lib = multi_bend_tool
offers = tool.primitive_offers('bend', in_ptype='wire', ccw=True, marker='ignored')
assert offers
assert all(isinstance(offer.commit(offer.parameter_domain[0]), AutoTool.ReusableData) for offer in offers)
def test_autotool_sbend_generator_receives_route_options() -> None:
markers: list[str] = []
def make_marked_sbend(jog: float, *, marker: str) -> Pattern:
markers.append(marker)
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0, ptype='wire')
pat.ports['B'] = Port((3, jog), pi, ptype='wire')
return pat
tool = AutoTool().add_sbend(
make_marked_sbend,
'wire',
'A',
'B',
endpoint=lambda jog: Port((3, jog), pi, ptype='wire'),
)
p = Pather(Library(), tools=tool, render='deferred')
p.ports['A'] = Port((0, 0), 0, ptype='wire')
p.jog('A', 4, length=3, tool_options={'marker': 'sbend'})
p.render()
assert markers == ['sbend']
def test_autotool_generated_bbox_uses_route_options() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A')
offer = tool.primitive_offers('straight', width=6)[0]
assert_allclose(offer.bbox_at(10), [[0, -3], [10, 3]])
def test_autotool_unsupported_generator_option_fails_during_render() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A')
p = Pather(Library(), tools=tool, render='deferred')
p.ports['A'] = Port((0, 0), 0, ptype='wire')
p.straight('A', 5, tool_options={'unknown_generator_option': True})
with pytest.raises(TypeError, match='unknown_generator_option'):
p.render()
def test_autotool_generator_options_must_preserve_planned_endpoint() -> None:
def shifted_straight(length: float, endpoint_shift: float = 0) -> Pattern:
pat = make_straight(length)
pat.ports['B'].x += endpoint_shift
return pat
tool = AutoTool().add_straight(shifted_straight, 'wire', 'A')
p = Pather(Library(), tools=tool, render='deferred')
p.ports['A'] = Port((0, 0), 0, ptype='wire')
p.straight('A', 5, tool_options={'endpoint_shift': 1})
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render()
@pytest.mark.parametrize("ccw", [False, True])
@ -1204,7 +1284,7 @@ def test_pather_autotool_pure_sbend_with_transition_dx() -> None:
assert_allclose(trans_port.offset, [5, 0])
assert isinstance(trans_data, AutoTool.ReusableData)
s_offer, s_port, s_data = selected_offer(tool, "s", 4, length=15, in_ptype="core")
s_offer, s_port, s_data = selected_offer(tool, "s", 4, in_ptype="core")
assert_allclose(s_port.offset, [10, 4])
assert isinstance(s_data, AutoTool.GeneratedData)
assert s_offer.out_ptype == "core"

View file

@ -1,11 +1,12 @@
from collections.abc import Sequence
from typing import Any, Literal, Never
import inspect
import pytest
import numpy
from numpy import pi
from masque import MinimumStatus, Pather, Library, Port, RouteError, RouteFailurePolicy
from masque import MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy
from masque.builder.planner import RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import NoLegalRouteError
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer
@ -199,7 +200,7 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None:
)
with pytest.raises(RouteError) as exc_info:
p.ccw('A', 1, out_ptype='wide', diagnostic_context='tool-value')
p.ccw('A', 1, out_ptype='wide', tool_options={'diagnostic_context': 'tool-value'})
details = exc_info.value.details
assert isinstance(exc_info.value, BuildError)
@ -211,7 +212,7 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None:
'ccw': True,
'length': 1,
'out_ptype': 'wide',
'diagnostic_context': 'tool-value',
'tool_options': {'diagnostic_context': 'tool-value'},
}
assert details.resolved_length == 1
assert details.resolved_jog is None
@ -705,7 +706,7 @@ def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.uturn('A', 4, **kwargs)
def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None:
@ -935,3 +936,74 @@ def test_pather_uturn_failed_two_bend_route_is_atomic() -> None:
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p._paths['A']) == 0
@pytest.mark.parametrize('route_type', [Pather, PortPather])
@pytest.mark.parametrize('name', ['trace', 'trace_to', 'straight', 'bend', 'ccw', 'cw', 'jog', 'uturn', 'trace_into'])
def test_routing_entry_points_have_no_catch_all_kwargs(route_type: type, name: str) -> None:
parameters = inspect.signature(getattr(route_type, name)).parameters.values()
assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters)
def test_routing_typo_fails_before_tool_lookup() -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.straight('A', lenght=10) # type: ignore[call-arg]
assert tool.offer_calls == 0
@pytest.mark.parametrize('tool_options', [{1: 'bad'}, {'ccw': False}, {'out_ptype': 'bad'}])
def test_tool_options_reject_invalid_or_reserved_keys(tool_options: dict[Any, Any]) -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(BuildError, match='tool_options'):
p.trace('A', None, tool_options=tool_options)
assert tool.offer_calls == 0
@pytest.mark.parametrize(
'operation',
[
lambda p: p.jog('A', numpy.nan, length=1),
lambda p: p.uturn('A', numpy.inf, length=1),
lambda p: p.straight('A', x=numpy.nan),
],
ids=['jog-offset', 'uturn-offset', 'position-bound'],
)
def test_nonfinite_route_geometry_fails_before_offer_query(operation: Any) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match='finite'):
operation(p)
assert tool.offer_calls == 0
@pytest.mark.parametrize('spacing', [-1, numpy.nan, numpy.inf])
def test_invalid_bundle_spacing_fails_before_offer_query(spacing: float) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={
'A': Port((0, 0), rotation=0, ptype='wire'),
'B': Port((0, 4), rotation=0, ptype='wire'),
},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match='spacing'):
p.trace(['A', 'B'], True, xmin=-10, spacing=spacing)
assert tool.offer_calls == 0

View file

@ -313,7 +313,7 @@ def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None:
def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('s', in_ptype='wire', length=6)[0]
offer = tool.primitive_offers('s', in_ptype='wire')[0]
bounds = offer.bbox_at(3)
expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single()
@ -333,7 +333,7 @@ def test_pathtool_u_offers_remain_unsupported() -> None:
[
('straight', {}),
('bend', {'ccw': True}),
('s', {'length': 6}),
('s', {}),
],
)
def test_pathtool_out_ptype_unk_is_wildcard(
@ -348,6 +348,12 @@ def test_pathtool_out_ptype_unk_is_wildcard(
assert offers[0].out_ptype == 'wire'
def test_pathtool_rejects_unsupported_planning_options() -> None:
tool = PathTool(layer='M1', width=1)
with pytest.raises(BuildError, match='does not support tool options: marker'):
tool.primitive_offers('straight', marker='sentinel')
def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None:
class NoOfferTool(PlanningOnlyTool):
def primitive_offers(
@ -437,13 +443,14 @@ class StrategyTieTool(PlanningOnlyTool):
) -> tuple[PrimitiveOffer, ...]:
self.seen_kwargs.append(dict(kwargs))
endpoint_ptype = out_ptype or in_ptype
marker = kwargs.get('marker')
if kind == 'straight':
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda length: (
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
{'kind': 'straight', 'length': length},
{'kind': 'straight', 'length': length, 'marker': marker},
)),
),)
if kind == 's':
@ -452,7 +459,7 @@ class StrategyTieTool(PlanningOnlyTool):
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((3, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog},
{'kind': 's', 'jog': jog, 'marker': marker},
)),
),)
return ()
@ -504,11 +511,12 @@ def test_pather_route_strategy_per_route_can_request_turn_first() -> None:
def test_pather_route_strategy_is_not_forwarded_to_tool() -> None:
pather, tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10, strategy='turn_first', marker='sentinel')
pather.jog('A', 4, length=10, strategy='turn_first', tool_options={'marker': 'sentinel'})
assert tool.seen_kwargs
assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs)
assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs)
assert all(step.data['marker'] == 'sentinel' for step in pather._paths['A'])
def test_pather_route_strategy_rejects_invalid_values() -> None:

View file

@ -252,7 +252,8 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = dst
with pytest.raises(BuildError, match=match):
_ = match
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.trace_into('A', 'B', plug_destination=False, **kwargs)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))