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}