From 153d7baf2f47abec7df24d9804eda81bef8886aa Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 20 May 2026 11:28:25 +0200 Subject: [PATCH] Migrate to pydantic v2; drop unmaintained streamlit_pydantic (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit front has moved to pydantic v2 (i2mint/front#30). streamlitfront consumes front.py2pydantic and was the next package pinning the ecosystem to pydantic v1. The blocker was the streamlit_pydantic dependency: its last release (0.6.0, 2022) imports pydantic.BaseSettings, which was removed in pydantic v2, and there is no v2-compatible release. Rather than keep the ecosystem on pydantic v1, the small subset of streamlit_pydantic that streamlitfront used is reimplemented natively. Changes ------- - streamlitfront/pydantic_widgets.py (new) Native-Streamlit rendering of pydantic v2 models — a drop-in for the old `import streamlit_pydantic as sp`: * pydantic_input(key, model) -> validated instance (or None) * pydantic_form(key, model) -> instance on submit, else None * pydantic_output(instance) -> render as JSON Widgets are chosen per field type via model.model_fields. - streamlitfront/page_funcs.py SimplePageFuncPydanticWrite / SimplePageFuncPydanticWithOutput now use streamlitfront.pydantic_widgets. Result handling uses instance.model_dump() instead of dict(instance). - streamlitfront/tests/dummy_app.py Same import swap; added the missing module docstring. - streamlitfront/tests/test_pydantic_widgets.py (new) Covers the new module: pure-helper unit tests plus AppTest-based integration tests that render the widgets in a headless Streamlit run and confirm a validated model instance is built. - setup.cfg Drop streamlit_pydantic; add an explicit pydantic>=2 requirement (it was used at import time but never declared). --- setup.cfg | 4 +- streamlitfront/page_funcs.py | 10 +- streamlitfront/pydantic_widgets.py | 103 +++++++++++++++ streamlitfront/tests/dummy_app.py | 4 +- streamlitfront/tests/test_pydantic_widgets.py | 118 ++++++++++++++++++ 5 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 streamlitfront/pydantic_widgets.py create mode 100644 streamlitfront/tests/test_pydantic_widgets.py diff --git a/setup.cfg b/setup.cfg index 375f11c..5cb1c30 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,12 +16,12 @@ display_name = streamlitfront packages = find: include_package_data = True zip_safe = False -install_requires = +install_requires = front graphviz i2 importlib_resources + pydantic>=2 stogui streamlit - streamlit_pydantic diff --git a/streamlitfront/page_funcs.py b/streamlitfront/page_funcs.py index fb8e85a..913a8d6 100644 --- a/streamlitfront/page_funcs.py +++ b/streamlitfront/page_funcs.py @@ -273,7 +273,7 @@ def __call__(self, state): from i2 import name_of_obj from front.py2pydantic import func_to_pyd_input_model_cls, pydantic_model_from_type import streamlit as st -import streamlit_pydantic as sp # pip install streamlit-pydantic +from streamlitfront import pydantic_widgets as sp class SimplePageFuncPydanticWrite(BasePageFunc): @@ -282,13 +282,9 @@ def __call__(self, state): mymodel = func_to_pyd_input_model_cls(self.func) name = name_of_obj(self.func) data = sp.pydantic_form(key=f'my_form_{name}', model=mymodel) - # data = sp.pydantic_input(key=f"my_form_{name}", model=mymodel) if data: - # print(f"--------st.write(self.func(**dict(data)))") - # print(f"{Sig(self.func)}") - # print(f"{dict(data)}") - st.write(self.func(**dict(data))) + st.write(self.func(**data.model_dump())) class SimplePageFuncPydanticWithOutput(BasePageFunc): @@ -305,7 +301,7 @@ def __call__(self, state): data = sp.pydantic_input(key=f'my_form_{name}', model=mymodel) if data: - func_result = self.func(**data) + func_result = self.func(**data.model_dump()) instance = output_model(result=func_result) diff --git a/streamlitfront/pydantic_widgets.py b/streamlitfront/pydantic_widgets.py new file mode 100644 index 0000000..3ad9650 --- /dev/null +++ b/streamlitfront/pydantic_widgets.py @@ -0,0 +1,103 @@ +"""Native-Streamlit rendering of pydantic v2 models — input forms and output. + +A minimal, self-contained replacement for the small subset of the +(unmaintained, pydantic-v1-only) ``streamlit_pydantic`` package that +streamlitfront actually used. + +``streamlit_pydantic`` 0.6.0 — its last release — imports +``pydantic.BaseSettings``, which was removed in pydantic v2, and there is no +v2-compatible release. Rather than pin the whole ecosystem to pydantic v1, +the three helpers used by streamlitfront are reimplemented here against the +pydantic v2 API (``model.model_fields``) and native ``streamlit`` widgets. + +Public API (drop-in for the old ``import streamlit_pydantic as sp``): + +- :func:`pydantic_input` — render one widget per model field, return a + validated model instance (or ``None`` on validation error). +- :func:`pydantic_form` — same, wrapped in an ``st.form`` with a submit + button; returns the instance on submit, ``None`` otherwise. +- :func:`pydantic_output` — render a model instance. +""" + +from typing import Optional, Type, Union, get_args, get_origin + +import streamlit as st +from pydantic import BaseModel, ValidationError + + +def _unwrap_optional(annotation): + """Return ``X`` for an ``Optional[X]`` / ``Union[X, None]`` annotation, else the annotation.""" + if get_origin(annotation) is Union: + non_none = [a for a in get_args(annotation) if a is not type(None)] + if len(non_none) == 1: + return non_none[0] + return annotation + + +def _widget_for_field(field_name, field_info, *, key): + """Render the Streamlit widget that best matches a pydantic field's type.""" + annotation = _unwrap_optional(field_info.annotation) + has_default = not field_info.is_required() + default = field_info.default if has_default else None + + if annotation is bool: + return st.checkbox(field_name, value=bool(default) or False, key=key) + if annotation is int: + return st.number_input( + field_name, value=int(default) if default is not None else 0, step=1, key=key + ) + if annotation is float: + return st.number_input( + field_name, value=float(default) if default is not None else 0.0, key=key + ) + # str, Any, and everything else fall back to a text field. + return st.text_input( + field_name, value=str(default) if default is not None else "", key=key + ) + + +def _collect_field_values(model: Type[BaseModel], *, key: str) -> dict: + """Render a widget per model field and return ``{field_name: widget_value}``.""" + return { + name: _widget_for_field(name, info, key=f"{key}_{name}") + for name, info in model.model_fields.items() + } + + +def _instantiate(model: Type[BaseModel], values: dict) -> Optional[BaseModel]: + """Build a model instance from collected values, surfacing errors via ``st.error``.""" + try: + return model(**values) + except ValidationError as error: + st.error(str(error)) + return None + + +def pydantic_input(key: str, model: Type[BaseModel]) -> Optional[BaseModel]: + """Render an input widget per field of ``model``; return a validated instance. + + Returns ``None`` if the current widget values fail validation. + """ + values = _collect_field_values(model, key=key) + return _instantiate(model, values) + + +def pydantic_form( + key: str, model: Type[BaseModel], *, submit_label: str = "Submit" +) -> Optional[BaseModel]: + """Like :func:`pydantic_input`, wrapped in an ``st.form`` with a submit button. + + Returns the validated model instance once the form is submitted, otherwise + ``None`` (so callers can guard with ``if data:``). + """ + with st.form(key=key): + values = _collect_field_values(model, key=key) + submitted = st.form_submit_button(submit_label) + if not submitted: + return None + return _instantiate(model, values) + + +def pydantic_output(instance: BaseModel) -> None: + """Render a pydantic model instance as JSON.""" + st.json(instance.model_dump()) diff --git a/streamlitfront/tests/dummy_app.py b/streamlitfront/tests/dummy_app.py index 480d491..ab35ff4 100644 --- a/streamlitfront/tests/dummy_app.py +++ b/streamlitfront/tests/dummy_app.py @@ -1,7 +1,9 @@ +"""A tiny streamlit-app fixture used by the streamlitfront test suite.""" + from streamlitfront.base import get_pages_specs, get_func_args_specs, BasePageFunc import streamlit as st from pydantic import BaseModel -import streamlit_pydantic as sp +from streamlitfront import pydantic_widgets as sp def multiple(x: int, word: str) -> str: diff --git a/streamlitfront/tests/test_pydantic_widgets.py b/streamlitfront/tests/test_pydantic_widgets.py new file mode 100644 index 0000000..e248786 --- /dev/null +++ b/streamlitfront/tests/test_pydantic_widgets.py @@ -0,0 +1,118 @@ +"""Tests for :mod:`streamlitfront.pydantic_widgets`. + +The widget functions need a Streamlit run context, so they're exercised +through ``streamlit.testing.v1.AppTest`` — a real (headless) app run. The +pure helpers are tested directly. +""" + +from typing import Optional + +from streamlit.testing.v1 import AppTest + +from streamlitfront.pydantic_widgets import _unwrap_optional + + +# ---------------------------------------------------------------------------- +# Pure-helper unit tests + + +def test_unwrap_optional(): + assert _unwrap_optional(Optional[int]) is int + assert _unwrap_optional(int) is int + # A non-Optional Union is left untouched. + union = _unwrap_optional(eval("int | str")) + assert union == eval("int | str") + + +# ---------------------------------------------------------------------------- +# AppTest-based integration tests + +_INPUT_SCRIPT = """ +import streamlit as st +from pydantic import BaseModel +from streamlitfront.pydantic_widgets import pydantic_input + +class Person(BaseModel): + age: int + name: str = "anon" + active: bool = False + +person = pydantic_input("person", Person) +if person is not None: + st.text(f"{person.name}:{person.age}:{person.active}") +""" + + +def test_pydantic_input_renders_and_builds_instance(): + """Each field gets a widget; editing widgets rebuilds a validated instance.""" + at = AppTest.from_string(_INPUT_SCRIPT) + at.run() + assert not at.exception + + # One widget per field, of the type matching the annotation. + assert len(at.number_input) == 1 # age: int + assert len(at.text_input) == 1 # name: str + assert len(at.checkbox) == 1 # active: bool + + # Defaults flow through to a valid instance immediately. + assert at.text[0].value == "anon:0:False" + + # Edit the widgets; the instance should reflect the new values. + at.number_input[0].set_value(42).run() + at.text_input[0].set_value("alice").run() + at.checkbox[0].set_value(True).run() + assert not at.exception + assert at.text[0].value == "alice:42:True" + + +_FORM_SCRIPT = """ +import streamlit as st +from pydantic import BaseModel +from streamlitfront.pydantic_widgets import pydantic_form + +class Coords(BaseModel): + x: int + y: int = 5 + +result = pydantic_form("coords", Coords) +st.text("submitted" if result is not None else "pending") +if result is not None: + st.text(f"{result.x},{result.y}") +""" + + +def test_pydantic_form_returns_none_until_submitted(): + """The form yields None before submit, an instance after submit.""" + at = AppTest.from_string(_FORM_SCRIPT) + at.run() + assert not at.exception + # Before submitting, the form returns None. + assert at.text[0].value == "pending" + + # Fill in and submit the form. + at.number_input[0].set_value(3) + at.number_input[1].set_value(9) + at.button[0].click().run() + assert not at.exception + assert at.text[0].value == "submitted" + assert at.text[1].value == "3,9" + + +_OUTPUT_SCRIPT = """ +from pydantic import BaseModel +from streamlitfront.pydantic_widgets import pydantic_output + +class Result(BaseModel): + value: int + label: str + +pydantic_output(Result(value=7, label="seven")) +""" + + +def test_pydantic_output_renders_instance(): + """pydantic_output renders the instance without error.""" + at = AppTest.from_string(_OUTPUT_SCRIPT) + at.run() + assert not at.exception + assert len(at.json) == 1