Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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

10 changes: 3 additions & 7 deletions streamlitfront/page_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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)

Expand Down
103 changes: 103 additions & 0 deletions streamlitfront/pydantic_widgets.py
Original file line number Diff line number Diff line change
@@ -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())
4 changes: 3 additions & 1 deletion streamlitfront/tests/dummy_app.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
118 changes: 118 additions & 0 deletions streamlitfront/tests/test_pydantic_widgets.py
Original file line number Diff line number Diff line change
@@ -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
Loading