From 1b84c87d9bda9681b76c38e94be169b5e43ef229 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 27 Aug 2026 11:22:37 -0700 Subject: [PATCH] [RouteError] include callsite traceback in route errors --- masque/builder/error.py | 7 ++++-- masque/test/test_build_library.py | 32 +++++++++++++++++++++++++- masque/test/test_builder.py | 4 ++++ masque/test/test_pather_constraints.py | 28 ++++++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/masque/builder/error.py b/masque/builder/error.py index 927c03c..0e4a749 100644 --- a/masque/builder/error.py +++ b/masque/builder/error.py @@ -6,7 +6,7 @@ from enum import Enum, auto from pprint import pformat from types import MappingProxyType -from ..error import BuildError +from ..error import BuildError, format_stacktrace RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] @@ -68,7 +68,7 @@ class RouteFailureDetails: class RouteError(BuildError): - """A route-selection failure with structured request diagnostics.""" + """A route-selection failure with structured request and call-site diagnostics.""" details: RouteFailureDetails policy: RouteFailurePolicy @@ -103,4 +103,7 @@ 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)) super().__init__('\n'.join(lines)) diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index e020912..c9afc3b 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -1,8 +1,9 @@ from collections.abc import Iterator +from types import FunctionType import pytest -from ..builder import Pather +from ..builder import Pather, PathTool from ..error import BuildError, LibraryError from ..library import ( INameView, @@ -28,6 +29,17 @@ def _owned_by(report: BuildReport, owner: str) -> set[str]: } +def _external_failing_route_recipe(lib: ILibrary) -> Pattern: + pather = Pather( + lib, + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=PathTool(layer='M1', width=2, ptype='wire'), + render='deferred', + ) + pather.ccw('A', 10, out_ptype='optical') + return pather.pattern + + class _MetadataSource(ILibraryView): def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None: self.mapping = mapping @@ -263,6 +275,24 @@ def test_build_library_validate_is_retryable_after_failure() -> None: assert report.dependency_graph["parent"] == frozenset({"child"}) +def test_build_library_error_preserves_route_call_site() -> None: + builder = LibraryBuilder() + external_recipe = FunctionType( + _external_failing_route_recipe.__code__.replace(co_filename='/project/user_recipe.py'), + globals(), + ) + builder.cells.top = cell(external_recipe)(builder.library) + + with pytest.raises(BuildError) as exc_info: + builder.validate() + + 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 + + def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None: builder = LibraryBuilder() builder["child"] = Pattern() diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py index 4bf5c53..55e1b21 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -60,6 +60,10 @@ def test_route_failure_details_enforces_minimum_status_invariants() -> None: ) +def test_plain_build_error_does_not_include_stacktrace() -> None: + assert str(BuildError('plain builder failure')) == 'plain builder failure' + + def test_builder_init() -> None: lib = Library() b = Pather(lib, name="mypat") diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py index 2f75389..f2d353b 100644 --- a/masque/test/test_pather_constraints.py +++ b/masque/test/test_pather_constraints.py @@ -1,5 +1,6 @@ from collections.abc import Sequence from typing import Any, Literal, Never +from types import FunctionType import inspect import pytest @@ -34,6 +35,10 @@ class PlanningOnlyTool(Tool): return tree +def _external_failing_ccw(pather: Pather) -> None: + pather.ccw('A', 10, out_ptype='optical') + + class FirstPortOnlyTraceTool(PlanningOnlyTool): def __init__(self) -> None: self.render_calls = 0 @@ -270,6 +275,29 @@ def test_route_error_reports_no_route_at_any_length() -> None: assert 'no legal route exists at any length' in str(exc_info.value) +def test_route_error_reports_external_pather_call_site() -> 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_ccw.__code__.replace(co_filename='/project/user_layout.py'), + globals(), + ) + + with pytest.raises(RouteError) as exc_info: + 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 + + def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None: planner = RoutingPlanner() context = RoutePortContext(