Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
79f70c0
Add failing tests
jpbrodrick89 Nov 14, 2025
ea7cf02
add support for union types, need to fix UI
jpbrodrick89 Nov 14, 2025
2c7ffac
use checkboxes for optional inputs
jpbrodrick89 Nov 14, 2025
4da8b53
make try_parse_number private
jpbrodrick89 Nov 18, 2025
3c84f27
Incorporate review feedback
jpbrodrick89 Nov 22, 2025
56f3ab7
Bring back array casting behavioru
jpbrodrick89 Nov 22, 2025
97af5f8
raise errors for unsupported types
jpbrodrick89 Nov 25, 2025
3925f31
Merge remote-tracking branch 'origin' into jpb/support-anyOf
jpbrodrick89 Nov 25, 2025
0f43227
Merge remote-tracking branch 'origin/jpb/support-anyOf' into jpb/supp…
jpbrodrick89 Nov 25, 2025
90b2bad
fix linting errors
jpbrodrick89 Nov 25, 2025
a627666
bring back the G :dog:
jpbrodrick89 Nov 25, 2025
f889dad
docs: docstring Google style guide updates
jacanchaplais Nov 26, 2025
fe319e2
refactor: make supported types explicit, add check for composite types
jacanchaplais Nov 26, 2025
6fea286
refactor: use sets explicitly, replace comprehensions with one loop
jacanchaplais Nov 26, 2025
f8a5723
chore: reduce repetition for composite checking
jacanchaplais Nov 26, 2025
41405f2
docs: add docstring to _is_composite
jacanchaplais Nov 26, 2025
937c2d2
refactor: explicitly call NumberConstraints constructor
jacanchaplais Nov 26, 2025
ddd6b7c
fix: check is_all_numeric as a subset of numeric types, not equal sets
jacanchaplais Nov 26, 2025
6507ffc
fix: create func to init NumberConstraints and comply with typing
jacanchaplais Nov 27, 2025
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,37 @@ This will open a browser window with the Streamlit UI where users can input valu
| --------------------------------- | ---------------------------- |
| ![](examples/vectoradd_jax/screenshots/vec-b.png) | ![](examples/vectoradd_jax/screenshots/plot.png) |

## 🔀 Union Type Support

`tesseract-streamlit` supports Pydantic union types (e.g., `int | str`, `float | None`) with automatic type inference:

### Supported Union Patterns

* **Optional numbers**: `int | None`, `float | None` - Rendered as text inputs (not number inputs) with placeholder text "Leave blank if not needed". This allows the field to truly be empty. The input is parsed as an integer or float when provided.
* **Optional strings**: `str | None` - Rendered as text inputs with placeholder text. Leave blank to pass `None`.
* **Mixed scalar types**: `int | float`, `float | str`, `int | str | None` - Rendered as text inputs with automatic type casting.

### Auto-Casting Behavior

Union type fields use a text input with automatic type detection:

1. **Integers**: Input like `42` or `-23` is parsed as `int`
2. **Floats**: Input like `3.14`, `-0.5`, or `1e-5` is parsed as `float`
3. **Strings**: Any other input is treated as a string
4. **None**: Leaving the field blank (for optional fields) passes `None`

**Example:**
```python
class InputSchema(BaseModel):
threshold: float | str # Can accept 0.5 (float) or "auto" (string)
count: int | None = None # Optional integer, blank input passes None
```
Comment on lines +127 to +151
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the attention to keeping the docs up to date! I do think this section is a bit gratuitous, though. I think a simple sentence somewhere to say that union and optional types in InputSchema are supported would be sufficient. The rest should just be intuitive from using the web UI with the placeholder text, etc.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another solution could be changing the "Current limitations" section to something more positive like "Supported features and limitations", since that's basically what it is anyway. Then you could add a bullet or two there, as you see fit.


### Limitations

* **Collections of complex types**: Unions like `Model | list[Model]` or `float | list[float]` are not yet supported. These are deferred to a future release.
* **No specialized widgets**: Union fields always use text inputs. For example, `int | float` uses a text input instead of a number input with spinners.
Comment on lines +153 to +156
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you shift this down and merge it with the existing "Current limitations" section?


## ⚠️ Current Limitations

While `tesseract-streamlit` supports Tesseracts with an `InputSchema` formed with arbitrary nesting of Pydantic models, it **does not yet support** nesting Pydantic models **inside native Python collection types** such as:
Expand Down
138 changes: 126 additions & 12 deletions tesseract_streamlit/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,26 @@ class NumberConstraints(typing.TypedDict):
step: NotRequired[float]


def _num_constraints_from_field(field_data: dict[str, typing.Any]) -> NumberConstraints:
"""Initialise and populate NumberConstraints object from field data.

Args:
field_data: dictionary of data representing the field.

Returns:
Dictionary representing range and increments of valid number
values for an input field.
"""
number_constraints = NumberConstraints()
if (min_value := field_data.get("minimum", None)) is not None:
number_constraints["min_value"] = min_value
if (max_value := field_data.get("maximum", None)) is not None:
number_constraints["max_value"] = max_value
if (step := field_data.get("multipleOf", None)) is not None:
number_constraints["step"] = step
return number_constraints


class _InputField(typing.TypedDict):
"""Simplified schema for an input field in the Streamlit template.

Expand All @@ -280,6 +300,7 @@ class _InputField(typing.TypedDict):
title: str
description: str
ancestors: list[str]
optional: bool
default: NotRequired[typing.Any]
number_constraints: NumberConstraints

Expand All @@ -289,6 +310,80 @@ def _key_to_title(key: str) -> str:
return key.replace("_", " ").title()


def _is_union_type(field_data: dict[str, typing.Any]) -> bool:
"""Check if a field uses union type (anyOf).

Args:
field_data: dictionary of data representing the field.

Returns:
True if the field uses anyOf (union type), False otherwise.
"""
return "anyOf" in field_data and "type" not in field_data


_SupportedTypes: typing.TypeAlias = typing.Literal[
"integer", "number", "boolean", "string", "union", "array"
]


def _is_composite(member: dict[str, typing.Any]) -> bool:
"""Checks if a node in the OAS represents a composite type."""
return "$ref" in member or ("properties" in member and "type" not in member)


def _resolve_union_type(
field_data: dict[str, typing.Any],
) -> tuple[_SupportedTypes, bool]:
"""Resolve union type, preserving base type for simple optionals.

Args:
field_data: Node in OAS representing a field with key "anyOf".

Returns:
resolved_type:
String representation of the type's name. For optional
types, this will simply be the name of the non-null type,
*eg.* `boolean | null` will return "boolean". For unions
between `array` and `number` or `integer`, this returns
"array". All other combinations of types return `union`.
is_optional:
Boolean determining if the union contains `null`.

Raises:
ValueError: If union includes composite types (not supported)
"""
any_of = field_data.get("anyOf", [])

types = set()
is_optional = False
for member in any_of:
if _is_composite(member):
raise ValueError(
"Unions including composite types (e.g., Model | int, Model1 "
"| Model2) are not currently supported. Use one of the "
"composite types directly."
)
if "type" not in member:
continue
if member["type"] == "null":
is_optional = True
continue
types.add(member["type"])

if len(types) == 1:
return types.pop(), is_optional

has_array = "array" in types
non_array_types = types - {"array"}
is_all_numeric = non_array_types <= {"integer", "number"}

if has_array and is_all_numeric:
return "array", is_optional

return "union", is_optional


def _format_field(
field_key: str,
field_data: dict[str, typing.Any],
Expand All @@ -308,37 +403,55 @@ def _format_field(
Returns:
Formatted input field data.
"""
# Handle union types (anyOf)
is_optional = False
if _is_union_type(field_data):
resolved_type, is_optional = _resolve_union_type(field_data)
# Inject resolved type into field_data so rest of function works normally
field_data = {**field_data, "type": resolved_type}

# Check for collections of composite types (unsupported)
if field_data.get("type") == "array":
if _is_composite(field_data.get("items", {})):
raise ValueError(
f"Collections of composite types (e.g., list[Model]) are not currently "
f"supported for field '{field_key}'. Consider restructuring your schema."
)

field = _InputField(
type=field_data["type"],
title=field_data.get("title", field_key) if use_title else field_key,
description=field_data.get("description", None),
ancestors=[*ancestors, field_key],
optional=is_optional,
)

if "properties" not in field_data: # signals a Python primitive type
if field["type"] != "object":
default_val = field_data.get("default", None)
if (field_data["type"] == "string") and (default_val is None):
# For non-optional strings, convert None default to empty string
if (
(field_data["type"] == "string")
and (default_val is None)
and not is_optional
):
default_val = ""
field["default"] = default_val
if field_data["type"] in ("number", "integer"):
field["number_constraints"] = {
"min_value": field_data.get("minimum", None),
"max_value": field_data.get("maximum", None),
"step": field_data.get("multipleOf", None),
}
# Only add number_constraints if constraints actually exist
if field_data["type"] in {"number", "integer"}:
if {"minimum", "maximum", "multipleOf"}.intersection(field_data):
field["number_constraints"] = _num_constraints_from_field(
field_data
)
return field

field["title"] = _key_to_title(field_key) if use_title else field_key
if ARRAY_PROPS <= set(field_data["properties"]):
data_type = "array"
if _is_scalar(field_data["properties"]["shape"]):
data_type = "number"
field["default"] = field_data.get("default", None)
field["number_constraints"] = {
"min_value": field_data.get("minimum", None),
"max_value": field_data.get("maximum", None),
"step": field_data.get("multipleOf", None),
}
field["number_constraints"] = _num_constraints_from_field(field_data)
field["type"] = data_type
return field
# at this point, not an array or primitive, so must be composite
Expand Down Expand Up @@ -424,6 +537,7 @@ class JinjaField(typing.TypedDict):
type: str
description: str
title: str
optional: bool
default: NotRequired[typing.Any]
number_constraints: NumberConstraints

Expand Down
Loading