diff --git a/plugboard/utils/export_mixin.py b/plugboard/utils/export_mixin.py index abf17739..f7e97856 100644 --- a/plugboard/utils/export_mixin.py +++ b/plugboard/utils/export_mixin.py @@ -32,13 +32,24 @@ 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: + try: + delattr(self, _reentrant_key) + except AttributeError: + pass return _wrapper 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."""