Skip to content

Commit 5a261f2

Browse files
BloggerBustjayanth-kumar-morem
authored andcommitted
feat(runtime): attach callsite metadata to atoms for precise error reporting
This change enhances the runtime error handling by propagating `callsite_metadata` into all reactive atoms. The metadata includes the filename, line number, and source line of the user defined code that produced the atom. If this information is not statically available, it is inferred from the traceback during execution. - Refactored `_build_atom_function` to require a `callsite_node` and attach `callsite_metadata` to the decorator - Updated all `_finalize_and_register_atom` callsites to pass the originating AST node - Added `get_user_code_callsite()` utility to detect user source location at runtime - Enhanced `Atom.run()` to register structured errors with origin metadata - Replaced global error list with a thread safe singleton `ErrorRegistry` with deduplication - Updated frontend error report to display error counts (e.g. `(x3)`) - Improved handling of script paths by consistently resolving them to absolute paths
1 parent 25082e2 commit 5a261f2

File tree

7 files changed

+266
-145
lines changed

7 files changed

+266
-145
lines changed

frontend/src/components/ErrorsReport.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const ErrorsReport = ({ errors }) => {
3636
{errors.map((err, idx) => (
3737
<li key={idx}>
3838
<strong>{err.filename}:{err.lineno}</strong> - {err.message}
39+
{err.count > 1 ? ` (x${err.count})` : null}
3940
</li>
4041
))}
4142
</ul>

preswald/engine/base_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def initialize(cls, script_path=None):
8686
if cls._instance is None:
8787
cls._instance = cls()
8888
if script_path:
89-
cls._instance._script_path = script_path
89+
cls._instance._script_path = os.path.abspath(script_path)
9090
cls._instance._initialize_data_manager(script_path)
9191
return cls._instance
9292

@@ -100,7 +100,7 @@ def script_path(self, path: str):
100100
if not os.path.exists(path):
101101
raise FileNotFoundError(f"Script not found: {path}")
102102

103-
self._script_path = path
103+
self._script_path = os.path.abspath(path)
104104
self._initialize_data_manager(path)
105105

106106
@property

preswald/engine/runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ def compile_and_run(src_code, script_path, script_globals, execution_context):
406406
logger.warning(f"[ScriptRunner] No producer atom found {component_id=}")
407407
continue
408408

409-
errors = self._service.get_errors(type="ast_transform", filename=self.script_path)
409+
errors = self._service.get_errors(filename=self.script_path)
410410
message_type = "errors:result" if len(errors) else "components"
411411

412412
if (components and row_count) or len(errors):

preswald/engine/transformers/reactive_runtime.py

Lines changed: 92 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -472,9 +472,10 @@ def _finalize_and_register_atom(
472472
callsite_deps: list[str],
473473
call_expr: ast.AST | list[ast.stmt],
474474
*,
475-
return_target: str | list[str] | tuple[str, ...] | ast.expr | None = None
475+
return_target: str | list[str] | tuple[str, ...] | ast.expr | None = None,
476+
callsite_node: ast.AST | None = None,
476477
) -> ast.FunctionDef:
477-
func = self._build_atom_function(atom_name, component_id, callsite_deps, call_expr, return_target=return_target)
478+
func = self._build_atom_function(atom_name, component_id, callsite_deps, call_expr, return_target=return_target, callsite_node=callsite_node)
478479
self._finalize_atom_deps(func)
479480
self._current_frame.generated_atoms.append(func)
480481
return func
@@ -647,7 +648,8 @@ def visit_Name(self, node): # noqa: N802
647648
component_id,
648649
callsite_deps,
649650
assign_stmt,
650-
return_target=ast.Name(id=target.id, ctx=ast.Load())
651+
return_target=ast.Name(id=target.id, ctx=ast.Load()),
652+
callsite_node=stmt
651653
)
652654

653655
def _lift_output_stream_stmt(self, stmt: ast.Expr, component_id: str, atom_name: str, stream: str) -> None:
@@ -690,7 +692,7 @@ def _lift_output_stream_stmt(self, stmt: ast.Expr, component_id: str, atom_name:
690692

691693
try:
692694
call_and_return = ast.parse(source).body
693-
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, call_and_return)
695+
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, call_and_return, callsite_node=stmt)
694696
except SyntaxError as e:
695697
self._safe_register_error(
696698
lineno=self._get_stable_lineno(stmt, "output stream code generation"),
@@ -743,7 +745,7 @@ def _lift_return_renderable_call(
743745
]
744746
)
745747

746-
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, wrapped_call)
748+
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, wrapped_call, callsite_node=stmt)
747749

748750
def _lift_side_effect_stmt(self, stmt: ast.Expr) -> None:
749751
"""
@@ -821,7 +823,7 @@ def visit_Name(self, node: ast.Name): # noqa: N802
821823

822824
component_id, atom_name = self.generate_component_and_atom_name("sideeffect", stmt)
823825
atom_body = [ast.Expr(value=patched_call)]
824-
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, atom_body)
826+
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, atom_body, callsite_node=stmt)
825827
logger.debug('[AST] lifted side effect statement into atom %s -> %s', component_id, atom_name)
826828

827829
except Exception as e:
@@ -838,7 +840,6 @@ def _lift_blackbox_function_call(
838840
scoped_map: dict[str, str],
839841
variable_map: dict[str, str],
840842
) -> None:
841-
logger.debug('[DEBUG] enter _lift_blackbox_function_call')
842843

843844
component_id, atom_name = self.generate_component_and_atom_name(func_name, stmt)
844845

@@ -934,7 +935,8 @@ def _lift_blackbox_function_call(
934935
component_id,
935936
callsite_deps,
936937
body,
937-
return_target=return_target
938+
return_target=return_target,
939+
callsite_node=stmt
938940
)
939941

940942
def _lift_producer_stmt(self, stmt: ast.Assign, pending_assignments: list[ast.Assign], variable_map: dict[str, str]) -> None:
@@ -973,7 +975,7 @@ def _lift_producer_stmt(self, stmt: ast.Assign, pending_assignments: list[ast.As
973975

974976
return_target: str | list[str] | None = None
975977
if isinstance(stmt.targets[0], ast.Tuple | ast.List):
976-
component_id, atom_name = self.generate_component_and_atom_name("producer")
978+
component_id, atom_name = self.generate_component_and_atom_name("producer", stmt)
977979
self._current_frame.tuple_returning_atoms.add(atom_name)
978980

979981
unpacked_vars = [
@@ -1095,7 +1097,8 @@ def _lift_producer_stmt(self, stmt: ast.Assign, pending_assignments: list[ast.As
10951097
component_id,
10961098
deps,
10971099
[patched_stmt],
1098-
return_target=return_target
1100+
return_target=return_target,
1101+
callsite_node=stmt
10991102
)
11001103
logger.info("[AST] lifted subscript assignment into atom %s -> %s", component_id, new_atom_name)
11011104
return
@@ -1135,7 +1138,7 @@ def _lift_producer_stmt(self, stmt: ast.Assign, pending_assignments: list[ast.As
11351138
self._register_variable_bindings(stmt, atom_name)
11361139
logger.debug(f"[AST] Lifted producer: {atom_name=} {callsite_deps=}")
11371140

1138-
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, patched_expr, return_target=return_target)
1141+
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, patched_expr, return_target=return_target, callsite_node=stmt)
11391142
self._current_frame.variable_to_atom.update(variable_map)
11401143

11411144
def _lift_consumer_stmt(self, stmt: ast.Expr, *, component_id: str | None = None, atom_name: str | None = None) -> ast.Expr:
@@ -1215,7 +1218,7 @@ def visit_Name(self, node: ast.Name): # noqa: N802
12151218
patched_expr = TupleAwareReplacer().visit(copy.deepcopy(expr))
12161219
ast.fix_missing_locations(patched_expr)
12171220

1218-
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, patched_expr)
1221+
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, patched_expr, callsite_node=stmt)
12191222

12201223
# Return the rewritten expression as a call to the generated atom
12211224
callsite = self._make_callsite(atom_name, callsite_deps)
@@ -1330,7 +1333,7 @@ def _try_lift_display_renderer(
13301333
],
13311334
)
13321335

1333-
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, renderer_call)
1336+
self._finalize_and_register_atom(atom_name, component_id, callsite_deps, renderer_call, callsite_node=stmt)
13341337

13351338
#logger.debug(f"[DEBUG] Replacing .show call with call to: {renderer_fn.__name__}({object_arg=}, {component_id=})")
13361339

@@ -1677,7 +1680,7 @@ def resolve_attribute(node: ast.AST) -> str | None:
16771680
if logger.isEnabledFor(logging.DEBUG):
16781681
logger.debug(f"[AST] Unable to resolve function name for call: {ast.dump(call)}")
16791682
else:
1680-
logger.warning(f"[AST] Unable to resolve function name for call. Enable debug logging for ast dump.")
1683+
logger.warning("[AST] Unable to resolve function name for call. Enable debug logging for ast dump.")
16811684
return "<unknown>"
16821685

16831686
def _has_runtime_execution(self, body: list[ast.stmt]) -> bool:
@@ -2163,7 +2166,7 @@ def visit_JoinedStr(self, node: ast.JoinedStr) -> ast.JoinedStr: # noqa: N802
21632166
self._current_frame.tuple_variable_index,
21642167
).visit(call)
21652168

2166-
def visit_Assign(self, node: ast.Assign) -> ast.AST:
2169+
def visit_Assign(self, node: ast.Assign) -> ast.AST: # noqa: N802
21672170
# Only support simple single target assignments for now
21682171
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
21692172
varname = node.targets[0].id
@@ -2265,9 +2268,10 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef: # noqa: N
22652268
return node
22662269

22672270
# Attach atom decorator
2268-
callsite_hint = f"{self.filename}:{getattr(node, 'lineno', 0)}"
2269-
atom_name = generate_stable_id("_auto_atom", callsite_hint=callsite_hint)
2270-
decorator = self._create_workflow_atom_decorator(atom_name, callsite_deps=[])
2271+
callsite_metadata = self._build_callsite_metadata(node, self.filename)
2272+
atom_name = generate_stable_id("_auto_atom", callsite_hint=callsite_metadata["callsite_hint"])
2273+
decorator = self._create_workflow_atom_decorator(atom_name, callsite_deps=[], callsite_metadata=callsite_metadata)
2274+
22712275
node.decorator_list.insert(0, decorator)
22722276
node.generated_atom_name = atom_name
22732277
self.atoms.append(atom_name)
@@ -2441,47 +2445,51 @@ def _build_atom_function(
24412445
callsite_deps: list[str],
24422446
call_expr: ast.AST | list[ast.stmt],
24432447
*,
2444-
return_target: str | list[str] | tuple[str, ...] | ast.expr | None = None
2448+
return_target: str | list[str] | tuple[str, ...] | ast.expr | None = None,
2449+
callsite_node: ast.AST | None = None,
24452450
) -> ast.FunctionDef:
24462451
"""
24472452
Constructs a reactive atom function from a lifted expression or component call.
24482453
24492454
The generated function will:
24502455
- Accept `param0`, `param1`, ... as arguments for each reactive dependency
2451-
- Wrap the user expression(s) in a function body
2452-
- Return the computed value (or specified `return_target`)
2453-
- Be decorated with `@workflow.atom(name=..., dependencies=[...])`
2456+
- Wrap the normalized expression(s) in a function body
2457+
- Return the computed value, using `return_target` if provided, or appending a default return otherwise
2458+
- Attach the @workflow.atom(...) decorator with name, dependencies, and callsite metadata
24542459
24552460
Supports:
24562461
- Named assignments: `x = ...`
24572462
- Subscript assignments: `param0["col"] = ...`
24582463
- Blocks of statements: `list[ast.stmt]`
2459-
- Raw expressions: a value to directly `return`
2464+
- Single expressions wrapped as `ast.Expr`
24602465
- Explicit return override via `return_target`
2466+
2467+
Any other AST node types passed as `call_expr` will result in a safe transformation error.
24612468
"""
24622469

24632470
# Create function parameters: (param0, param1, ...)
24642471
args_ast = self._make_param_args(callsite_deps)
24652472

2466-
# Build decorator
2467-
decorator = self._create_workflow_atom_decorator(atom_name, callsite_deps)
2468-
2469-
# Build function body
2473+
# Normalize call_expr into a body list
24702474
if isinstance(call_expr, list):
24712475
body = call_expr
2472-
elif isinstance(call_expr, ast.Assign):
2473-
body = [call_expr]
2474-
elif isinstance(call_expr, ast.Expr):
2476+
elif isinstance(call_expr, ast.Assign) or isinstance(call_expr, ast.Expr):
24752477
body = [call_expr]
24762478
else:
2477-
# e.g. raw expression to return directly
2478-
return_stmt = ast.Return(value=call_expr)
2479-
return ast.FunctionDef(
2480-
name=atom_name,
2481-
args=args_ast,
2482-
body=[return_stmt],
2483-
decorator_list=[decorator],
2479+
self._safe_register_error(
2480+
node=call_expr,
2481+
message=f"Unexpected AST node type in _build_atom_function: {type(call_expr).__name__}",
2482+
component_id=component_id,
2483+
atom_name=atom_name,
24842484
)
2485+
return None
2486+
2487+
callsite_metadata = self._build_callsite_metadata(callsite_node, self.filename)
2488+
decorator = self._create_workflow_atom_decorator(
2489+
atom_name,
2490+
callsite_deps,
2491+
callsite_metadata=callsite_metadata
2492+
)
24852493

24862494
# Append appropriate return statement
24872495
if isinstance(return_target, str):
@@ -2512,16 +2520,24 @@ def _build_atom_function(
25122520
decorator_list=[decorator],
25132521
)
25142522

2515-
def _create_workflow_atom_decorator(self, atom_name: str, callsite_deps: list[str]) -> ast.Call:
2523+
def _create_workflow_atom_decorator(self, atom_name: str, callsite_deps: list[str], callsite_metadata: dict | None = None) -> ast.Call:
25162524
"""
25172525
Constructs a decorator expression for @workflow.atom(...).
25182526
2527+
Includes metadata such as the atom's name, its dependency list, and optionally,
2528+
source level callsite information (filename, line number, and source line).
2529+
2530+
The `callsite_metadata` is propagated into the atom definition to enable more
2531+
precise runtime error handling, allowing the workflow engine to report
2532+
meaningful context when atom execution fails.
2533+
25192534
Args:
25202535
atom_name: The name to assign to the reactive atom.
25212536
callsite_deps: A list of atom names this atom depends on.
2537+
callsite_metadata: Optional dictionary with source information, such as filename, lineno, source
25222538
25232539
Returns:
2524-
An `ast.Call` node representing the decorator.
2540+
An `ast.Call` node representing the fully parameterized decorator.
25252541
"""
25262542

25272543
keywords = [ast.keyword(arg="name", value=ast.Constant(value=atom_name))]
@@ -2538,6 +2554,17 @@ def _create_workflow_atom_decorator(self, atom_name: str, callsite_deps: list[st
25382554
)
25392555
)
25402556

2557+
if callsite_metadata is not None:
2558+
keywords.append(
2559+
ast.keyword(
2560+
arg="callsite_metadata",
2561+
value=ast.Dict(
2562+
keys=[ast.Constant(value=k) for k in callsite_metadata],
2563+
values=[ast.Constant(value=v) for v in callsite_metadata.values()],
2564+
),
2565+
)
2566+
)
2567+
25412568
return ast.Call(
25422569
func=ast.Attribute(value=ast.Name(id="workflow", ctx=ast.Load()), attr="atom", ctx=ast.Load()),
25432570
args=[],
@@ -2583,7 +2610,7 @@ def lift_component_call_to_atom(self, node: ast.Call, component_id: str, atom_na
25832610
patched_call = self._patch_callsite(node, callsite_deps, component_id)
25842611

25852612
# Generate the atom function that wraps the patched call
2586-
new_func = self._build_atom_function(atom_name, component_id, callsite_deps, patched_call, return_target=return_target)
2613+
new_func = self._build_atom_function(atom_name, component_id, callsite_deps, patched_call, return_target=return_target, callsite_node=node)
25872614

25882615
self._current_frame.generated_atoms.append(new_func)
25892616

@@ -2657,6 +2684,31 @@ def generate_component_and_atom_name(self, func_name: str, stmt: ast.stmt | None
26572684
logger.debug(f"[AST] Generated names {func_name=} {callsite_hint=} {component_id=} {atom_name=}")
26582685
return component_id, atom_name
26592686

2687+
def _build_callsite_metadata(self, node: ast.AST, filename: str) -> dict:
2688+
"""
2689+
Constructs callsite metadata (filename, lineno, source) for a given AST node.
2690+
2691+
Returns:
2692+
A dict with keys: callsite_filename, callsite_lineno, callsite_source
2693+
"""
2694+
lineno = getattr(node, "lineno", None)
2695+
source = ""
2696+
2697+
if filename and lineno:
2698+
try:
2699+
with open(filename, "r") as f:
2700+
lines = f.readlines()
2701+
source = lines[lineno - 1].strip() if 0 < lineno <= len(lines) else ""
2702+
except Exception:
2703+
pass
2704+
2705+
return {
2706+
"callsite_filename": filename,
2707+
"callsite_lineno": lineno,
2708+
"callsite_source": source,
2709+
"callsite_hint": f"{filename}:{lineno}" if filename and lineno else None,
2710+
}
2711+
26602712

26612713
def annotate_parents(tree: ast.AST) -> ast.AST:
26622714
"""

0 commit comments

Comments
 (0)