From 79f70c056c9f130b29a914c49e2c1bdd87e64f0d Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Fri, 14 Nov 2025 16:08:24 +0000 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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.