From d09f47ca1c9f0ab7f8fd9890ace4e74f9fbb1980 Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Sun, 14 Dec 2025 13:41:18 +0100 Subject: [PATCH 01/10] Support lists of pydantic objects as dataframe-like structure --- lib/streamlit/dataframe_util.py | 12 +++++++++++- lib/tests/streamlit/data_test_cases.py | 27 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/streamlit/dataframe_util.py b/lib/streamlit/dataframe_util.py index 8b228221fa3..fa6ef8097eb 100644 --- a/lib/streamlit/dataframe_util.py +++ b/lib/streamlit/dataframe_util.py @@ -371,6 +371,11 @@ def is_snowpark_row_list(obj: object) -> bool: ) +def _is_list_of_pydantic_models(obj: object) -> bool: + """True if obj is a non-empty list of Pydantic model instances.""" + return isinstance(obj, list) and len(obj) > 0 and is_pydantic_model(obj[0]) + + def is_pyspark_data_object(obj: object) -> bool: """True if obj is a PySpark or PySpark Connect dataframe.""" return ( @@ -703,6 +708,11 @@ def convert_anything_to_pandas_df( if is_snowpark_row_list(data): return pd.DataFrame([row.as_dict() for row in data]) + if _is_list_of_pydantic_models(data): + if has_callable_attr(data[0], "model_dump"): + return pd.DataFrame([item.model_dump() for item in data]) + return pd.DataFrame([item.dict() for item in data]) + if has_callable_attr(data, "to_pandas"): return pd.DataFrame(data.to_pandas()) @@ -1242,7 +1252,7 @@ def determine_data_format(input_data: Any) -> DataFormat: # This should always contain at least one element, # otherwise the values type from infer_dtype would have been empty first_element = next(iter(input_data)) - if isinstance(first_element, dict): + if isinstance(first_element, dict) or is_pydantic_model(first_element): return DataFormat.LIST_OF_RECORDS if isinstance(first_element, (list, tuple, set, frozenset)): return DataFormat.LIST_OF_ROWS diff --git a/lib/tests/streamlit/data_test_cases.py b/lib/tests/streamlit/data_test_cases.py index d26b356b170..c25f1524069 100644 --- a/lib/tests/streamlit/data_test_cases.py +++ b/lib/tests/streamlit/data_test_cases.py @@ -1250,6 +1250,33 @@ class ElementPydanticModel(BaseModel): dict, ), ), + ( + "List of Pydantic Models", + [ + ElementPydanticModel( + name="st.number_input", is_widget=True, usage=0.32 + ), + ElementPydanticModel( + name="st.text_input", is_widget=True, usage=0.45 + ), + ], + CaseMetadata( + 2, + 3, + DataFormat.LIST_OF_RECORDS, + [ + ElementPydanticModel( + name="st.number_input", is_widget=True, usage=0.32 + ), + ElementPydanticModel( + name="st.text_input", is_widget=True, usage=0.45 + ), + ], + "json", + False, + list, + ), + ), ] ) except ModuleNotFoundError: From 401e19f8d156cf43b30d4ebf2660f0d1265c1868 Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Sun, 14 Dec 2025 13:54:40 +0100 Subject: [PATCH 02/10] Apply feedback --- lib/streamlit/dataframe_util.py | 23 ++++++++++++++------- lib/tests/streamlit/data_test_cases.py | 28 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/lib/streamlit/dataframe_util.py b/lib/streamlit/dataframe_util.py index fa6ef8097eb..9e75f29f4ef 100644 --- a/lib/streamlit/dataframe_util.py +++ b/lib/streamlit/dataframe_util.py @@ -371,9 +371,12 @@ def is_snowpark_row_list(obj: object) -> bool: ) -def _is_list_of_pydantic_models(obj: object) -> bool: - """True if obj is a non-empty list of Pydantic model instances.""" - return isinstance(obj, list) and len(obj) > 0 and is_pydantic_model(obj[0]) +def _is_sequence_of_pydantic_models(obj: object) -> bool: + """True if obj is a non-empty list/tuple/set/frozenset of Pydantic model instances.""" + if not isinstance(obj, (list, tuple, set, frozenset)) or len(obj) == 0: + return False + first_element = next(iter(obj)) + return is_pydantic_model(first_element) def is_pyspark_data_object(obj: object) -> bool: @@ -708,10 +711,16 @@ def convert_anything_to_pandas_df( if is_snowpark_row_list(data): return pd.DataFrame([row.as_dict() for row in data]) - if _is_list_of_pydantic_models(data): - if has_callable_attr(data[0], "model_dump"): - return pd.DataFrame([item.model_dump() for item in data]) - return pd.DataFrame([item.dict() for item in data]) + if _is_sequence_of_pydantic_models(data): + # Try to convert pydantic models to DataFrame. If some elements are not + # pydantic models (mixed sequence), fall through to pandas' native handling. + try: + first_element = next(iter(data)) + if has_callable_attr(first_element, "model_dump"): + return pd.DataFrame([item.model_dump() for item in data]) + return pd.DataFrame([item.dict() for item in data]) + except AttributeError: + pass if has_callable_attr(data, "to_pandas"): return pd.DataFrame(data.to_pandas()) diff --git a/lib/tests/streamlit/data_test_cases.py b/lib/tests/streamlit/data_test_cases.py index c25f1524069..58fb5cfc0b1 100644 --- a/lib/tests/streamlit/data_test_cases.py +++ b/lib/tests/streamlit/data_test_cases.py @@ -1277,6 +1277,34 @@ class ElementPydanticModel(BaseModel): list, ), ), + ( + "Tuple of Pydantic Models", + ( + ElementPydanticModel( + name="st.number_input", is_widget=True, usage=0.32 + ), + ElementPydanticModel( + name="st.text_input", is_widget=True, usage=0.45 + ), + ), + CaseMetadata( + 2, + 3, + DataFormat.LIST_OF_RECORDS, + [ + ElementPydanticModel( + name="st.number_input", is_widget=True, usage=0.32 + ), + ElementPydanticModel( + name="st.text_input", is_widget=True, usage=0.45 + ), + ], + "json", + False, + # LIST_OF_RECORDS always converts back to list, not tuple + list, + ), + ), ] ) except ModuleNotFoundError: From bd1c4e77edf94cac4cf87c13c39a2b0bed7fd199 Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Sun, 14 Dec 2025 14:02:31 +0100 Subject: [PATCH 03/10] Update --- lib/streamlit/dataframe_util.py | 11 ++--------- lib/streamlit/elements/json.py | 11 ++++++++++- lib/streamlit/type_util.py | 8 ++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/streamlit/dataframe_util.py b/lib/streamlit/dataframe_util.py index 9e75f29f4ef..ce398b2fb82 100644 --- a/lib/streamlit/dataframe_util.py +++ b/lib/streamlit/dataframe_util.py @@ -47,6 +47,7 @@ is_list_like, is_namedtuple, is_pydantic_model, + is_sequence_of_pydantic_models, is_type, is_version_less_than, ) @@ -371,14 +372,6 @@ def is_snowpark_row_list(obj: object) -> bool: ) -def _is_sequence_of_pydantic_models(obj: object) -> bool: - """True if obj is a non-empty list/tuple/set/frozenset of Pydantic model instances.""" - if not isinstance(obj, (list, tuple, set, frozenset)) or len(obj) == 0: - return False - first_element = next(iter(obj)) - return is_pydantic_model(first_element) - - def is_pyspark_data_object(obj: object) -> bool: """True if obj is a PySpark or PySpark Connect dataframe.""" return ( @@ -711,7 +704,7 @@ def convert_anything_to_pandas_df( if is_snowpark_row_list(data): return pd.DataFrame([row.as_dict() for row in data]) - if _is_sequence_of_pydantic_models(data): + if is_sequence_of_pydantic_models(data): # Try to convert pydantic models to DataFrame. If some elements are not # pydantic models (mixed sequence), fall through to pandas' native handling. try: diff --git a/lib/streamlit/elements/json.py b/lib/streamlit/elements/json.py index 608d009cc64..c8797c9e6ad 100644 --- a/lib/streamlit/elements/json.py +++ b/lib/streamlit/elements/json.py @@ -31,6 +31,7 @@ is_list_like, is_namedtuple, is_pydantic_model, + is_sequence_of_pydantic_models, ) if TYPE_CHECKING: @@ -120,7 +121,15 @@ def json( body = dict(body) # type: ignore if is_list_like(body): - body = list(body) # ty: ignore[invalid-argument-type] + if is_sequence_of_pydantic_models(body): + first_item = next(iter(body)) + # Pydantic v2 uses model_dump(), v1 uses dict() + if hasattr(first_item, "model_dump"): + body = [item.model_dump() for item in body] + else: + body = [item.dict() for item in body] + else: + body = list(body) # ty: ignore[invalid-argument-type] if not isinstance(body, str): try: diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py index 3ae7a4a95e3..eead16858bf 100644 --- a/lib/streamlit/type_util.py +++ b/lib/streamlit/type_util.py @@ -331,6 +331,14 @@ def is_pydantic_model(obj: object) -> bool: return _is_type_instance(obj, "pydantic.main.BaseModel") +def is_sequence_of_pydantic_models(obj: object) -> bool: + """True if obj is a non-empty list/tuple/set/frozenset of Pydantic model instances.""" + if not isinstance(obj, (list, tuple, set, frozenset)) or len(obj) == 0: + return False + first_element = next(iter(obj)) + return is_pydantic_model(first_element) + + def _is_from_streamlit(obj: object) -> bool: """True if the object is from the streamlit package.""" return obj.__class__.__module__.startswith("streamlit") From e73ab97dd21873e6b108073d35fe39689bafefd0 Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Sun, 14 Dec 2025 17:50:02 +0100 Subject: [PATCH 04/10] Update --- lib/streamlit/elements/json.py | 6 +++--- lib/streamlit/type_util.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/streamlit/elements/json.py b/lib/streamlit/elements/json.py index c8797c9e6ad..532f6a0b8af 100644 --- a/lib/streamlit/elements/json.py +++ b/lib/streamlit/elements/json.py @@ -122,12 +122,12 @@ def json( if is_list_like(body): if is_sequence_of_pydantic_models(body): - first_item = next(iter(body)) + first_item = next(iter(body)) # ty: ignore[no-matching-overload] # Pydantic v2 uses model_dump(), v1 uses dict() if hasattr(first_item, "model_dump"): - body = [item.model_dump() for item in body] + body = [item.model_dump() for item in body] # ty: ignore[not-iterable] else: - body = [item.dict() for item in body] + body = [item.dict() for item in body] # ty: ignore[not-iterable] else: body = list(body) # ty: ignore[invalid-argument-type] diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py index eead16858bf..2e9630b833b 100644 --- a/lib/streamlit/type_util.py +++ b/lib/streamlit/type_util.py @@ -331,7 +331,7 @@ def is_pydantic_model(obj: object) -> bool: return _is_type_instance(obj, "pydantic.main.BaseModel") -def is_sequence_of_pydantic_models(obj: object) -> bool: +def is_sequence_of_pydantic_models(obj: object) -> TypeGuard[Sequence[Any]]: """True if obj is a non-empty list/tuple/set/frozenset of Pydantic model instances.""" if not isinstance(obj, (list, tuple, set, frozenset)) or len(obj) == 0: return False From d8efb42c31e0031d7e815bf0e682c4ad32a9ad6f Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Mon, 15 Dec 2025 10:44:54 +0100 Subject: [PATCH 05/10] Apply feedback --- lib/streamlit/dataframe_util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/streamlit/dataframe_util.py b/lib/streamlit/dataframe_util.py index ce398b2fb82..1c68ed897d7 100644 --- a/lib/streamlit/dataframe_util.py +++ b/lib/streamlit/dataframe_util.py @@ -710,7 +710,8 @@ def convert_anything_to_pandas_df( try: first_element = next(iter(data)) if has_callable_attr(first_element, "model_dump"): - return pd.DataFrame([item.model_dump() for item in data]) + # Use mode="json" to ensure proper serialization of types like Decimal + return pd.DataFrame([item.model_dump(mode="json") for item in data]) return pd.DataFrame([item.dict() for item in data]) except AttributeError: pass From 1d90c7a898419ca772285a5322e69afc72d616e1 Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Mon, 15 Dec 2025 10:50:38 +0100 Subject: [PATCH 06/10] Update --- lib/streamlit/dataframe_util.py | 7 ++----- lib/streamlit/elements/json.py | 8 ++------ lib/streamlit/type_util.py | 10 ++++++++++ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/streamlit/dataframe_util.py b/lib/streamlit/dataframe_util.py index 1c68ed897d7..c64dc970150 100644 --- a/lib/streamlit/dataframe_util.py +++ b/lib/streamlit/dataframe_util.py @@ -41,6 +41,7 @@ from streamlit import config, errors, logger, string_util from streamlit.type_util import ( CustomDict, + dump_pydantic_model, has_callable_attr, is_custom_dict, is_dataclass_instance, @@ -708,11 +709,7 @@ def convert_anything_to_pandas_df( # Try to convert pydantic models to DataFrame. If some elements are not # pydantic models (mixed sequence), fall through to pandas' native handling. try: - first_element = next(iter(data)) - if has_callable_attr(first_element, "model_dump"): - # Use mode="json" to ensure proper serialization of types like Decimal - return pd.DataFrame([item.model_dump(mode="json") for item in data]) - return pd.DataFrame([item.dict() for item in data]) + return pd.DataFrame(dump_pydantic_model(data)) except AttributeError: pass diff --git a/lib/streamlit/elements/json.py b/lib/streamlit/elements/json.py index 532f6a0b8af..2e639055815 100644 --- a/lib/streamlit/elements/json.py +++ b/lib/streamlit/elements/json.py @@ -27,6 +27,7 @@ from streamlit.proto.Json_pb2 import Json as JsonProto from streamlit.runtime.metrics_util import gather_metrics from streamlit.type_util import ( + dump_pydantic_model, is_custom_dict, is_list_like, is_namedtuple, @@ -122,12 +123,7 @@ def json( if is_list_like(body): if is_sequence_of_pydantic_models(body): - first_item = next(iter(body)) # ty: ignore[no-matching-overload] - # Pydantic v2 uses model_dump(), v1 uses dict() - if hasattr(first_item, "model_dump"): - body = [item.model_dump() for item in body] # ty: ignore[not-iterable] - else: - body = [item.dict() for item in body] # ty: ignore[not-iterable] + body = dump_pydantic_model(body) else: body = list(body) # ty: ignore[invalid-argument-type] diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py index 2e9630b833b..4904508a4b3 100644 --- a/lib/streamlit/type_util.py +++ b/lib/streamlit/type_util.py @@ -339,6 +339,16 @@ def is_sequence_of_pydantic_models(obj: object) -> TypeGuard[Sequence[Any]]: return is_pydantic_model(first_element) +def dump_pydantic_model(obj: Sequence[Any]) -> list[dict[str, Any]]: + """Dump a Pydantic model to a dictionary.""" + first_element = next(iter(obj)) + # Pydantic v2 uses model_dump(), v1 uses dict() + if has_callable_attr(first_element, "model_dump"): + # Use mode="json" to ensure proper serialization of types like Decimal + return [item.model_dump(mode="json") for item in obj] + return [item.dict() for item in obj] + + def _is_from_streamlit(obj: object) -> bool: """True if the object is from the streamlit package.""" return obj.__class__.__module__.startswith("streamlit") From 0a7f165b06a386ff54cd05984f50f788d710a08d Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Mon, 15 Dec 2025 10:51:57 +0100 Subject: [PATCH 07/10] Update --- lib/streamlit/dataframe_util.py | 4 ++-- lib/streamlit/elements/json.py | 4 ++-- lib/streamlit/type_util.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/streamlit/dataframe_util.py b/lib/streamlit/dataframe_util.py index c64dc970150..a55a4646d53 100644 --- a/lib/streamlit/dataframe_util.py +++ b/lib/streamlit/dataframe_util.py @@ -41,7 +41,7 @@ from streamlit import config, errors, logger, string_util from streamlit.type_util import ( CustomDict, - dump_pydantic_model, + dump_pydantic_sequence, has_callable_attr, is_custom_dict, is_dataclass_instance, @@ -709,7 +709,7 @@ def convert_anything_to_pandas_df( # Try to convert pydantic models to DataFrame. If some elements are not # pydantic models (mixed sequence), fall through to pandas' native handling. try: - return pd.DataFrame(dump_pydantic_model(data)) + return pd.DataFrame(dump_pydantic_sequence(data)) except AttributeError: pass diff --git a/lib/streamlit/elements/json.py b/lib/streamlit/elements/json.py index 2e639055815..cfe52b4fcc2 100644 --- a/lib/streamlit/elements/json.py +++ b/lib/streamlit/elements/json.py @@ -27,7 +27,7 @@ from streamlit.proto.Json_pb2 import Json as JsonProto from streamlit.runtime.metrics_util import gather_metrics from streamlit.type_util import ( - dump_pydantic_model, + dump_pydantic_sequence, is_custom_dict, is_list_like, is_namedtuple, @@ -123,7 +123,7 @@ def json( if is_list_like(body): if is_sequence_of_pydantic_models(body): - body = dump_pydantic_model(body) + body = dump_pydantic_sequence(body) else: body = list(body) # ty: ignore[invalid-argument-type] diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py index 4904508a4b3..066f146572f 100644 --- a/lib/streamlit/type_util.py +++ b/lib/streamlit/type_util.py @@ -339,8 +339,8 @@ def is_sequence_of_pydantic_models(obj: object) -> TypeGuard[Sequence[Any]]: return is_pydantic_model(first_element) -def dump_pydantic_model(obj: Sequence[Any]) -> list[dict[str, Any]]: - """Dump a Pydantic model to a dictionary.""" +def dump_pydantic_sequence(obj: Sequence[Any]) -> list[dict[str, Any]]: + """Dump a sequence of Pydantic models to a list of dictionaries.""" first_element = next(iter(obj)) # Pydantic v2 uses model_dump(), v1 uses dict() if has_callable_attr(first_element, "model_dump"): From 6141e0886cae3654329a9a96ad775df0b6b8975e Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Mon, 15 Dec 2025 10:52:32 +0100 Subject: [PATCH 08/10] Update --- lib/streamlit/type_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py index 066f146572f..d4504f7b29f 100644 --- a/lib/streamlit/type_util.py +++ b/lib/streamlit/type_util.py @@ -339,7 +339,7 @@ def is_sequence_of_pydantic_models(obj: object) -> TypeGuard[Sequence[Any]]: return is_pydantic_model(first_element) -def dump_pydantic_sequence(obj: Sequence[Any]) -> list[dict[str, Any]]: +def dump_pydantic_sequence(obj: Sequence[object]) -> list[dict[str, Any]]: """Dump a sequence of Pydantic models to a list of dictionaries.""" first_element = next(iter(obj)) # Pydantic v2 uses model_dump(), v1 uses dict() From de9ddf44dff93757d399391797241022c8aa28df Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Mon, 15 Dec 2025 10:53:39 +0100 Subject: [PATCH 09/10] Fix typing --- lib/streamlit/type_util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py index d4504f7b29f..5b5fb99d7fe 100644 --- a/lib/streamlit/type_util.py +++ b/lib/streamlit/type_util.py @@ -345,8 +345,8 @@ def dump_pydantic_sequence(obj: Sequence[object]) -> list[dict[str, Any]]: # Pydantic v2 uses model_dump(), v1 uses dict() if has_callable_attr(first_element, "model_dump"): # Use mode="json" to ensure proper serialization of types like Decimal - return [item.model_dump(mode="json") for item in obj] - return [item.dict() for item in obj] + return [item.model_dump(mode="json") for item in obj] # type: ignore + return [item.dict() for item in obj] # type: ignore def _is_from_streamlit(obj: object) -> bool: From 984c9822fb234317c28a9f09437c1f638da55631 Mon Sep 17 00:00:00 2001 From: lukasmasuch Date: Mon, 15 Dec 2025 11:00:16 +0100 Subject: [PATCH 10/10] Update --- lib/streamlit/elements/json.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/streamlit/elements/json.py b/lib/streamlit/elements/json.py index cfe52b4fcc2..84165056d8e 100644 --- a/lib/streamlit/elements/json.py +++ b/lib/streamlit/elements/json.py @@ -123,7 +123,11 @@ def json( if is_list_like(body): if is_sequence_of_pydantic_models(body): - body = dump_pydantic_sequence(body) + try: + body = dump_pydantic_sequence(body) + except AttributeError: + # Fallback to list(body) if it contains non-Pydantic models: + body = list(body) # ty: ignore[invalid-argument-type] else: body = list(body) # ty: ignore[invalid-argument-type]