From 0e2d2b236560c18030c5eb73ce7a8e2ab1bf1296 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:25:31 +0000 Subject: [PATCH 1/4] Initial plan From 55d981431cc6cca16ab302ce81f4f4364cd22d98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:29:06 +0000 Subject: [PATCH 2/4] fix: record __init__ args only for the outermost call in ExportMixin Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- plugboard/utils/export_mixin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugboard/utils/export_mixin.py b/plugboard/utils/export_mixin.py index abf17739..06d05637 100644 --- a/plugboard/utils/export_mixin.py +++ b/plugboard/utils/export_mixin.py @@ -32,13 +32,21 @@ def _save_args_wrapper(method: _t.Callable, key: str) -> _t.Callable: for k, p in inspect.signature(method).parameters.items() if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD and k != "self" ] + _reentrant_key = f"__{key}_active__" @wraps(method) def _wrapper(self: _t.Any, *args: _t.Any, **kwargs: _t.Any) -> None: - saved_kwargs = ExportMixin._convert_exportable_objs(kwargs) - saved_args = dict(zip(_positional_args[: len(args)], args)) - setattr(self, key, {**getattr(self, key, {}), **saved_args, **saved_kwargs}) - method(self, *args, **kwargs) + is_outermost = not getattr(self, _reentrant_key, False) + if is_outermost: + setattr(self, _reentrant_key, True) + saved_kwargs = ExportMixin._convert_exportable_objs(kwargs) + saved_args = dict(zip(_positional_args[: len(args)], args)) + setattr(self, key, {**saved_args, **saved_kwargs}) + try: + method(self, *args, **kwargs) + finally: + if is_outermost: + setattr(self, _reentrant_key, False) return _wrapper From f38d1f230392c7b5efd75852ba04549ef63f5dfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:30:32 +0000 Subject: [PATCH 3/4] fix: clean up reentrant flag after outermost __init__ completes Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- plugboard/utils/export_mixin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugboard/utils/export_mixin.py b/plugboard/utils/export_mixin.py index 06d05637..f7e97856 100644 --- a/plugboard/utils/export_mixin.py +++ b/plugboard/utils/export_mixin.py @@ -46,7 +46,10 @@ def _wrapper(self: _t.Any, *args: _t.Any, **kwargs: _t.Any) -> None: method(self, *args, **kwargs) finally: if is_outermost: - setattr(self, _reentrant_key, False) + try: + delattr(self, _reentrant_key) + except AttributeError: + pass return _wrapper From ac72ad5fcffa98b5197c85fd393ff7d6dc449c12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:35:01 +0000 Subject: [PATCH 4/4] test: add unit tests for ExportMixin outermost-args fix Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- tests/unit/test_component.py | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/test_component.py b/tests/unit/test_component.py index b0139d6d..9c43bafb 100644 --- a/tests/unit/test_component.py +++ b/tests/unit/test_component.py @@ -68,6 +68,45 @@ async def test_component_initial_values(initial_values: dict[str, _t.Iterable]) await component.io.close() +class ExportBase(Component): + io = IO(inputs=[], outputs=["out"]) + + def __init__(self, x: int = 1, **kwargs: _t.Unpack[ComponentArgsDict]) -> None: + super().__init__(**kwargs) + self.x = x + + async def step(self) -> None: + pass + + +class ExportDerived(ExportBase): + io = IO(inputs=[], outputs=["out"]) + + def __init__(self, y: int = 2, **kwargs: _t.Unpack[ComponentArgsDict]) -> None: + super().__init__(x=y * 10, **kwargs) + self.y = y + + async def step(self) -> None: + pass + + +def test_export_captures_only_outermost_init_args() -> None: + """export() should record only the arguments passed to the outermost __init__.""" + d = ExportDerived(name="d", y=3) + args = d.export()["args"] + # Only ExportDerived's own args should appear; ExportBase's derived 'x' must not + assert args == {"name": "d", "y": 3} + # Rebuilding from the exported args must succeed without TypeError + d2 = ExportDerived(**args) + assert d2.export()["args"] == args + + +def test_export_no_stale_flag_on_instance() -> None: + """Re-entrancy flag must not persist on the instance after construction.""" + d = ExportDerived(name="d", y=5) + assert not any(attr.endswith("_active__") for attr in vars(d)) + + @pytest.mark.asyncio async def test_component_status() -> None: """Tests the status of a `Component` across its lifecycle."""