From a8210d1f117d9323f661e29d6a3160458894ec96 Mon Sep 17 00:00:00 2001 From: Shivanand Mishra Date: Sat, 6 Jun 2026 21:37:16 +0530 Subject: [PATCH 1/4] fix(transpiler): return ast.Assign directly from visit_FunctionDef --- src/bocpy/transpiler.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bocpy/transpiler.py b/src/bocpy/transpiler.py index 74862c3..af7faef 100644 --- a/src/bocpy/transpiler.py +++ b/src/bocpy/transpiler.py @@ -607,8 +607,13 @@ def visit_FunctionDef(self, node: ast.FunctionDef): # noqa: N802 when_call = ast.Call(func=ast.Name(id="whencall"), args=args, keywords=[]) ast.copy_location(when_call, node) - ast.fix_missing_locations(when_call) - return ast.Expr(ast.Assign([ast.Name(id=node.name)], when_call)) + assign = ast.Assign( + targets=[ast.Name(id=node.name, ctx=ast.Store())], + value=when_call, + ) + ast.copy_location(assign, node) + ast.fix_missing_locations(assign) + return assign visit_AsyncFunctionDef = visit_FunctionDef # noqa: N815 @@ -674,4 +679,4 @@ def export_module_from_file(path: str) -> ExportResult: def export_main() -> ExportResult: """Export the currently running __main__ module.""" - return export_module_from_file(sys.modules["__main__"].__file__) + return export_module_from_file(sys.modules["__main__"].__file__) \ No newline at end of file From 9cc832c6a3511d9e35312a32d5ef95f289a3addb Mon Sep 17 00:00:00 2001 From: Shivanand Mishra Date: Mon, 8 Jun 2026 19:11:29 +0530 Subject: [PATCH 2/4] test: add regression test for transpiler edge case --- src/bocpy/transpiler.py | 2 +- test/test_transpiler.py | 126 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/bocpy/transpiler.py b/src/bocpy/transpiler.py index af7faef..9d1ebb9 100644 --- a/src/bocpy/transpiler.py +++ b/src/bocpy/transpiler.py @@ -679,4 +679,4 @@ def export_module_from_file(path: str) -> ExportResult: def export_main() -> ExportResult: """Export the currently running __main__ module.""" - return export_module_from_file(sys.modules["__main__"].__file__) \ No newline at end of file + return export_module_from_file(sys.modules["__main__"].__file__) diff --git a/test/test_transpiler.py b/test/test_transpiler.py index e728c7d..75cf183 100644 --- a/test/test_transpiler.py +++ b/test/test_transpiler.py @@ -1100,3 +1100,129 @@ def b(c): names = [info.name for info in result.behaviors.values()] assert names == ["__behavior__0"] assert "from bocpy import whencall" in result.code + + +# Regression: @when result assignment must not be dropped + +class TestWhenResultAssignment: + """@when-decorated functions must produce a name = whencall(...) assignment + in the exported module. + + Regression: WhenTransformer.visit_FunctionDef was returning + ``ast.Expr(ast.Assign(...))``, an ast.Assign statement incorrectly + wrapped in ast.Expr. visit_Module filters out every ast.Expr node (to + drop bare expression-statement whencall results), so the wrapping caused + every @when result assignment to be silently dropped from the exported + module. Any code that read .value, checked .exception, or chained + behaviors on the result was operating on None with no error at schedule + time. + """ + + @staticmethod + def _export(source, path="/tmp/test.py"): + tree = ast.parse(textwrap.dedent(source)) + return export_module(tree, path) + + def test_result_assigned_in_exported_code(self): + """The behavior name must appear as an assignment target in the export.""" + result = self._export("""\ + from bocpy import when, whencall, Cown + + x = Cown(1) + + @when(x) + def my_task(x): + return x.value + """) + assert "my_task = whencall(" in result.code, ( + "result assignment was dropped from exported module;\n" + f"generated code:\n{result.code}" + ) + + def test_result_is_ast_assign_not_expr(self): + """The whencall node returned by visit_FunctionDef must be an + ast.Assign, not an ast.Expr wrapping an ast.Assign. + + visit_Module filters out all ast.Expr nodes; an ast.Expr return + would silently drop the assignment. + """ + result = self._export("""\ + from bocpy import when, whencall, Cown + + x = Cown(1) + + @when(x) + def my_task(x): + return x.value + """) + gen_tree = ast.parse(result.code) + assigns = [ + node for node in ast.walk(gen_tree) + if isinstance(node, ast.Assign) + and any( + isinstance(t, ast.Name) and t.id == "my_task" + for t in node.targets + ) + ] + assert assigns, ( + "no ast.Assign for 'my_task' found in exported AST; " + "the assignment was likely wrapped in ast.Expr and dropped.\n" + f"generated code:\n{result.code}" + ) + + def test_multiple_behaviors_all_assigned(self): + """Every @when function in the module must be assigned, not just the first.""" + result = self._export("""\ + from bocpy import when, whencall, Cown + + x = Cown(1) + y = Cown(2) + + @when(x) + def task_a(x): + return x.value + + @when(y) + def task_b(y): + return y.value + """) + assert "task_a = whencall(" in result.code, ( + "'task_a' assignment missing from export;\n" + f"generated code:\n{result.code}" + ) + assert "task_b = whencall(" in result.code, ( + "'task_b' assignment missing from export;\n" + f"generated code:\n{result.code}" + ) + + def test_assignment_store_context(self): + """The assignment target must use ast.Store context, not ast.Load.""" + result = self._export("""\ + from bocpy import when, whencall, Cown + + x = Cown(1) + + @when(x) + def my_task(x): + return x.value + """) + gen_tree = ast.parse(result.code) + for node in ast.walk(gen_tree): + if ( + isinstance(node, ast.Assign) + and any( + isinstance(t, ast.Name) and t.id == "my_task" + for t in node.targets + ) + ): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "my_task": + assert isinstance(target.ctx, ast.Store), ( + f"assignment target 'my_task' has ctx " + f"{type(target.ctx).__name__!r}, expected Store" + ) + return + raise AssertionError( + "no assignment for 'my_task' found in exported AST" + ) + \ No newline at end of file From ed690b4650179125ac5fa2246244d0ab75f99e19 Mon Sep 17 00:00:00 2001 From: Shivanand Mishra Date: Mon, 8 Jun 2026 20:52:25 +0530 Subject: [PATCH 3/4] style: fix docstring and whitespace issues --- test/test_transpiler.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/test_transpiler.py b/test/test_transpiler.py index 75cf183..998252c 100644 --- a/test/test_transpiler.py +++ b/test/test_transpiler.py @@ -1105,8 +1105,7 @@ def b(c): # Regression: @when result assignment must not be dropped class TestWhenResultAssignment: - """@when-decorated functions must produce a name = whencall(...) assignment - in the exported module. + """@when-decorated functions must produce a name = whencall(...) assignment. Regression: WhenTransformer.visit_FunctionDef was returning ``ast.Expr(ast.Assign(...))``, an ast.Assign statement incorrectly @@ -1225,4 +1224,3 @@ def my_task(x): raise AssertionError( "no assignment for 'my_task' found in exported AST" ) - \ No newline at end of file From 8897a004caa4a5ec73b3b99f6919269a278ea450 Mon Sep 17 00:00:00 2001 From: Shivanand Mishra Date: Mon, 8 Jun 2026 21:28:38 +0530 Subject: [PATCH 4/4] style: fix docstring and whitespace issues --- test/test_transpiler.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_transpiler.py b/test/test_transpiler.py index 998252c..87bf90b 100644 --- a/test/test_transpiler.py +++ b/test/test_transpiler.py @@ -1139,8 +1139,7 @@ def my_task(x): ) def test_result_is_ast_assign_not_expr(self): - """The whencall node returned by visit_FunctionDef must be an - ast.Assign, not an ast.Expr wrapping an ast.Assign. + """The whencall node returned by visit_FunctionDef must be an ast.Assign, not an ast.Expr wrapping an ast.Assign. visit_Module filters out all ast.Expr nodes; an ast.Expr return would silently drop the assignment.