From 79f70c056c9f130b29a914c49e2c1bdd87e64f0d Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Fri, 14 Nov 2025 16:08:24 +0000 Subject: [PATCH 01/17] Add failing tests --- tests/goodbyeworld/tesseract_api.py | 20 ++++-- tests/mock-schema-fields.json | 97 ++++++++++++++++++++++++-- tests/mock-schema.json | 104 +++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 11 deletions(-) diff --git a/tests/goodbyeworld/tesseract_api.py b/tests/goodbyeworld/tesseract_api.py index bf395b6..b996287 100644 --- a/tests/goodbyeworld/tesseract_api.py +++ b/tests/goodbyeworld/tesseract_api.py @@ -15,7 +15,7 @@ class InputSchema(BaseModel): name: str = Field( description="Name of the person you want to greet.", default="John Doe" ) - age: int = Field( + age: int | None = Field( description="Age of person in years.", default=30, minimum=0, maximum=125 ) height: float = Field( @@ -32,7 +32,7 @@ class InputSchema(BaseModel): leg_lengths: Array[(2,), Float32] = Field( description="The length of the person's left and right legs in cm." ) - hobby: Hobby = Field(description="The person's only hobby.") + hobby: Hobby | list[Hobby] = Field(description="The person's only hobby.") class OutputSchema(BaseModel): @@ -41,16 +41,28 @@ class OutputSchema(BaseModel): def apply(inputs: InputSchema) -> OutputSchema: """Greet a person whose name is given as input.""" + if isinstance(inputs.hobby_name, Hobby): + hobby_message = f"{inputs.hobby.name} as a hobby" + elif isinstance(inputs.hobby_str, list): + hobby_message = ( + f"{', '.join(*inputs.hobby[:-1])} and f{inputs.hobby[-1]} as hobbies." + ) + + if inputs.age: + age_message = "You are {inputs.age} years old." + else: + age_message = "I understand you don't like to talk about your age, my apologies" + return OutputSchema( greeting=( - f"Hello {inputs.name}! You are {inputs.age} years old. " + f"Hello {inputs.name}! {age_message} " f"That's pretty good. Oh, so tall? {inputs.height} cm! Wow. You " f"must be very successful. " f"You are {inputs.weight} kg? That's much larger than an atom, " "and much smaller than the Sun, so pretty middling all things " f"considered. Your left leg is {inputs.leg_lengths[0]} and your " f"right leg is {inputs.leg_lengths[1]} - is that normal? " - f"Ah, I see you do {inputs.hobby.name} as a hobby! That's great. " + f"Ah, I see you do {hobby_message}! That's great. " f"You've got {inputs.hobby.experience} years of experience, and " f"you're {'' if inputs.hobby.active else 'not'} actively doing " "it. I guess that's somewhat interesting. Anyway, pretty " diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index 76a0fdf..477cdb0 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -6,7 +6,8 @@ "ancestors": [ "name" ], - "default": "John Doe" + "default": "John Doe", + "optional": false }, { "type": "integer", @@ -16,6 +17,7 @@ "age" ], "default": 30, + "optional": false, "number_constraints": { "max_value": 125, "min_value": 0, @@ -30,6 +32,7 @@ "height" ], "default": 175, + "optional": false, "number_constraints": { "max_value": 300, "min_value": 0, @@ -43,7 +46,8 @@ "ancestors": [ "alive" ], - "default": true + "default": true, + "optional": false }, { "type": "number", @@ -53,6 +57,7 @@ "weight" ], "default": null, + "optional": false, "number_constraints": { "max_value": null, "min_value": 0, @@ -65,7 +70,8 @@ "description": "The length of the person's left and right legs in cm.", "ancestors": [ "leg_lengths" - ] + ], + "optional": false }, { "type": "composite", @@ -73,7 +79,8 @@ "description": "The person's only hobby.", "ancestors": [ "hobby" - ] + ], + "optional": false }, { "type": "string", @@ -83,7 +90,8 @@ "hobby", "name" ], - "default": "" + "default": "", + "optional": false }, { "type": "boolean", @@ -93,7 +101,8 @@ "hobby", "active" ], - "default": null + "default": null, + "optional": false }, { "type": "integer", @@ -104,10 +113,86 @@ "experience" ], "default": null, + "optional": false, "number_constraints": { "max_value": 120, "min_value": 0, "step": null } + }, + { + "type": "integer", + "title": "Optional Age", + "description": "Optional age field for testing int | None union.", + "ancestors": [ + "optional_age" + ], + "default": 25, + "optional": true, + "number_constraints": { + "max_value": 125, + "min_value": 0, + "step": null + } + }, + { + "type": "string", + "title": "Optional Name", + "description": "Optional name field for testing str | None union.", + "ancestors": [ + "optional_name" + ], + "default": null, + "optional": true + }, + { + "type": "number", + "title": "Number Or Int", + "description": "Field that accepts int or float for testing number coercion.", + "ancestors": [ + "number_or_int" + ], + "default": 42, + "optional": false + }, + { + "type": "string", + "title": "Threshold", + "description": "Threshold as number or special string like 'auto' for testing float | str union.", + "ancestors": [ + "threshold" + ], + "default": null, + "optional": false + }, + { + "type": "array", + "title": "Learning Rate", + "description": "Single learning rate or schedule for testing float | list[float] union.", + "ancestors": [ + "learning_rate" + ], + "default": 0.001, + "optional": false + }, + { + "type": "string", + "title": "Hobby Union", + "description": "Single hobby or list of hobbies for testing Hobby | list[Hobby] union.", + "ancestors": [ + "hobby_union" + ], + "default": null, + "optional": false + }, + { + "type": "string", + "title": "Complex Optional", + "description": "Field accepting int, str, or None for testing complex union with optional.", + "ancestors": [ + "complex_optional" + ], + "default": null, + "optional": true } ] diff --git a/tests/mock-schema.json b/tests/mock-schema.json index 9dcc9ca..3749a4f 100644 --- a/tests/mock-schema.json +++ b/tests/mock-schema.json @@ -223,6 +223,106 @@ "hobby": { "$ref": "#/components/schemas/Apply_Hobby", "description": "The person's only hobby." + }, + "optional_age": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Optional Age", + "description": "Optional age field for testing int | None union.", + "default": 25, + "minimum": 0, + "maximum": 125 + }, + "optional_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Optional Name", + "description": "Optional name field for testing str | None union.", + "default": null + }, + "number_or_int": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + } + ], + "title": "Number Or Int", + "description": "Field that accepts int or float for testing number coercion.", + "default": 42 + }, + "threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Threshold", + "description": "Threshold as number or special string like 'auto' for testing float | str union." + }, + "learning_rate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "number" + } + } + ], + "title": "Learning Rate", + "description": "Single learning rate or schedule for testing float | list[float] union.", + "default": 0.001 + }, + "hobby_union": { + "anyOf": [ + { + "$ref": "#/components/schemas/Apply_Hobby" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Apply_Hobby" + } + } + ], + "title": "Hobby Union", + "description": "Single hobby or list of hobbies for testing Hobby | list[Hobby] union." + }, + "complex_optional": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Complex Optional", + "description": "Field accepting int, str, or None for testing complex union with optional.", + "default": null } }, "additionalProperties": false, @@ -230,7 +330,9 @@ "required": [ "weight", "leg_lengths", - "hobby" + "hobby", + "threshold", + "hobby_union" ], "title": "Apply_InputSchema" }, From ea7cf02b5129361c7d1ee7745349ee098e2190fa Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Fri, 14 Nov 2025 18:20:10 +0000 Subject: [PATCH 02/17] add support for union types, need to fix UI --- tesseract_streamlit/parse.py | 183 +++++++++++++++++++++- tesseract_streamlit/templates/template.j2 | 24 ++- tests/goodbyeworld/tesseract_api.py | 23 ++- tests/mock-schema-fields.json | 17 +- tests/test_cli.py | 2 +- tests/test_parse.py | 62 ++++++++ 6 files changed, 283 insertions(+), 28 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index cf1c0d8..863a38c 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -21,6 +21,7 @@ import importlib.util import inspect import operator +import re import sys import typing import warnings @@ -40,6 +41,8 @@ "UserDefinedFunctionError", "UserDefinedFunctionWarning", "extract_template_data", + "try_parse_number", + "parse_json_or_string", ] @@ -280,8 +283,66 @@ class _InputField(typing.TypedDict): title: str description: str ancestors: list[str] + optional: bool default: NotRequired[typing.Any] number_constraints: NumberConstraints + could_be_number: NotRequired[bool] + + +def try_parse_number(text: str) -> str | int | float: + """Try to parse string as number, fallback to string. + + Uses JSON parsing which handles integers, floats, and strings naturally. + This function is used in the Streamlit template for union types that + can accept both numbers and strings (e.g., float | str). + + Args: + text: The string to parse. + + Returns: + The parsed number (int or float) if successful, otherwise the + original string. + """ + if not text: + return text + try: + return orjson.loads(text) + except: + return text + + +def parse_json_or_string(text: str) -> typing.Any: + """Parse JSON, or auto-string simple identifiers. + + Attempts to parse input as JSON. If parsing fails, checks if the input + is a simple string identifier (contains at least one letter, only + alphanumeric characters, spaces, hyphens, and underscores). If so, + returns it as a string. Otherwise, re-raises the JSON parsing error. + + This function is used in the Streamlit template for the json field type, + which is used for complex unions like Hobby | list[Hobby]. + + Args: + text: The string to parse. + + Returns: + The parsed JSON value, or the string if it matches simple identifier + pattern, or None if text is empty. + + Raises: + Exception: If text is not valid JSON and doesn't match the simple + identifier pattern. + """ + if not text: + return None + try: + return orjson.loads(text) + except: + # Auto-string: ≥1 letter, only alphanumeric+space+dash+underscore + # Rejects: pure numbers, JSON punctuation ([]{},"':) + if re.match(r'^(?=.*[a-zA-Z])[a-zA-Z0-9_\s-]+$', text): + return text + raise # Re-raise for malformed JSON def _key_to_title(key: str) -> str: @@ -289,6 +350,92 @@ 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, bool]: + """Resolve a union type (anyOf) to a single type. + + Args: + field_data: dictionary of data representing the field with anyOf. + + Returns: + tuple[str, bool, bool]: (resolved_type, is_optional, could_be_number) + + Resolution rules: + 1. If null is in union, remove it and set is_optional=True + 2. If any member has $ref, resolve to "json" + 3. If only int/float types remain, resolve to "number" + 4. If array + only int/float, resolve to "array" + 5. If has number + other non-composite types, resolve to "string" with could_be_number=True + 6. Otherwise resolve to "string" with could_be_number=False + """ + any_of = field_data.get("anyOf", []) + + # Collect type information from union members + types = [] + has_composite = False + has_number = False + + for member in any_of: + if "type" in member: + member_type = member["type"] + types.append(member_type) + if member_type in ("integer", "number"): + has_number = True + elif "$ref" in member: + # Complex type (object reference) + has_composite = True + + # Remove null type and determine if optional + is_optional = "null" in types + types = [t for t in types if t != "null"] + + # Apply resolution rules + if has_composite: + # Rule: Has $ref → json type + return ("json", is_optional, False) + + if len(types) == 0: + # Only had null type - this should not be possible in valid OpenAPI + raise ValueError( + "Union type (anyOf) cannot contain only null type. " + f"Field data: {field_data}" + ) + + if len(types) == 1: + # Only one type after removing null - preserve the specific type + single_type = types[0] + return (single_type, is_optional, False) + + # Multiple types remaining + # Check if only int/float + if set(types) <= {"integer", "number"}: + return ("number", is_optional, False) + + # Check if array + only int/float + if "array" in types: + non_array_types = [t for t in types if t != "array"] + if set(non_array_types) <= {"integer", "number"}: + return ("array", is_optional, False) + + # Has number + other types → string with could_be_number + if has_number: + return ("string", is_optional, True) + + # Default fallback + return ("string", is_optional, False) + + def _format_field( field_key: str, field_data: dict[str, typing.Any], @@ -308,25 +455,47 @@ def _format_field( Returns: Formatted input field data. """ + # Handle union types (anyOf) + is_optional = False + could_be_number = False + if _is_union_type(field_data): + resolved_type, is_optional, could_be_number = _resolve_union_type(field_data) + # Inject resolved type into field_data so rest of function works normally + field_data = {**field_data, "type": resolved_type} + 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, ) + # Add could_be_number for string types + if field["type"] == "string": + field["could_be_number"] = could_be_number + 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-union strings, convert None default to empty string + # But for union-resolved strings, preserve None + if ( + (field_data["type"] == "string") + and (default_val is None) + and not could_be_number + 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"]): @@ -424,8 +593,10 @@ class JinjaField(typing.TypedDict): type: str description: str title: str + optional: bool default: NotRequired[typing.Any] number_constraints: NumberConstraints + could_be_number: NotRequired[bool] def _input_to_jinja(field: _InputField) -> JinjaField: diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index 1f9097b..bf0f4ca 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -10,6 +10,7 @@ import numpy as np import orjson import streamlit as st from tesseract_core import Tesseract +from tesseract_streamlit.parse import parse_json_or_string, try_parse_number {% if needs_pyvista %} import multiprocessing @@ -76,11 +77,16 @@ st.markdown({% if not udf.docs %}""{% else %} {% endif %} {# identify which type of Streamlit element to render based on field type #} {% if field.type == 'string' %} -{{ field.uid }} = {{ field.container }}.text_input( +{{ field.uid }}_raw = {{ field.container }}.text_input( "{{ field.get('title', field.uid) }}", - key="int.{{ field.key }}", + key="string.{{ field.key }}", value="{{ field.get('default', '') }}", ) +{% if field.get('could_be_number', False) %} +{{ field.uid }} = try_parse_number({{ field.uid }}_raw) +{% else %} +{{ field.uid }} = {{ field.uid }}_raw +{% endif %} {% elif field.type == 'integer' %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", @@ -142,6 +148,20 @@ if {{ field.uid }} is not None: {{ field.container }}.code(arr) except Exception as e: {{ field.container }}.error(f"Invalid array input for {{ field.uid }}: {e}") +{% elif field.type == 'json' %} +# JSON input (with auto-quoting for simple strings) +{{ field.container }}.markdown("**Enter JSON or simple value:**") +{{ field.uid }}_text = {{ field.container }}.text_area( + "Paste {{ field.uid.replace("_", ".") }} JSON or value", + height=100, + key="textarea.{{ field.key }}" +) +{{ field.uid }} = None +if {{ field.uid }}_text: + try: + {{ field.uid }} = parse_json_or_string({{ field.uid }}_text) + except Exception as e: + {{ field.container }}.error(f"Failed to parse {{ field.uid }}: {e}") {% endif %} {% endfor %} diff --git a/tests/goodbyeworld/tesseract_api.py b/tests/goodbyeworld/tesseract_api.py index b996287..5f2215f 100644 --- a/tests/goodbyeworld/tesseract_api.py +++ b/tests/goodbyeworld/tesseract_api.py @@ -12,7 +12,7 @@ class Hobby(BaseModel): class InputSchema(BaseModel): - name: str = Field( + name: str | list[str] = Field( description="Name of the person you want to greet.", default="John Doe" ) age: int | None = Field( @@ -32,7 +32,7 @@ class InputSchema(BaseModel): leg_lengths: Array[(2,), Float32] = Field( description="The length of the person's left and right legs in cm." ) - hobby: Hobby | list[Hobby] = Field(description="The person's only hobby.") + hobby: Hobby = Field(description="The person's only hobby.") class OutputSchema(BaseModel): @@ -41,32 +41,29 @@ class OutputSchema(BaseModel): def apply(inputs: InputSchema) -> OutputSchema: """Greet a person whose name is given as input.""" - if isinstance(inputs.hobby_name, Hobby): - hobby_message = f"{inputs.hobby.name} as a hobby" - elif isinstance(inputs.hobby_str, list): - hobby_message = ( - f"{', '.join(*inputs.hobby[:-1])} and f{inputs.hobby[-1]} as hobbies." - ) + if isinstance(inputs.name, str): + names = inputs.name + elif isinstance(inputs.name, list): + names = f"{', '.join(*inputs.name[:-1])} and {inputs.name[-1]}" if inputs.age: - age_message = "You are {inputs.age} years old." + age_message = f"You are {inputs.age} years old." else: age_message = "I understand you don't like to talk about your age, my apologies" return OutputSchema( greeting=( - f"Hello {inputs.name}! {age_message} " + f"Hello {names}! {age_message} " f"That's pretty good. Oh, so tall? {inputs.height} cm! Wow. You " f"must be very successful. " f"You are {inputs.weight} kg? That's much larger than an atom, " "and much smaller than the Sun, so pretty middling all things " f"considered. Your left leg is {inputs.leg_lengths[0]} and your " f"right leg is {inputs.leg_lengths[1]} - is that normal? " - f"Ah, I see you do {hobby_message}! That's great. " + f"Ah, I see you do {inputs.hobby.name} as a hobby! That's great. " f"You've got {inputs.hobby.experience} years of experience, and " f"you're {'' if inputs.hobby.active else 'not'} actively doing " "it. I guess that's somewhat interesting. Anyway, pretty " + ("cool you're alive." if inputs.alive else "sad you're dead.") - ), - dummy=list(range(100)), + ) ) diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index 477cdb0..f7a098c 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -7,7 +7,8 @@ "name" ], "default": "John Doe", - "optional": false + "optional": false, + "could_be_number": false }, { "type": "integer", @@ -91,7 +92,8 @@ "name" ], "default": "", - "optional": false + "optional": false, + "could_be_number": false }, { "type": "boolean", @@ -143,7 +145,8 @@ "optional_name" ], "default": null, - "optional": true + "optional": true, + "could_be_number": false }, { "type": "number", @@ -163,7 +166,8 @@ "threshold" ], "default": null, - "optional": false + "optional": false, + "could_be_number": true }, { "type": "array", @@ -176,7 +180,7 @@ "optional": false }, { - "type": "string", + "type": "json", "title": "Hobby Union", "description": "Single hobby or list of hobbies for testing Hobby | list[Hobby] union.", "ancestors": [ @@ -193,6 +197,7 @@ "complex_optional" ], "default": null, - "optional": true + "optional": true, + "could_be_number": true } ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 43592e4..c964fda 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,7 +53,7 @@ def test_app(goodbyeworld_url: str) -> None: app.number_input(key="number.weight").set_value(83.0).run() app.text_area(key="textarea.leg_lengths").input("[100.0, 100.0]").run() - app.text_input(key="int.hobby.name").input("hula hoop").run() + app.text_input(key="string.hobby.name").input("hula hoop").run() app.checkbox(key="boolean.hobby.active").check().run() app.number_input(key="int.hobby.experience").set_value(3).run() app.button[0].click().run() diff --git a/tests/test_parse.py b/tests/test_parse.py index d37d58f..465bc94 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -7,6 +7,7 @@ import pyvista as pv from tesseract_streamlit import parse +from tesseract_streamlit.parse import parse_json_or_string, try_parse_number PARENT_DIR = Path(__file__).parent @@ -155,3 +156,64 @@ def test_description_from_oas( f"Apply the Tesseract to the input data.\n\n{zerodim_apply_docstring}" ) assert zd_descr == zd_apply_docs + + +# Testing template helper functions for union type parsing: +# =========================================================== + + +def test_try_parse_number() -> None: + """Test try_parse_number helper function.""" + # Empty string + assert try_parse_number("") == "" + + # Numbers + assert try_parse_number("42") == 42 + assert try_parse_number("-23") == -23 + assert try_parse_number("3.14") == 3.14 + assert try_parse_number("0") == 0 + assert try_parse_number("-0.5") == -0.5 + + # Strings + assert try_parse_number("hello") == "hello" + assert try_parse_number("hello123") == "hello123" + assert try_parse_number("auto") == "auto" + assert try_parse_number("hello world") == "hello world" + + +def test_parse_json_or_string() -> None: + """Test parse_json_or_string helper function.""" + # Empty string + assert parse_json_or_string("") is None + + # Valid JSON + assert parse_json_or_string('{"key":"value"}') == {"key": "value"} + assert parse_json_or_string("[1,2,3]") == [1, 2, 3] + assert parse_json_or_string("42") == 42 + assert parse_json_or_string("true") is True + assert parse_json_or_string("null") is None + + # Auto-string: simple identifiers + assert parse_json_or_string("hello") == "hello" + assert parse_json_or_string("hello world") == "hello world" + assert parse_json_or_string("my-value_123") == "my-value_123" + assert parse_json_or_string("foo bar") == "foo bar" + assert parse_json_or_string("test-case") == "test-case" + assert parse_json_or_string("value_123") == "value_123" + + # Invalid JSON that should raise (has non-allowed punctuation) + with pytest.raises(Exception): + parse_json_or_string("[hello") + + with pytest.raises(Exception): + parse_json_or_string("hello,world") + + with pytest.raises(Exception): + parse_json_or_string("{incomplete") + + with pytest.raises(Exception): + parse_json_or_string('"quoted') + + # Pure numbers should parse as numbers, not strings + assert parse_json_or_string("123") == 123 + assert parse_json_or_string("-456") == -456 From 2c7ffac2f2aebe1d3ede7924626979b2489a927c Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Fri, 14 Nov 2025 20:47:35 +0000 Subject: [PATCH 03/17] use checkboxes for optional inputs --- tesseract_streamlit/templates/template.j2 | 82 +++++++++++++++++------ tests/goodbyeworld/tesseract_api.py | 4 +- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index bf0f4ca..d438ce1 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -59,6 +59,34 @@ st.markdown({% if not udf.docs %}""{% else %} {% endif %} {%- endmacro %} +{#- wraps a field with optional checkbox if field is optional. + args: + field: the field to potentially wrap + outputs: either wrapped field with checkbox or unwrapped field +-#} +{% macro optional_field_wrapper(field) -%} +{% if field.get('optional', False) -%} +{# Optional field: add checkbox on left, input on right (same line) #} +col1_{{ field.uid }}, col2_{{ field.uid }} = {{ field.container }}.columns([1, 11]) +with col1_{{ field.uid }}: + {{ field.uid }}_enabled = st.checkbox( + "", + key="enabled.{{ field.key }}", + value={{ field.get("default", None) is not none }}, + help="Check to include this field", + ) +with col2_{{ field.uid }}: +{{ caller() | indent(4, first=True) }} +{# After rendering the widget, conditionally set to None if disabled #} +if not {{ field.uid }}_enabled: + {{ field.uid }} = None +{% else -%} +{# Required field: render in container context #} +with {{ field.container }}: +{{ caller() | indent(4, first=True) }} +{% endif -%} +{% endmacro %} + {#- renders the streamlit form elements for each field. args: schema: the schema for the Streamlit form @@ -68,17 +96,20 @@ st.markdown({% if not udf.docs %}""{% else %} {# AUTOMATICALLY POPULATING THE WEB-APP WITH INPUTS FROM THE SCHEMA ================================================================ #} {% for field in schema %} +{# prepare display title with (optional) suffix if needed #} +{% set display_title = field.get('title', field.uid) + (' (optional)' if field.get('optional', False) else '') %} {# first, instantiate the container to hold the field #} {{ field.container }} = {{ field.parent_container }}.container(border=True) {# next, add subheading and descriptive text #} -{{ field.container }}.subheader("{{ field.title }}") +{{ field.container }}.subheader("{{ display_title }}") {% if field.description %} {{ field.container }}.caption("{{ field.description }}") {% endif %} {# identify which type of Streamlit element to render based on field type #} {% if field.type == 'string' %} -{{ field.uid }}_raw = {{ field.container }}.text_input( - "{{ field.get('title', field.uid) }}", +{% call optional_field_wrapper(field) %} +{{ field.uid }}_raw = st.text_input( + "", key="string.{{ field.key }}", value="{{ field.get('default', '') }}", ) @@ -87,9 +118,11 @@ st.markdown({% if not udf.docs %}""{% else %} {% else %} {{ field.uid }} = {{ field.uid }}_raw {% endif %} +{% endcall %} {% elif field.type == 'integer' %} -{{ field.uid }} = {{ field.container }}.number_input( - "{{ field.get('title', field.uid) }}", +{% call optional_field_wrapper(field) %} +{{ field.uid }} = st.number_input( + "", min_value=_cast_type({{ field.get("number_constraints", {}).get("min_value", None) }}, int), max_value=_cast_type({{ field.get("number_constraints", {}).get("max_value", None) }}, int), step=_cast_type({{ field.get("number_constraints", {}).get("step", 1) }}, int), @@ -97,9 +130,11 @@ st.markdown({% if not udf.docs %}""{% else %} key="int.{{ field.key }}", value=_cast_type({{ field.get("default", None) }}, int), ) +{% endcall %} {% elif field.type == 'number' %} -{{ field.uid }} = {{ field.container }}.number_input( - "{{ field.get('title', field.uid) }}", +{% call optional_field_wrapper(field) %} +{{ field.uid }} = st.number_input( + "", min_value=_cast_type({{ field.get("number_constraints", {}).get("min_value", None) }}, float), max_value=_cast_type({{ field.get("number_constraints", {}).get("max_value", None) }}, float), step=_cast_type({{ field.get("number_constraints", {}).get("step", None) }}, float), @@ -107,22 +142,26 @@ st.markdown({% if not udf.docs %}""{% else %} format="%f", value=_cast_type({{ field.get("default", None) }}, float), ) +{% endcall %} {% elif field.type == 'boolean' %} -{{ field.uid }} = {{ field.container }}.checkbox( - "{{ field.get('title', field.uid) }}", +{% call optional_field_wrapper(field) %} +{{ field.uid }} = st.checkbox( + "{{ display_title }}", key="boolean.{{ field.key }}", value={{ field.get("default", None) }}, ) +{% endcall %} {# array inputs are more complex, and require both text and file inputs #} {% elif field.type == 'array' %} +{% call optional_field_wrapper(field) %} # Array input -{{ field.container }}.markdown("**Enter JSON array or upload file:**") -{{ field.uid }}_text = {{ field.container }}.text_area( +st.markdown("**Enter JSON array or upload file:**") +{{ field.uid }}_text = st.text_area( "Paste {{ field.uid.replace("_", ".") }} JSON", height=100, key="textarea.{{ field.key }}" ) -{{ field.uid }}_file = {{ field.container }}.file_uploader( +{{ field.uid }}_file = st.file_uploader( "Upload {{ field.uid.replace("_", ".") }} JSON file", type=["json", "txt"], key="fileupload.{{ field.key }}" @@ -134,24 +173,26 @@ if {{ field.uid }}_file is not None: try: {{ field.uid }} = orjson.loads({{ field.uid }}_file.getvalue()) except Exception as e: - {{ field.container }}.error(f"Failed to load {{ field.uid }} from file: {e}") + st.error(f"Failed to load {{ field.uid }} from file: {e}") elif {{ field.uid }}_text: try: {{ field.uid }} = orjson.loads({{ field.uid }}_text) except Exception as e: - {{ field.container }}.error(f"Failed to parse {{ field.uid }} from text: {e}") + st.error(f"Failed to parse {{ field.uid }} from text: {e}") if {{ field.uid }} is not None: try: arr = np.array({{ field.uid }}, dtype=np.float64) - {{ field.container }}.write("Array preview:") - {{ field.container }}.code(arr) + st.write("Array preview:") + st.code(arr) except Exception as e: - {{ field.container }}.error(f"Invalid array input for {{ field.uid }}: {e}") + st.error(f"Invalid array input for {{ field.uid }}: {e}") +{% endcall %} {% elif field.type == 'json' %} +{% call optional_field_wrapper(field) %} # JSON input (with auto-quoting for simple strings) -{{ field.container }}.markdown("**Enter JSON or simple value:**") -{{ field.uid }}_text = {{ field.container }}.text_area( +st.markdown("**Enter JSON or simple value:**") +{{ field.uid }}_text = st.text_area( "Paste {{ field.uid.replace("_", ".") }} JSON or value", height=100, key="textarea.{{ field.key }}" @@ -161,7 +202,8 @@ if {{ field.uid }}_text: try: {{ field.uid }} = parse_json_or_string({{ field.uid }}_text) except Exception as e: - {{ field.container }}.error(f"Failed to parse {{ field.uid }}: {e}") + st.error(f"Failed to parse {{ field.uid }}: {e}") +{% endcall %} {% endif %} {% endfor %} diff --git a/tests/goodbyeworld/tesseract_api.py b/tests/goodbyeworld/tesseract_api.py index 5f2215f..12aa3a8 100644 --- a/tests/goodbyeworld/tesseract_api.py +++ b/tests/goodbyeworld/tesseract_api.py @@ -49,7 +49,9 @@ def apply(inputs: InputSchema) -> OutputSchema: if inputs.age: age_message = f"You are {inputs.age} years old." else: - age_message = "I understand you don't like to talk about your age, my apologies" + age_message = ( + "I understand you don't like to talk about your age, my apologies." + ) return OutputSchema( greeting=( From 4da8b53eaab7ad3721df8276246e0309e1181916 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 18 Nov 2025 14:47:50 +0000 Subject: [PATCH 04/17] make try_parse_number private --- tesseract_streamlit/parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 863a38c..a903e5d 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -289,7 +289,7 @@ class _InputField(typing.TypedDict): could_be_number: NotRequired[bool] -def try_parse_number(text: str) -> str | int | float: +def _try_parse_number(text: str) -> str | int | float: """Try to parse string as number, fallback to string. Uses JSON parsing which handles integers, floats, and strings naturally. From 3c84f279c8df1e89ce5b1988a3334f027cc7b171 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Sat, 22 Nov 2025 02:07:28 +0000 Subject: [PATCH 05/17] Incorporate review feedback --- README.md | 31 +++ tesseract_streamlit/parse.py | 145 ++----------- tesseract_streamlit/templates/template.j2 | 238 +++++++++++----------- tests/goodbyeworld/tesseract_api.py | 2 +- tests/mock-schema-fields.json | 39 +--- tests/mock-schema.json | 34 +--- tests/test_cli.py | 1 + tests/test_parse.py | 56 ----- 8 files changed, 178 insertions(+), 368 deletions(-) diff --git a/README.md b/README.md index 279c776..2301435 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +### 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. + ## ⚠️ 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: diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 863a38c..aa1230c 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -286,63 +286,6 @@ class _InputField(typing.TypedDict): optional: bool default: NotRequired[typing.Any] number_constraints: NumberConstraints - could_be_number: NotRequired[bool] - - -def try_parse_number(text: str) -> str | int | float: - """Try to parse string as number, fallback to string. - - Uses JSON parsing which handles integers, floats, and strings naturally. - This function is used in the Streamlit template for union types that - can accept both numbers and strings (e.g., float | str). - - Args: - text: The string to parse. - - Returns: - The parsed number (int or float) if successful, otherwise the - original string. - """ - if not text: - return text - try: - return orjson.loads(text) - except: - return text - - -def parse_json_or_string(text: str) -> typing.Any: - """Parse JSON, or auto-string simple identifiers. - - Attempts to parse input as JSON. If parsing fails, checks if the input - is a simple string identifier (contains at least one letter, only - alphanumeric characters, spaces, hyphens, and underscores). If so, - returns it as a string. Otherwise, re-raises the JSON parsing error. - - This function is used in the Streamlit template for the json field type, - which is used for complex unions like Hobby | list[Hobby]. - - Args: - text: The string to parse. - - Returns: - The parsed JSON value, or the string if it matches simple identifier - pattern, or None if text is empty. - - Raises: - Exception: If text is not valid JSON and doesn't match the simple - identifier pattern. - """ - if not text: - return None - try: - return orjson.loads(text) - except: - # Auto-string: ≥1 letter, only alphanumeric+space+dash+underscore - # Rejects: pure numbers, JSON punctuation ([]{},"':) - if re.match(r'^(?=.*[a-zA-Z])[a-zA-Z0-9_\s-]+$', text): - return text - raise # Re-raise for malformed JSON def _key_to_title(key: str) -> str: @@ -362,78 +305,40 @@ def _is_union_type(field_data: dict[str, typing.Any]) -> bool: return "anyOf" in field_data and "type" not in field_data -def _resolve_union_type(field_data: dict[str, typing.Any]) -> tuple[str, bool, bool]: - """Resolve a union type (anyOf) to a single type. +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, bool]: (resolved_type, is_optional, could_be_number) - - Resolution rules: - 1. If null is in union, remove it and set is_optional=True - 2. If any member has $ref, resolve to "json" - 3. If only int/float types remain, resolve to "number" - 4. If array + only int/float, resolve to "array" - 5. If has number + other non-composite types, resolve to "string" with could_be_number=True - 6. Otherwise resolve to "string" with could_be_number=False + tuple[str, bool]: (resolved_type, is_optional) + + Resolution logic: + - If union is `Type | None`, return (Type, True) + - If union has multiple non-null types, return ("union", is_optional) """ any_of = field_data.get("anyOf", []) - # Collect type information from union members + # Collect non-null types types = [] - has_composite = False - has_number = False - for member in any_of: if "type" in member: member_type = member["type"] - types.append(member_type) - if member_type in ("integer", "number"): - has_number = True - elif "$ref" in member: - # Complex type (object reference) - has_composite = True - - # Remove null type and determine if optional - is_optional = "null" in types - types = [t for t in types if t != "null"] - - # Apply resolution rules - if has_composite: - # Rule: Has $ref → json type - return ("json", is_optional, False) - - if len(types) == 0: - # Only had null type - this should not be possible in valid OpenAPI - raise ValueError( - "Union type (anyOf) cannot contain only null type. " - f"Field data: {field_data}" - ) + if member_type != "null": + types.append(member_type) - if len(types) == 1: - # Only one type after removing null - preserve the specific type - single_type = types[0] - return (single_type, is_optional, False) - - # Multiple types remaining - # Check if only int/float - if set(types) <= {"integer", "number"}: - return ("number", is_optional, False) - - # Check if array + only int/float - if "array" in types: - non_array_types = [t for t in types if t != "array"] - if set(non_array_types) <= {"integer", "number"}: - return ("array", is_optional, False) + # Check if null is in the union + is_optional = any( + member.get("type") == "null" for member in any_of if "type" in member + ) - # Has number + other types → string with could_be_number - if has_number: - return ("string", is_optional, True) + # If only one non-null type, return it (e.g., int | None → integer) + if len(types) == 1: + return (types[0], is_optional) - # Default fallback - return ("string", is_optional, False) + # Multiple non-null types → union + return ("union", is_optional) def _format_field( @@ -457,9 +362,8 @@ def _format_field( """ # Handle union types (anyOf) is_optional = False - could_be_number = False if _is_union_type(field_data): - resolved_type, is_optional, could_be_number = _resolve_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} @@ -471,19 +375,13 @@ def _format_field( optional=is_optional, ) - # Add could_be_number for string types - if field["type"] == "string": - field["could_be_number"] = could_be_number - if "properties" not in field_data: # signals a Python primitive type if field["type"] != "object": default_val = field_data.get("default", None) - # For non-union strings, convert None default to empty string - # But for union-resolved strings, preserve None + # For non-optional strings, convert None default to empty string if ( (field_data["type"] == "string") and (default_val is None) - and not could_be_number and not is_optional ): default_val = "" @@ -596,7 +494,6 @@ class JinjaField(typing.TypedDict): optional: bool default: NotRequired[typing.Any] number_constraints: NumberConstraints - could_be_number: NotRequired[bool] def _input_to_jinja(field: _InputField) -> JinjaField: diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index d438ce1..070b302 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -10,7 +10,6 @@ import numpy as np import orjson import streamlit as st from tesseract_core import Tesseract -from tesseract_streamlit.parse import parse_json_or_string, try_parse_number {% if needs_pyvista %} import multiprocessing @@ -59,34 +58,6 @@ st.markdown({% if not udf.docs %}""{% else %} {% endif %} {%- endmacro %} -{#- wraps a field with optional checkbox if field is optional. - args: - field: the field to potentially wrap - outputs: either wrapped field with checkbox or unwrapped field --#} -{% macro optional_field_wrapper(field) -%} -{% if field.get('optional', False) -%} -{# Optional field: add checkbox on left, input on right (same line) #} -col1_{{ field.uid }}, col2_{{ field.uid }} = {{ field.container }}.columns([1, 11]) -with col1_{{ field.uid }}: - {{ field.uid }}_enabled = st.checkbox( - "", - key="enabled.{{ field.key }}", - value={{ field.get("default", None) is not none }}, - help="Check to include this field", - ) -with col2_{{ field.uid }}: -{{ caller() | indent(4, first=True) }} -{# After rendering the widget, conditionally set to None if disabled #} -if not {{ field.uid }}_enabled: - {{ field.uid }} = None -{% else -%} -{# Required field: render in container context #} -with {{ field.container }}: -{{ caller() | indent(4, first=True) }} -{% endif -%} -{% endmacro %} - {#- renders the streamlit form elements for each field. args: schema: the schema for the Streamlit form @@ -106,104 +77,99 @@ with {{ field.container }}: {{ field.container }}.caption("{{ field.description }}") {% endif %} {# identify which type of Streamlit element to render based on field type #} -{% if field.type == 'string' %} -{% call optional_field_wrapper(field) %} -{{ field.uid }}_raw = st.text_input( - "", - key="string.{{ field.key }}", - value="{{ field.get('default', '') }}", -) -{% if field.get('could_be_number', False) %} -{{ field.uid }} = try_parse_number({{ field.uid }}_raw) -{% else %} -{{ field.uid }} = {{ field.uid }}_raw -{% endif %} -{% endcall %} +{# All string fields and optional numeric fields use text_input with same pattern #} +{% if field.type == 'string' or (field.get('optional', False) and field.type in ['integer', 'number']) %} +with {{ field.container }}: + {% set key_prefix = 'string' if field.type == 'string' else ('int' if field.type == 'integer' else 'number') %} + {% set label = display_title if field.type == 'string' else field.get('title', field.uid) %} + {% set default_val = field.get('default', '') if (field.type == 'string' or field.get('default') is none) else field.get('default', '') %} + {{ field.uid }}_raw = st.text_input( + "{{ label }}", + label_visibility="collapsed", + key="{{ key_prefix }}.{{ field.key }}", + value="{{ default_val }}", + placeholder="{{ 'Leave blank if not needed' if field.get('optional', False) else '' }}", + ) + {{ field.uid }} = {{ field.uid }}_raw if {{ field.uid }}_raw else None +{# Required numeric fields use number_input with constraints #} {% elif field.type == 'integer' %} -{% call optional_field_wrapper(field) %} -{{ field.uid }} = st.number_input( - "", - min_value=_cast_type({{ field.get("number_constraints", {}).get("min_value", None) }}, int), - max_value=_cast_type({{ field.get("number_constraints", {}).get("max_value", None) }}, int), - step=_cast_type({{ field.get("number_constraints", {}).get("step", 1) }}, int), - format="%d", - key="int.{{ field.key }}", - value=_cast_type({{ field.get("default", None) }}, int), -) -{% endcall %} +with {{ field.container }}: + {{ field.uid }} = st.number_input( + "{{ field.get('title', field.uid) }}", + label_visibility="collapsed", + min_value=_cast_type({{ field.get("number_constraints", {}).get("min_value", None) }}, int), + max_value=_cast_type({{ field.get("number_constraints", {}).get("max_value", None) }}, int), + step=_cast_type({{ field.get("number_constraints", {}).get("step", 1) }}, int), + format="%d", + key="int.{{ field.key }}", + value=_cast_type({{ field.get("default", None) }}, int), + ) {% elif field.type == 'number' %} -{% call optional_field_wrapper(field) %} -{{ field.uid }} = st.number_input( - "", - min_value=_cast_type({{ field.get("number_constraints", {}).get("min_value", None) }}, float), - max_value=_cast_type({{ field.get("number_constraints", {}).get("max_value", None) }}, float), - step=_cast_type({{ field.get("number_constraints", {}).get("step", None) }}, float), - key="number.{{ field.key }}", - format="%f", - value=_cast_type({{ field.get("default", None) }}, float), -) -{% endcall %} +with {{ field.container }}: + {{ field.uid }} = st.number_input( + "{{ field.get('title', field.uid) }}", + label_visibility="collapsed", + min_value=_cast_type({{ field.get("number_constraints", {}).get("min_value", None) }}, float), + max_value=_cast_type({{ field.get("number_constraints", {}).get("max_value", None) }}, float), + step=_cast_type({{ field.get("number_constraints", {}).get("step", None) }}, float), + key="number.{{ field.key }}", + format="%f", + value=_cast_type({{ field.get("default", None) }}, float), + ) {% elif field.type == 'boolean' %} -{% call optional_field_wrapper(field) %} -{{ field.uid }} = st.checkbox( - "{{ display_title }}", - key="boolean.{{ field.key }}", - value={{ field.get("default", None) }}, -) -{% endcall %} +with {{ field.container }}: + {{ field.uid }} = st.checkbox( + "{{ display_title }}", + key="boolean.{{ field.key }}", + value={{ field.get("default", None) }}, + ) {# array inputs are more complex, and require both text and file inputs #} {% elif field.type == 'array' %} -{% call optional_field_wrapper(field) %} -# Array input -st.markdown("**Enter JSON array or upload file:**") -{{ field.uid }}_text = st.text_area( - "Paste {{ field.uid.replace("_", ".") }} JSON", - height=100, - key="textarea.{{ field.key }}" -) -{{ field.uid }}_file = st.file_uploader( - "Upload {{ field.uid.replace("_", ".") }} JSON file", - type=["json", "txt"], - key="fileupload.{{ field.key }}" -) -{# we also validate the JSON in try / except blocks, and alert the user - using Streamlit's built-in "error" function. #} -{{ field.uid }} = None -if {{ field.uid }}_file is not None: - try: - {{ field.uid }} = orjson.loads({{ field.uid }}_file.getvalue()) - except Exception as e: - st.error(f"Failed to load {{ field.uid }} from file: {e}") -elif {{ field.uid }}_text: - try: - {{ field.uid }} = orjson.loads({{ field.uid }}_text) - except Exception as e: - st.error(f"Failed to parse {{ field.uid }} from text: {e}") - -if {{ field.uid }} is not None: - try: - arr = np.array({{ field.uid }}, dtype=np.float64) - st.write("Array preview:") - st.code(arr) - except Exception as e: - st.error(f"Invalid array input for {{ field.uid }}: {e}") -{% endcall %} -{% elif field.type == 'json' %} -{% call optional_field_wrapper(field) %} -# JSON input (with auto-quoting for simple strings) -st.markdown("**Enter JSON or simple value:**") -{{ field.uid }}_text = st.text_area( - "Paste {{ field.uid.replace("_", ".") }} JSON or value", - height=100, - key="textarea.{{ field.key }}" -) -{{ field.uid }} = None -if {{ field.uid }}_text: - try: - {{ field.uid }} = parse_json_or_string({{ field.uid }}_text) - except Exception as e: - st.error(f"Failed to parse {{ field.uid }}: {e}") -{% endcall %} +with {{ field.container }}: + # Array input + st.markdown("**Enter JSON array or upload file:**") + {{ field.uid }}_text = st.text_area( + "Paste {{ field.uid.replace("_", ".") }} JSON", + height=100, + key="textarea.{{ field.key }}", + placeholder="{{ 'Leave blank if not needed' if field.get('optional', False) else '' }}", + ) + {{ field.uid }}_file = st.file_uploader( + "Upload {{ field.uid.replace("_", ".") }} JSON file", + type=["json", "txt"], + key="fileupload.{{ field.key }}" + ) + {# we also validate the JSON in try / except blocks, and alert the user + using Streamlit's built-in "error" function. #} + {{ field.uid }} = None + if {{ field.uid }}_file is not None: + try: + {{ field.uid }} = orjson.loads({{ field.uid }}_file.getvalue()) + except Exception as e: + st.error(f"Failed to load {{ field.uid }} from file: {e}") + elif {{ field.uid }}_text: + try: + {{ field.uid }} = orjson.loads({{ field.uid }}_text) + except Exception as e: + st.error(f"Failed to parse {{ field.uid }} from text: {e}") + + if {{ field.uid }} is not None: + try: + arr = np.array({{ field.uid }}, dtype=np.float64) + st.write("Array preview:") + st.code(arr) + except Exception as e: + st.error(f"Invalid array input for {{ field.uid }}: {e}") +{% elif field.type == 'union' %} +with {{ field.container }}: + {{ field.uid }}_raw = st.text_input( + "{{ field.get('title', field.uid) }}", + label_visibility="collapsed", + key="union.{{ field.key }}", + value="{{ field.get('default', '') }}", + placeholder="{{ 'Leave blank if not needed' if field.get('optional', False) else '' }}", + ) + {{ field.uid }} = _autocast({{ field.uid }}_raw) {% endif %} {% endfor %} @@ -277,6 +243,34 @@ def _cast_type(val: T | None, type_: type[T]) -> T | None: return type_(val) +def _autocast(text: str) -> typing.Any: + """Auto-cast text input to int, float, or string. + + Attempts to parse text as integer (if no decimal point or scientific notation), + then float, then falls back to string if both fail. + + Args: + text: The string to parse. + + Returns: + Parsed value as int, float, or string. Returns None for empty strings. + """ + if not text: + return None + + text = text.strip() + + try: + # Try int first (no decimal point or scientific notation) + if '.' not in text and 'e' not in text.lower(): + return int(text) + # Try float + return float(text) + except ValueError: + # Not a number, return as string + return text + + def _insert_nested( dict_node: dict[str, typing.Any], keys: list[str], diff --git a/tests/goodbyeworld/tesseract_api.py b/tests/goodbyeworld/tesseract_api.py index 12aa3a8..c1a19bb 100644 --- a/tests/goodbyeworld/tesseract_api.py +++ b/tests/goodbyeworld/tesseract_api.py @@ -16,7 +16,7 @@ class InputSchema(BaseModel): description="Name of the person you want to greet.", default="John Doe" ) age: int | None = Field( - description="Age of person in years.", default=30, minimum=0, maximum=125 + description="Age of person in years.", minimum=0, maximum=125 ) height: float = Field( description="Height of person in cm.", diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index f7a098c..b278310 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -7,8 +7,7 @@ "name" ], "default": "John Doe", - "optional": false, - "could_be_number": false + "optional": false }, { "type": "integer", @@ -92,8 +91,7 @@ "name" ], "default": "", - "optional": false, - "could_be_number": false + "optional": false }, { "type": "boolean", @@ -145,11 +143,10 @@ "optional_name" ], "default": null, - "optional": true, - "could_be_number": false + "optional": true }, { - "type": "number", + "type": "union", "title": "Number Or Int", "description": "Field that accepts int or float for testing number coercion.", "ancestors": [ @@ -159,45 +156,23 @@ "optional": false }, { - "type": "string", + "type": "union", "title": "Threshold", "description": "Threshold as number or special string like 'auto' for testing float | str union.", "ancestors": [ "threshold" ], "default": null, - "optional": false, - "could_be_number": true - }, - { - "type": "array", - "title": "Learning Rate", - "description": "Single learning rate or schedule for testing float | list[float] union.", - "ancestors": [ - "learning_rate" - ], - "default": 0.001, - "optional": false - }, - { - "type": "json", - "title": "Hobby Union", - "description": "Single hobby or list of hobbies for testing Hobby | list[Hobby] union.", - "ancestors": [ - "hobby_union" - ], - "default": null, "optional": false }, { - "type": "string", + "type": "union", "title": "Complex Optional", "description": "Field accepting int, str, or None for testing complex union with optional.", "ancestors": [ "complex_optional" ], "default": null, - "optional": true, - "could_be_number": true + "optional": true } ] diff --git a/tests/mock-schema.json b/tests/mock-schema.json index 3749a4f..6aebc64 100644 --- a/tests/mock-schema.json +++ b/tests/mock-schema.json @@ -277,37 +277,6 @@ "title": "Threshold", "description": "Threshold as number or special string like 'auto' for testing float | str union." }, - "learning_rate": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "array", - "items": { - "type": "number" - } - } - ], - "title": "Learning Rate", - "description": "Single learning rate or schedule for testing float | list[float] union.", - "default": 0.001 - }, - "hobby_union": { - "anyOf": [ - { - "$ref": "#/components/schemas/Apply_Hobby" - }, - { - "type": "array", - "items": { - "$ref": "#/components/schemas/Apply_Hobby" - } - } - ], - "title": "Hobby Union", - "description": "Single hobby or list of hobbies for testing Hobby | list[Hobby] union." - }, "complex_optional": { "anyOf": [ { @@ -331,8 +300,7 @@ "weight", "leg_lengths", "hobby", - "threshold", - "hobby_union" + "threshold" ], "title": "Apply_InputSchema" }, diff --git a/tests/test_cli.py b/tests/test_cli.py index c964fda..7d799e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -51,6 +51,7 @@ def test_app(goodbyeworld_url: str) -> None: # reset to original default app.number_input(key="number.height").decrement().run() + app.text_input(key="int.age").input("30").run() app.number_input(key="number.weight").set_value(83.0).run() app.text_area(key="textarea.leg_lengths").input("[100.0, 100.0]").run() app.text_input(key="string.hobby.name").input("hula hoop").run() diff --git a/tests/test_parse.py b/tests/test_parse.py index 465bc94..10d9ebf 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -7,7 +7,6 @@ import pyvista as pv from tesseract_streamlit import parse -from tesseract_streamlit.parse import parse_json_or_string, try_parse_number PARENT_DIR = Path(__file__).parent @@ -162,58 +161,3 @@ def test_description_from_oas( # =========================================================== -def test_try_parse_number() -> None: - """Test try_parse_number helper function.""" - # Empty string - assert try_parse_number("") == "" - - # Numbers - assert try_parse_number("42") == 42 - assert try_parse_number("-23") == -23 - assert try_parse_number("3.14") == 3.14 - assert try_parse_number("0") == 0 - assert try_parse_number("-0.5") == -0.5 - - # Strings - assert try_parse_number("hello") == "hello" - assert try_parse_number("hello123") == "hello123" - assert try_parse_number("auto") == "auto" - assert try_parse_number("hello world") == "hello world" - - -def test_parse_json_or_string() -> None: - """Test parse_json_or_string helper function.""" - # Empty string - assert parse_json_or_string("") is None - - # Valid JSON - assert parse_json_or_string('{"key":"value"}') == {"key": "value"} - assert parse_json_or_string("[1,2,3]") == [1, 2, 3] - assert parse_json_or_string("42") == 42 - assert parse_json_or_string("true") is True - assert parse_json_or_string("null") is None - - # Auto-string: simple identifiers - assert parse_json_or_string("hello") == "hello" - assert parse_json_or_string("hello world") == "hello world" - assert parse_json_or_string("my-value_123") == "my-value_123" - assert parse_json_or_string("foo bar") == "foo bar" - assert parse_json_or_string("test-case") == "test-case" - assert parse_json_or_string("value_123") == "value_123" - - # Invalid JSON that should raise (has non-allowed punctuation) - with pytest.raises(Exception): - parse_json_or_string("[hello") - - with pytest.raises(Exception): - parse_json_or_string("hello,world") - - with pytest.raises(Exception): - parse_json_or_string("{incomplete") - - with pytest.raises(Exception): - parse_json_or_string('"quoted') - - # Pure numbers should parse as numbers, not strings - assert parse_json_or_string("123") == 123 - assert parse_json_or_string("-456") == -456 From 56f3ab7a3d3153f3cecde580eb6c83fc5c2244e1 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Sat, 22 Nov 2025 04:04:35 +0000 Subject: [PATCH 06/17] Bring back array casting behavioru --- tesseract_streamlit/parse.py | 9 +++++++++ tests/mock-schema-fields.json | 10 ++++++++++ tests/mock-schema.json | 18 +++++++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index aa1230c..f8ed808 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -316,6 +316,7 @@ def _resolve_union_type(field_data: dict[str, typing.Any]) -> tuple[str, bool]: 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) """ any_of = field_data.get("anyOf", []) @@ -337,6 +338,14 @@ def _resolve_union_type(field_data: dict[str, typing.Any]) -> tuple[str, bool]: 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) diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index b278310..66edc6f 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -174,5 +174,15 @@ ], "default": null, "optional": true + }, + { + "type": "array", + "title": "Scalar Or Array", + "description": "Field accepting either a single integer or an array of integers for testing int | list[int] union.", + "ancestors": [ + "scalar_or_array" + ], + "optional": false, + "default": null } ] diff --git a/tests/mock-schema.json b/tests/mock-schema.json index 6aebc64..35093f9 100644 --- a/tests/mock-schema.json +++ b/tests/mock-schema.json @@ -292,6 +292,21 @@ "title": "Complex Optional", "description": "Field accepting int, str, or None for testing complex union with optional.", "default": null + }, + "scalar_or_array": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ], + "title": "Scalar Or Array", + "description": "Field accepting either a single integer or an array of integers for testing int | list[int] union." } }, "additionalProperties": false, @@ -300,7 +315,8 @@ "weight", "leg_lengths", "hobby", - "threshold" + "threshold", + "scalar_or_array" ], "title": "Apply_InputSchema" }, From 97af5f87703ea7046a97d41d9b7b612dc878ac33 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 25 Nov 2025 10:58:21 +0000 Subject: [PATCH 07/17] raise errors for unsupported types --- tesseract_streamlit/parse.py | 23 ++++++++++++++++++++ tests/test_parse.py | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index f8ed808..29c10a1 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -318,9 +318,23 @@ def _resolve_union_type(field_data: dict[str, typing.Any]) -> tuple[str, bool]: - 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: @@ -376,6 +390,15 @@ def _format_field( # 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, diff --git a/tests/test_parse.py b/tests/test_parse.py index 10d9ebf..fab09c9 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -161,3 +161,45 @@ def test_description_from_oas( # =========================================================== +def test_union_with_composite_raises_error() -> None: + """Test that unions including composite types raise ValueError.""" + # Schema with int | Model union (composite in union) + schema_with_union_composite = { + "properties": { + "field": { + "anyOf": [ + {"type": "integer"}, + {"$ref": "#/components/schemas/SomeModel"} + ], + "title": "Field" + } + } + } + + with pytest.raises( + ValueError, + match=r"Unions including composite types.*not currently supported" + ): + parse._simplify_schema(schema_with_union_composite["properties"]) + + +def test_collection_of_composites_raises_error() -> None: + """Test that collections of composite types raise ValueError.""" + # Schema with list[Model] (collection of composites) + schema_with_collection_composite = { + "properties": { + "models": { + "type": "array", + "items": {"$ref": "#/components/schemas/SomeModel"}, + "title": "Models" + } + } + } + + with pytest.raises( + ValueError, + match=r"Collections of composite types.*not currently supported" + ): + parse._simplify_schema(schema_with_collection_composite["properties"]) + + From 90b2bad7f6e24d3b60588709ee352aa694c09a8f Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 25 Nov 2025 11:15:31 +0000 Subject: [PATCH 08/17] fix linting errors --- tesseract_streamlit/parse.py | 4 +--- tests/test_parse.py | 14 +++++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index cedda4e..8c0796b 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -40,8 +40,6 @@ "UserDefinedFunctionError", "UserDefinedFunctionWarning", "extract_template_data", - "parse_json_or_string", - "try_parse_number", ] @@ -203,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. diff --git a/tests/test_parse.py b/tests/test_parse.py index fab09c9..8bdc61c 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -169,16 +169,15 @@ def test_union_with_composite_raises_error() -> None: "field": { "anyOf": [ {"type": "integer"}, - {"$ref": "#/components/schemas/SomeModel"} + {"$ref": "#/components/schemas/SomeModel"}, ], - "title": "Field" + "title": "Field", } } } with pytest.raises( - ValueError, - match=r"Unions including composite types.*not currently supported" + ValueError, match=r"Unions including composite types.*not currently supported" ): parse._simplify_schema(schema_with_union_composite["properties"]) @@ -191,15 +190,12 @@ def test_collection_of_composites_raises_error() -> None: "models": { "type": "array", "items": {"$ref": "#/components/schemas/SomeModel"}, - "title": "Models" + "title": "Models", } } } with pytest.raises( - ValueError, - match=r"Collections of composite types.*not currently supported" + ValueError, match=r"Collections of composite types.*not currently supported" ): parse._simplify_schema(schema_with_collection_composite["properties"]) - - From a6276660fa38af5df12874fa8bd0f6d2f6ff939a Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 25 Nov 2025 13:24:05 +0000 Subject: [PATCH 09/17] bring back the G :dog: --- tesseract_streamlit/parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 8c0796b..c20c494 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -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, yieldin a dictionary. This dictionary + dictionary is resolved, yielding a dictionary. This dictionary is then merged with the parent dictionary where the '$ref' key was located. From f889daddfd843b2a923a6a926f1e0684a58135af Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Wed, 26 Nov 2025 19:39:35 +0000 Subject: [PATCH 10/17] docs: docstring Google style guide updates --- tesseract_streamlit/parse.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index c20c494..c908139 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -306,15 +306,17 @@ 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. + field_data: Node in OAS representing a field with key "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) + 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) From fe319e2e109ee56f1054d1068bf4fdcf3af7da35 Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Wed, 26 Nov 2025 19:43:13 +0000 Subject: [PATCH 11/17] refactor: make supported types explicit, add check for composite types --- tesseract_streamlit/parse.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index c908139..5a6efbc 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -302,7 +302,18 @@ def _is_union_type(field_data: dict[str, typing.Any]) -> bool: return "anyOf" in field_data and "type" not in field_data -def _resolve_union_type(field_data: dict[str, typing.Any]) -> tuple[str, bool]: +_SupportedTypes: typing.TypeAlias = typing.Literal[ + "integer", "number", "boolean", "string", "union", "array" +] + + +def _is_composite(member: dict[str, typing.Any]) -> bool: + 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: From 6fea286b7e715dbb00c6d7c98b96ca7bb88bcd12 Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Wed, 26 Nov 2025 19:45:04 +0000 Subject: [PATCH 12/17] refactor: use sets explicitly, replace comprehensions with one loop --- tesseract_streamlit/parse.py | 51 ++++++++++++++---------------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 5a6efbc..1c7ea3a 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -334,44 +334,33 @@ def _resolve_union_type( """ 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 = [] + types = set() + is_optional = False 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 _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 only one non-null type, return it (e.g., int | None → integer) if len(types) == 1: - return (types[0], is_optional) + return types.pop(), 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) + non_array_types = types - {"array"} + is_all_numeric = non_array_types == {"integer", "number"} - if has_array and is_all_numeric and non_array_types: - return ("array", is_optional) + if has_array and is_all_numeric: + return "array", is_optional - # Multiple non-null types → union - return ("union", is_optional) + return "union", is_optional def _format_field( From f8a5723b47020912c9301fb66e8c6569f2d82efe Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Wed, 26 Nov 2025 19:47:22 +0000 Subject: [PATCH 13/17] chore: reduce repetition for composite checking --- tesseract_streamlit/parse.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 1c7ea3a..b8cc3c4 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -391,8 +391,7 @@ def _format_field( # 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): + 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." From 41405f25969dc73e6b0523c4748a6e0461857bf8 Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Wed, 26 Nov 2025 20:12:50 +0000 Subject: [PATCH 14/17] docs: add docstring to _is_composite --- tesseract_streamlit/parse.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index b8cc3c4..8e1da4e 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -308,6 +308,7 @@ def _is_union_type(field_data: dict[str, typing.Any]) -> bool: 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) From 937c2d27fe280e7b9a8fabf5faace3c0e2c1853a Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Wed, 26 Nov 2025 20:14:30 +0000 Subject: [PATCH 15/17] refactor: explicitly call NumberConstraints constructor --- tesseract_streamlit/parse.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 8e1da4e..bbdbfba 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -418,14 +418,15 @@ def _format_field( default_val = "" field["default"] = default_val # Only add number_constraints if constraints actually exist - if field_data["type"] in ("number", "integer"): - 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), - } + if field_data["type"] in {"number", "integer"}: + if {"minimum", "maximum", "multipleOf"}.intersection(field_data): + field["number_constraints"] = NumberConstraints( + 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"]): data_type = "array" From ddd6b7c413db70f385a1a2ff9380ec5d837be869 Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Wed, 26 Nov 2025 20:48:32 +0000 Subject: [PATCH 16/17] fix: check is_all_numeric as a subset of numeric types, not equal sets --- tesseract_streamlit/parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index bbdbfba..0fcb1fd 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -356,7 +356,7 @@ def _resolve_union_type( has_array = "array" in types non_array_types = types - {"array"} - is_all_numeric = non_array_types == {"integer", "number"} + is_all_numeric = non_array_types <= {"integer", "number"} if has_array and is_all_numeric: return "array", is_optional From 6507ffc67a9197cddec0a6984c6760d728beab8a Mon Sep 17 00:00:00 2001 From: Jacan Chaplais Date: Thu, 27 Nov 2025 10:14:19 +0000 Subject: [PATCH 17/17] fix: create func to init NumberConstraints and comply with typing The type hints were not being respected. NotRequired fields are no longer treated as optional. --- tesseract_streamlit/parse.py | 32 +++++++++++++++++++++++--------- tests/mock-schema-fields.json | 13 ++++--------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 0fcb1fd..4f8bad8 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -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. @@ -420,10 +440,8 @@ def _format_field( # 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"] = NumberConstraints( - 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 ) return field @@ -433,11 +451,7 @@ def _format_field( 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 diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index 66edc6f..28012d7 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -20,8 +20,7 @@ "optional": false, "number_constraints": { "max_value": 125, - "min_value": 0, - "step": null + "min_value": 0 } }, { @@ -59,9 +58,7 @@ "default": null, "optional": false, "number_constraints": { - "max_value": null, - "min_value": 0, - "step": null + "min_value": 0 } }, { @@ -116,8 +113,7 @@ "optional": false, "number_constraints": { "max_value": 120, - "min_value": 0, - "step": null + "min_value": 0 } }, { @@ -131,8 +127,7 @@ "optional": true, "number_constraints": { "max_value": 125, - "min_value": 0, - "step": null + "min_value": 0 } }, {