Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
111 changes: 104 additions & 7 deletions tesseract_streamlit/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def _resolve_refs(

Recursively descends into the nested dictionary, locating '$ref'
keys. Where these are found, the URI path to elsewhere in the
dictionary is resolved, yielding a dictionary. This dictionary
dictionary is resolved, yieldin a dictionary. This dictionary
is then merged with the parent dictionary where the '$ref' key was
located.

Expand Down Expand Up @@ -280,6 +280,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 +290,77 @@ 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


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

Args:
field_data: dictionary of data representing the field with anyOf.

Returns:
tuple[str, bool]: (resolved_type, is_optional)

Resolution logic:
- If union is `Type | None`, return (Type, True)
- If union is numeric primitives + arrays, return ("array", is_optional)
- If union has multiple non-null types, return ("union", is_optional)

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

# Check for composite types in union (unsupported)
has_composite = any(
"$ref" in member or ("properties" in member and "type" not in member)
for member in any_of
)
if has_composite:
raise ValueError(
"Unions including composite types (e.g., Model | int, Model1 | Model2) "
"are not currently supported. Use one of the composite types directly."
)

# Collect non-null types
types = []
for member in any_of:
if "type" in member:
member_type = member["type"]
if member_type != "null":
types.append(member_type)

# Check if null is in the union
is_optional = any(
member.get("type") == "null" for member in any_of if "type" in member
)

# If only one non-null type, return it (e.g., int | None → integer)
if len(types) == 1:
return (types[0], is_optional)

# Check if union is numeric primitives + arrays (e.g., float | list[float])
non_array_types = [t for t in types if t != "array"]
has_array = "array" in types
is_all_numeric = all(t in ("integer", "number") for t in non_array_types)

if has_array and is_all_numeric and non_array_types:
return ("array", is_optional)

# Multiple non-null types → union
return ("union", is_optional)


def _format_field(
field_key: str,
field_data: dict[str, typing.Any],
Expand All @@ -308,25 +380,49 @@ 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":
items = field_data.get("items", {})
if "$ref" in items or ("properties" in items and "type" not in 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
# Only add number_constraints if constraints actually exist
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),
}
if any(k in field_data for k in ("minimum", "maximum", "multipleOf")):
field["number_constraints"] = {
"min_value": field_data.get("minimum", None),
"max_value": field_data.get("maximum", None),
"step": field_data.get("multipleOf", None),
}
return field
field["title"] = _key_to_title(field_key) if use_title else field_key
if ARRAY_PROPS <= set(field_data["properties"]):
Expand Down Expand Up @@ -424,6 +520,7 @@ class JinjaField(typing.TypedDict):
type: str
description: str
title: str
optional: bool
default: NotRequired[typing.Any]
number_constraints: NumberConstraints

Expand Down
Loading