From ab13da71d944469390dbf3a0b0e864743463098d Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:56:42 +0000 Subject: [PATCH] Stop ExecSection from mutating the shared kwargs input spec _feed_kwargs_input_spec assigned into inputs["kwargs"]["inputs"], which is the dict held by the convention default, so each section leaked its sibling input specs into every section built after it. Build new dicts instead and use self.inputs when making the input element specs. Fixes #39 Co-Authored-By: Claude Opus 5.5 --- front/elements/elements.py | 13 ++++- tests/test_exec_section_spec_isolation.py | 70 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/test_exec_section_spec_isolation.py diff --git a/front/elements/elements.py b/front/elements/elements.py index 9a0c935..287c87f 100644 --- a/front/elements/elements.py +++ b/front/elements/elements.py @@ -372,7 +372,7 @@ def __init__( ): self.inputs = inputs self._feed_kwargs_input_spec() - element_specs = dict(mk_input_element_specs(obj, inputs), output=output) + element_specs = dict(mk_input_element_specs(obj, self.inputs), output=output) super().__init__( obj=obj, name=name, display=display, tooltip=tooltip, **element_specs ) @@ -380,12 +380,21 @@ def __init__( self.on_submit = on_submit def _feed_kwargs_input_spec(self): + """Give the ``kwargs`` input spec its sibling input specs as nested ``inputs``. + + Builds new dicts rather than assigning into ``self.inputs["kwargs"]``: that + dict usually belongs to a shared convention default, so mutating it would + leak this section's inputs into every section built afterwards. + """ inputs_spec = dict(self.inputs) kwargs_spec = inputs_spec.pop("kwargs", None) if kwargs_spec: kwargs_inputs = kwargs_spec.get("inputs", {}) kwargs_inputs = deep_merge(inputs_spec, kwargs_inputs) - self.inputs["kwargs"]["inputs"] = kwargs_inputs + self.inputs = { + **self.inputs, + "kwargs": {**kwargs_spec, "inputs": kwargs_inputs}, + } def _render_inputs(self): input_components = [ diff --git a/tests/test_exec_section_spec_isolation.py b/tests/test_exec_section_spec_isolation.py new file mode 100644 index 0000000..b01b98d --- /dev/null +++ b/tests/test_exec_section_spec_isolation.py @@ -0,0 +1,70 @@ +"""An exec section must not mutate the (shared) input spec it is given. + +Regression test for i2mint/front#39: ``_feed_kwargs_input_spec`` used to assign +into ``inputs["kwargs"]["inputs"]``, which is the dict held by a convention +default, so one app's input specs leaked into every app built afterwards. +""" + +import copy +from dataclasses import dataclass + +from front.data_binding import BoundData +from front.elements.elements import ( + DEFAULT_INPUT_KEY, + ELEMENT_KEY, + ExecContainerBase, + OutputBase, + TextInputBase, +) + + +@dataclass +class _Text(TextInputBase): + def render(self): + return self.view_value + + +@dataclass +class _Out(OutputBase): + def render(self): + return self.output + + +class _Section(ExecContainerBase): + def render(self): + return None + + def _noneable(self, input): + return input + + +def _shared_inputs(state): + """Like a convention default: a ``kwargs`` entry that is always present.""" + return { + "kwargs": {ELEMENT_KEY: _Text}, + str: {ELEMENT_KEY: _Text}, + DEFAULT_INPUT_KEY: {"bound_data_factory": lambda k: BoundData(k, state)}, + } + + +def test_exec_section_does_not_mutate_the_shared_inputs_spec(): + state = {} + shared = _shared_inputs(state) + snapshot = copy.copy(shared["kwargs"]) + + def first(a: str): + return a + + def second(b: str): + return b + + first_section = _Section( + obj=first, + inputs=dict(shared, a={"disabled": True}), + output={ELEMENT_KEY: _Out}, + ) + _Section(obj=second, inputs=dict(shared), output={ELEMENT_KEY: _Out}) + + assert shared["kwargs"] == snapshot # nothing written into the shared default + # ... while each section still sees its own siblings under the kwargs spec + assert first_section.inputs["kwargs"]["inputs"]["a"] == {"disabled": True}