From a43c01d68d2623eadaf88cf44fcbf6719dbc4716 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:22:31 +0000 Subject: [PATCH] Add a '+' button for *args parameters (#28) Co-Authored-By: Claude Sonnet 5.5 --- streamlitfront/elements/elements.py | 45 +++++++++++++++++++++++++++ tests/test_var_positional.py | 47 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 tests/test_var_positional.py diff --git a/streamlitfront/elements/elements.py b/streamlitfront/elements/elements.py index 4d1e2e7..b1e09a0 100644 --- a/streamlitfront/elements/elements.py +++ b/streamlitfront/elements/elements.py @@ -4,7 +4,9 @@ specific abstract elements class defined in front. """ +import dataclasses from dataclasses import dataclass +from inspect import Parameter from functools import cached_property from pathlib import Path from time import sleep @@ -26,6 +28,7 @@ OutputBase, SelectorBase, TextInputBase, + ValueNotSet, TextSectionBase, ELEMENT_KEY, ) @@ -195,6 +198,48 @@ def _render_section_content(self): except ValidationError as e: st.error(e) + def _render_inputs(self): + """Render the inputs; a ``*args`` parameter gets a "+" button and one field per press.""" + inputs = {} + for child in self.children: + if not isinstance(child, InputBase): + continue + if getattr(child.obj, "kind", None) is Parameter.VAR_POSITIONAL: + inputs[child.obj.name] = self._render_var_positional(child) + else: + if child.is_noneable: + child = self._noneable(child) + inputs[child.obj.name] = child() + return inputs + + def _render_var_positional(self, template: InputBase) -> tuple: + """The values of a ``*args`` parameter: the user asks for more fields with "+", fewer with "-". + + Each field is a copy of ``template`` (so ``Iterable[float]``-style annotations give + number fields) under its own key. + """ + name = template.obj.name + count_key = f"{template.input_key}_count" + st.session_state.setdefault(count_key, 0) + + def bump(by): + st.session_state[count_key] = max(0, st.session_state[count_key] + by) + + st.markdown(name.replace("_", " ").title()) + plus, minus, _ = st.columns([1, 1, 4]) + plus.button("+", key=f"{count_key}_plus", on_click=bump, args=(1,), help=f"Add another {name}") + minus.button( + "-", key=f"{count_key}_minus", on_click=bump, args=(-1,), disabled=not st.session_state[count_key], + help=f"Remove the last {name}", + ) + values = [] + for i in range(st.session_state[count_key]): + field = dataclasses.replace( + template, name=f"{name} {i + 1}", input_key=f"{template.input_key}_{i}", value=ValueNotSet + ) + values.append(field()) + return tuple(values) + def _submit(self, inputs): super()._submit(inputs) if not self.auto_submit: diff --git a/tests/test_var_positional.py b/tests/test_var_positional.py new file mode 100644 index 0000000..906b154 --- /dev/null +++ b/tests/test_var_positional.py @@ -0,0 +1,47 @@ +"""A ``*args`` parameter gets a "+" button: one more field per press, and the +function is called with what was entered (i2mint/streamlitfront#28).""" + +import pytest + +pytest.importorskip("streamlit.testing.v1") +from streamlit.testing.v1 import AppTest + +APP = """ +from streamlitfront import mk_app +def add_nums(*nums_to_add: int): + return sum(nums_to_add) +def tag(prefix: str, *names: str): + return prefix + ",".join(names) +mk_app([add_nums, tag])() +""" + + +def _plus(at): + return next(b for b in at.button if b.key and b.key.endswith("_plus")) + + +def _minus(at): + return next(b for b in at.button if b.key and b.key.endswith("_minus")) + + +def test_plus_adds_a_field_per_press_and_submit_calls_the_function(): + at = AppTest.from_string(APP, default_timeout=30).run() + assert not at.exception + assert len(at.number_input) == 0 + for _ in range(3): + _plus(at).click().run() + assert len(at.number_input) == 3 + for field, v in zip(at.number_input, (1, 2, 3)): + field.set_value(v) + at.run() + _minus(at).click().run() + assert len(at.number_input) == 2 + next(b for b in at.button if b.label == "Submit").click().run() + assert not at.exception + assert any(m.value == "`3`" for m in at.markdown) + + +def test_no_press_means_no_extra_arguments(): + at = AppTest.from_string(APP, default_timeout=30).run() + next(b for b in at.button if b.label == "Submit").click().run() + assert not at.exception