diff --git a/masque/builder/error.py b/masque/builder/error.py index 0e4a749..eb2b205 100644 --- a/masque/builder/error.py +++ b/masque/builder/error.py @@ -5,8 +5,9 @@ from dataclasses import dataclass from enum import Enum, auto from pprint import pformat from types import MappingProxyType +import traceback -from ..error import BuildError, format_stacktrace +from ..error import BuildError RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] @@ -68,10 +69,11 @@ class RouteFailureDetails: 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 policy: RouteFailurePolicy + _call_stack: tuple[traceback.FrameSummary, ...] def __init__( self, @@ -103,7 +105,5 @@ class RouteError(BuildError): ] if details.minimum_cause is not None: lines.append(f' minimum_failure: {details.minimum_cause}') - stacktrace = format_stacktrace().rstrip() - if stacktrace: - lines.extend(('', 'Stack trace:', stacktrace)) + self._call_stack = tuple(traceback.extract_stack()[:-1]) super().__init__('\n'.join(lines)) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index f689cd6..c9bd1d4 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -79,7 +79,7 @@ from .planner.interface import ( RoutePortContext, route_failure_policy, ) -from .error import RouteFailurePolicy, ToolContractError +from .error import RouteError, RouteFailurePolicy, ToolContractError from .planner import RoutingPlanner from .planner.bounds import resolved_position_bound from .logging import PatherLogger @@ -699,6 +699,8 @@ class Pather(PortList): tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL: raise if length is not None and len(contexts) == 1: @@ -805,6 +807,8 @@ class Pather(PortList): tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if ( not self._dead or len(contexts) != 1 @@ -1021,6 +1025,8 @@ class Pather(PortList): tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if ( not self._dead or len(contexts) != 1 @@ -1098,6 +1104,8 @@ class Pather(PortList): tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if ( not self._dead or len(contexts) != 1 @@ -1170,16 +1178,20 @@ class Pather(PortList): plan_options=plan_opts, tool_options=tool_opts, ): - result = self.planner.plan_trace_into( - self._route_context(portspec_src), - portspec_dst, - self.pattern[portspec_dst].copy(), - out_ptype = out_ptype, - plug_destination = plug_destination, - thru = thru, - plan_options = plan_opts, - tool_options = tool_opts, - ) + try: + result = self.planner.plan_trace_into( + self._route_context(portspec_src), + portspec_dst, + self.pattern[portspec_dst].copy(), + out_ptype = out_ptype, + plug_destination = plug_destination, + thru = thru, + plan_options = plan_opts, + tool_options = tool_opts, + ) + except RouteError as err: + err.__traceback__ = None + raise self._apply_route_result(result) return self diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index c9afc3b..27393af 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -1,9 +1,10 @@ from collections.abc import Iterator from types import FunctionType +import traceback import pytest -from ..builder import Pather, PathTool +from ..builder import Pather, PathTool, RouteError from ..error import BuildError, LibraryError from ..library import ( INameView, @@ -288,9 +289,19 @@ def test_build_library_error_preserves_route_call_site() -> None: message = str(exc_info.value) assert 'Cause: Unable to plan trace_to route' in message - assert 'Stack trace:' in message - assert 'File "/project/user_recipe.py"' in message - assert 'in _external_failing_route_recipe' in message + assert 'Stack trace:' not 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: diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py index f2d353b..58ee5f6 100644 --- a/masque/test/test_pather_constraints.py +++ b/masque/test/test_pather_constraints.py @@ -2,6 +2,7 @@ from collections.abc import Sequence from typing import Any, Literal, Never from types import FunctionType import inspect +import traceback import pytest import numpy @@ -39,6 +40,14 @@ def _external_failing_ccw(pather: Pather) -> None: 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): def __init__(self) -> None: self.render_calls = 0 @@ -291,11 +300,72 @@ def test_route_error_reports_external_pather_call_site() -> None: external_call(p) message = str(exc_info.value) - assert 'Stack trace:' in message - assert 'File "/project/user_layout.py"' in message - assert 'in _external_failing_ccw' in message - assert '/masque/builder/pather.py' not in message - assert '/masque/builder/planner/' not in message + assert 'Stack trace:' not in message + + route_error = exc_info.value + native_frames = traceback.extract_tb(route_error.__traceback__) + 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: