[pather / RouteError] improve error reporting

This commit is contained in:
Jan Petykiewicz 2026-08-27 13:02:29 -07:00
commit 4d9aaf2fd9
4 changed files with 118 additions and 25 deletions

View file

@ -5,8 +5,9 @@ from dataclasses import dataclass
from enum import Enum, auto from enum import Enum, auto
from pprint import pformat from pprint import pformat
from types import MappingProxyType from types import MappingProxyType
import traceback
from ..error import BuildError, format_stacktrace from ..error import BuildError
RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn']
@ -68,10 +69,11 @@ class RouteFailureDetails:
class RouteError(BuildError): class RouteError(BuildError):
"""A route-selection failure with structured request and call-site diagnostics.""" """A route-selection failure with structured request and saved call-stack diagnostics."""
details: RouteFailureDetails details: RouteFailureDetails
policy: RouteFailurePolicy policy: RouteFailurePolicy
_call_stack: tuple[traceback.FrameSummary, ...]
def __init__( def __init__(
self, self,
@ -103,7 +105,5 @@ class RouteError(BuildError):
] ]
if details.minimum_cause is not None: if details.minimum_cause is not None:
lines.append(f' minimum_failure: {details.minimum_cause}') lines.append(f' minimum_failure: {details.minimum_cause}')
stacktrace = format_stacktrace().rstrip() self._call_stack = tuple(traceback.extract_stack()[:-1])
if stacktrace:
lines.extend(('', 'Stack trace:', stacktrace))
super().__init__('\n'.join(lines)) super().__init__('\n'.join(lines))

View file

@ -79,7 +79,7 @@ from .planner.interface import (
RoutePortContext, RoutePortContext,
route_failure_policy, route_failure_policy,
) )
from .error import RouteFailurePolicy, ToolContractError from .error import RouteError, RouteFailurePolicy, ToolContractError
from .planner import RoutingPlanner from .planner import RoutingPlanner
from .planner.bounds import resolved_position_bound from .planner.bounds import resolved_position_bound
from .logging import PatherLogger from .logging import PatherLogger
@ -699,6 +699,8 @@ class Pather(PortList):
tool_options=tool_opts, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL: if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL:
raise raise
if length is not None and len(contexts) == 1: if length is not None and len(contexts) == 1:
@ -805,6 +807,8 @@ class Pather(PortList):
tool_options=tool_opts, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if ( if (
not self._dead not self._dead
or len(contexts) != 1 or len(contexts) != 1
@ -1021,6 +1025,8 @@ class Pather(PortList):
tool_options=tool_opts, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if ( if (
not self._dead not self._dead
or len(contexts) != 1 or len(contexts) != 1
@ -1098,6 +1104,8 @@ class Pather(PortList):
tool_options=tool_opts, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if ( if (
not self._dead not self._dead
or len(contexts) != 1 or len(contexts) != 1
@ -1170,6 +1178,7 @@ class Pather(PortList):
plan_options=plan_opts, plan_options=plan_opts,
tool_options=tool_opts, tool_options=tool_opts,
): ):
try:
result = self.planner.plan_trace_into( result = self.planner.plan_trace_into(
self._route_context(portspec_src), self._route_context(portspec_src),
portspec_dst, portspec_dst,
@ -1180,6 +1189,9 @@ class Pather(PortList):
plan_options = plan_opts, plan_options = plan_opts,
tool_options = tool_opts, tool_options = tool_opts,
) )
except RouteError as err:
err.__traceback__ = None
raise
self._apply_route_result(result) self._apply_route_result(result)
return self return self

View file

@ -1,9 +1,10 @@
from collections.abc import Iterator from collections.abc import Iterator
from types import FunctionType from types import FunctionType
import traceback
import pytest import pytest
from ..builder import Pather, PathTool from ..builder import Pather, PathTool, RouteError
from ..error import BuildError, LibraryError from ..error import BuildError, LibraryError
from ..library import ( from ..library import (
INameView, INameView,
@ -288,9 +289,19 @@ def test_build_library_error_preserves_route_call_site() -> None:
message = str(exc_info.value) message = str(exc_info.value)
assert 'Cause: Unable to plan trace_to route' in message assert 'Cause: Unable to plan trace_to route' in message
assert 'Stack trace:' in message assert 'Stack trace:' not in message
assert 'File "/project/user_recipe.py"' in message
assert 'in _external_failing_route_recipe' in message route_error = exc_info.value.__cause__
assert isinstance(route_error, RouteError)
assert route_error.__traceback__ is not None
route_frames = traceback.extract_tb(route_error.__traceback__)
assert sum(frame.filename == '/project/user_recipe.py' for frame in route_frames) == 1
assert not any('/builder/planner/' in frame.filename for frame in route_frames)
assert route_error.__cause__ is not None
assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack)
native = ''.join(traceback.format_exception(exc_info.value))
assert native.count('/project/user_recipe.py') == 1
def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None: def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None:

View file

@ -2,6 +2,7 @@ from collections.abc import Sequence
from typing import Any, Literal, Never from typing import Any, Literal, Never
from types import FunctionType from types import FunctionType
import inspect import inspect
import traceback
import pytest import pytest
import numpy import numpy
@ -39,6 +40,14 @@ def _external_failing_ccw(pather: Pather) -> None:
pather.ccw('A', 10, out_ptype='optical') pather.ccw('A', 10, out_ptype='optical')
def _external_failing_trace_to(pather: Pather) -> None:
pather.trace_to('A', True, length=10, out_ptype='optical')
def _external_failing_portpather_ccw(pather: Pather) -> None:
pather.at('A').ccw(10, out_ptype='optical')
class FirstPortOnlyTraceTool(PlanningOnlyTool): class FirstPortOnlyTraceTool(PlanningOnlyTool):
def __init__(self) -> None: def __init__(self) -> None:
self.render_calls = 0 self.render_calls = 0
@ -291,11 +300,72 @@ def test_route_error_reports_external_pather_call_site() -> None:
external_call(p) external_call(p)
message = str(exc_info.value) message = str(exc_info.value)
assert 'Stack trace:' in message assert 'Stack trace:' not in message
assert 'File "/project/user_layout.py"' in message
assert 'in _external_failing_ccw' in message route_error = exc_info.value
assert '/masque/builder/pather.py' not in message native_frames = traceback.extract_tb(route_error.__traceback__)
assert '/masque/builder/planner/' not in message assert native_frames
assert [frame.name for frame in native_frames if frame.filename.endswith('/builder/pather.py')] == [
'ccw',
'bend',
]
assert not any('/builder/planner/' in frame.filename for frame in native_frames)
native = ''.join(traceback.format_exception(route_error))
assert native.count('/project/user_layout.py') == 1
assert '\nStack trace:\n' not in native
assert route_error.__cause__ is not None
assert isinstance(route_error._call_stack, tuple)
assert sum(frame.filename == '/project/user_layout.py' for frame in route_error._call_stack) == 1
assert any(frame.filename.endswith('/builder/pather.py') for frame in route_error._call_stack)
assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack)
def test_route_error_direct_trace_to_starts_native_traceback_at_caller() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_trace_to.__code__.replace(co_filename='/project/direct_route.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
frames = traceback.extract_tb(exc_info.value.__traceback__)
assert sum(frame.filename == '/project/direct_route.py' for frame in frames) == 1
assert not any(frame.filename.endswith('/builder/pather.py') for frame in frames)
assert not any('/builder/planner/' in frame.filename for frame in frames)
def test_route_error_portpather_keeps_only_forwarding_frames() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_portpather_ccw.__code__.replace(co_filename='/project/selected_route.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
frames = traceback.extract_tb(exc_info.value.__traceback__)
assert sum(frame.filename == '/project/selected_route.py' for frame in frames) == 1
assert [frame.name for frame in frames if frame.filename.endswith('/builder/pather.py')] == [
'ccw',
'bend',
'trace_to',
]
assert not any('/builder/planner/' in frame.filename for frame in frames)
def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None: def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None: