From f95c8e7db9143d13e6281ba130c66542e57dfb92 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 4 Nov 2025 16:33:20 +0000 Subject: [PATCH 01/11] feat: add validation of ranged numerical inputs --- tesseract_streamlit/parse.py | 14 + tesseract_streamlit/templates/template.j2 | 9 +- tests/mock-schema-fields.json | 106 ++++- tests/mock-schema.json | 533 +++++++++++++++++++++- 4 files changed, 659 insertions(+), 3 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index f521494..3b9bcfb 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -273,6 +273,9 @@ class _InputField(typing.TypedDict): description: str ancestors: list[str] default: NotRequired[typing.Any] + minimum: NotRequired[float | int] + maximum: NotRequired[float | int] + multiple_of: NotRequired[float | int] def _key_to_title(key: str) -> str: @@ -305,12 +308,17 @@ def _format_field( description=field_data.get("description", None), ancestors=[*ancestors, field_key], ) + 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): default_val = "" field["default"] = default_val + if field_data["type"] in ("number", "integer"): + field["minimum"] = field_data.get("minimum", None) + field["maximum"] = field_data.get("maximum", None) + field["multiple_of"] = 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"]): @@ -318,6 +326,9 @@ def _format_field( if _is_scalar(field_data["properties"]["shape"]): data_type = "number" field["default"] = field_data.get("default", None) + field["minimum"] = field_data.get("minimum", None) + field["maximum"] = field_data.get("maximum", None) + field["multiple_of"] = field_data.get("multipleOf", None) field["type"] = data_type return field # at this point, not an array or primitive, so must be composite @@ -404,6 +415,9 @@ class JinjaField(typing.TypedDict): description: str title: str default: NotRequired[typing.Any] + minimum: NotRequired[float | int] + maximum: NotRequired[float | int] + multiple_of: NotRequired[float | int] def _input_to_jinja(field: _InputField) -> JinjaField: diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index 8163283..4f2c3ad 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -84,7 +84,9 @@ st.markdown({% if not udf.docs %}""{% else %} {% elif field.type == 'integer' %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", - step=1, + min_value={{ field.get("minimum", None) }}, + max_value={{ field.get("maximum", None) }}, + step={{ field.get("multiple_of", 1) }}, format="%d", key="int.{{ field.key }}", value=_cast_type({{ field.get("default", None) }}, int), @@ -92,6 +94,11 @@ st.markdown({% if not udf.docs %}""{% else %} {% elif field.type == 'number' %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", + min_value={{ field.get("minimum", None) }}, + max_value={{ field.get("maximum", None) }}, + {% if field.get('multiple_of') is not none %} + step={{ field.multiple_of }}, + {% endif %} key="number.{{ field.key }}", format="%f", value=_cast_type({{ field.get("default", None) }}, float), diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index 0ee1441..bc7d228 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -1 +1,105 @@ -[{"type":"string","title":"Name","description":"Name of the person you want to greet.","ancestors":["name"],"default":"John Doe"},{"type":"integer","title":"Age","description":"Age of person in years.","ancestors":["age"],"default":30},{"type":"number","title":"Height","description":"Height of person in cm.","ancestors":["height"],"default":175.0},{"type":"boolean","title":"Alive","description":"Whether the person is (currently) alive.","ancestors":["alive"],"default":true},{"type":"number","title":"Weight","description":"The person's weight in kg.","ancestors":["weight"],"default":null},{"type":"array","title":"Leg Lengths","description":"The length of the person's left and right legs in cm.","ancestors":["leg_lengths"]},{"type":"composite","title":"Hobby","description":"The person's only hobby.","ancestors":["hobby"]},{"type":"string","title":"Name","description":"Name of the activity.","ancestors":["hobby","name"],"default":""},{"type":"boolean","title":"Active","description":"Does the person actively engage with it?","ancestors":["hobby","active"],"default":null},{"type":"integer","title":"Experience","description":"Experience practising it in years.","ancestors":["hobby","experience"],"default":null}] +[ + { + "type": "string", + "title": "Name", + "description": "Name of the person you want to greet.", + "ancestors": [ + "name" + ], + "default": "John Doe" + }, + { + "type": "integer", + "title": "Age", + "description": "Age of person in years.", + "ancestors": [ + "age" + ], + "default": 30, + "maximum": 125, + "minimum": 0, + "multiple_of": null + }, + { + "type": "number", + "title": "Height", + "description": "Height of person in cm.", + "ancestors": [ + "height" + ], + "default": 175, + "maximum": 300, + "minimum": 0, + "multiple_of": null + }, + { + "type": "boolean", + "title": "Alive", + "description": "Whether the person is (currently) alive.", + "ancestors": [ + "alive" + ], + "default": true + }, + { + "type": "number", + "title": "Weight", + "description": "The person's weight in kg.", + "ancestors": [ + "weight" + ], + "default": null, + "maximum": null, + "minimum": 0, + "multiple_of": null + }, + { + "type": "array", + "title": "Leg Lengths", + "description": "The length of the person's left and right legs in cm.", + "ancestors": [ + "leg_lengths" + ] + }, + { + "type": "composite", + "title": "Hobby", + "description": "The person's only hobby.", + "ancestors": [ + "hobby" + ] + }, + { + "type": "string", + "title": "Name", + "description": "Name of the activity.", + "ancestors": [ + "hobby", + "name" + ], + "default": "" + }, + { + "type": "boolean", + "title": "Active", + "description": "Does the person actively engage with it?", + "ancestors": [ + "hobby", + "active" + ], + "default": null + }, + { + "type": "integer", + "title": "Experience", + "description": "Experience practising it in years.", + "ancestors": [ + "hobby", + "experience" + ], + "default": null, + "maximum": 120, + "minimum": 0, + "multiple_of": null + } +] \ No newline at end of file diff --git a/tests/mock-schema.json b/tests/mock-schema.json index 14a80ea..8e74ee5 100644 --- a/tests/mock-schema.json +++ b/tests/mock-schema.json @@ -1 +1,532 @@ -{"openapi":"3.1.0","info":{"title":"goodbyeworld","version":"1.0.0"},"paths":{"/apply":{"post":{"summary":"Apply","description":"Apply the Tesseract to the input data.\n\nGreet a person whose name is given as input.","operationId":"apply_apply_post","parameters":[{"name":"accept","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accept"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyInputSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyOutputSchema"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"summary":"Health","description":"Get health status of the Tesseract instance.","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Health Health Get"}}}}}}},"/input_schema":{"get":{"summary":"Input Schema","description":"Get input schema for tesseract apply function.","operationId":"input_schema_input_schema_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Input Schema Input Schema Get"}}}}}}},"/output_schema":{"get":{"summary":"Output Schema","description":"Get output schema for tesseract apply function.","operationId":"output_schema_output_schema_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Output Schema Output Schema Get"}}}}}}}},"components":{"schemas":{"ApplyInputSchema":{"properties":{"inputs":{"$ref":"#/components/schemas/Apply_InputSchema","description":"The input data to apply the Tesseract to."}},"additionalProperties":false,"type":"object","required":["inputs"],"title":"ApplyInputSchema","differentiable_arrays":{}},"ApplyOutputSchema":{"$ref":"#/components/schemas/Apply_OutputSchema","title":"ApplyOutputSchema","description":"The output data from applying the Tesseract.","differentiable_arrays":{}},"Apply_Hobby":{"properties":{"name":{"type":"string","title":"Name","description":"Name of the activity."},"active":{"type":"boolean","title":"Active","description":"Does the person actively engage with it?"},"experience":{"type":"integer","title":"Experience","description":"Experience practising it in years."}},"additionalProperties":false,"type":"object","required":["name","active","experience"],"title":"Apply_Hobby"},"Apply_InputSchema":{"properties":{"name":{"type":"string","title":"Name","description":"Name of the person you want to greet.","default":"John Doe"},"age":{"type":"integer","title":"Age","description":"Age of person in years.","default":30},"height":{"type":"number","title":"Height","description":"Height of person in cm.","default":175.0},"alive":{"type":"boolean","title":"Alive","description":"Whether the person is (currently) alive.","default":true},"weight":{"$ref":"#/components/schemas/EncodedArrayModel__scalar__float32__noflags","title":"Weight","description":"The person's weight in kg."},"leg_lengths":{"$ref":"#/components/schemas/EncodedArrayModel__2__float32__noflags","title":"Leg Lengths","description":"The length of the person's left and right legs in cm."},"hobby":{"$ref":"#/components/schemas/Apply_Hobby","description":"The person's only hobby."}},"additionalProperties":false,"type":"object","required":["weight","leg_lengths","hobby"],"title":"Apply_InputSchema"},"Apply_OutputSchema":{"properties":{"greeting":{"type":"string","title":"Greeting","description":"A greeting!"}},"additionalProperties":false,"type":"object","required":["greeting"],"title":"Apply_OutputSchema"},"Base64ArrayData":{"properties":{"buffer":{"type":"string","title":"Buffer","description":"Base64 encoded binary buffer","examples":[""]},"encoding":{"type":"string","const":"base64","title":"Encoding"}},"additionalProperties":false,"type":"object","required":["buffer","encoding"],"title":"Base64ArrayData","description":"Data structure for base64 encoded binary buffers."},"BinrefArrayData":{"properties":{"buffer":{"type":"string","pattern":"^.+?(\\:\\d+)?$","title":"Buffer"},"encoding":{"type":"string","const":"binref","title":"Encoding"}},"additionalProperties":false,"type":"object","required":["buffer","encoding"],"title":"BinrefArrayData","description":"Data structure that dumps array data to binary file."},"EncodedArrayModel__2__float32__noflags":{"properties":{"object_type":{"type":"string","const":"array","title":"Object Type","description":"Indicates that this dict can be parsed to an array.","default":"array"},"shape":{"prefixItems":[{"type":"integer","enum":[1,2]}],"items":{"type":"integer"},"type":"array","maxItems":1,"minItems":1,"title":"Shape","description":"Shape of the array","examples":[[2]]},"dtype":{"type":"string","enum":["float16","float32","float64","int8","int16","int32","int64","bool","uint8","uint16","uint32","uint64"],"title":"Dtype","description":"Data type of the array","examples":["float32"]},"data":{"oneOf":[{"$ref":"#/components/schemas/BinrefArrayData"},{"$ref":"#/components/schemas/Base64ArrayData"},{"$ref":"#/components/schemas/JsonArrayData"}],"title":"Data","discriminator":{"propertyName":"encoding","mapping":{"base64":"#/components/schemas/Base64ArrayData","binref":"#/components/schemas/BinrefArrayData","json":"#/components/schemas/JsonArrayData"}}}},"additionalProperties":false,"type":"object","required":["shape","dtype","data"],"title":"EncodedArrayModel__2__float32__noflags","array_flags":[]},"EncodedArrayModel__scalar__float32__noflags":{"properties":{"object_type":{"type":"string","const":"array","title":"Object Type","description":"Indicates that this dict can be parsed to an array.","default":"array"},"shape":{"items":{"type":"integer"},"type":"array","maxItems":0,"minItems":0,"title":"Shape","description":"Shape of the array","examples":[[]]},"dtype":{"type":"string","enum":["float16","float32","float64","int8","int16","int32","int64","bool","uint8","uint16","uint32","uint64"],"title":"Dtype","description":"Data type of the array","examples":["float32"]},"data":{"oneOf":[{"$ref":"#/components/schemas/BinrefArrayData"},{"$ref":"#/components/schemas/Base64ArrayData"},{"$ref":"#/components/schemas/JsonArrayData"}],"title":"Data","discriminator":{"propertyName":"encoding","mapping":{"base64":"#/components/schemas/Base64ArrayData","binref":"#/components/schemas/BinrefArrayData","json":"#/components/schemas/JsonArrayData"}}}},"additionalProperties":false,"type":"object","required":["shape","dtype","data"],"title":"EncodedArrayModel__scalar__float32__noflags","array_flags":[]},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"JsonArrayData":{"properties":{"buffer":{"$ref":"#/components/schemas/JsonValue"},"encoding":{"type":"string","const":"json","title":"Encoding"}},"additionalProperties":false,"type":"object","required":["buffer","encoding"],"title":"JsonArrayData","description":"Data structure for json buffers (list of decimal numbers)."},"JsonValue":{},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}}} +{ + "openapi": "3.1.0", + "info": { + "title": "goodbyeworld", + "version": "1.0.0" + }, + "paths": { + "/apply": { + "post": { + "summary": "Apply", + "description": "Apply the Tesseract to the input data.\n\nGreet a person whose name is given as input.", + "operationId": "apply_apply_post", + "parameters": [ + { + "name": "accept", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accept" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyInputSchema" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyOutputSchema" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/health": { + "get": { + "summary": "Health", + "description": "Get health status of the Tesseract instance.", + "operationId": "health_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Health Health Get" + } + } + } + } + } + } + }, + "/input_schema": { + "get": { + "summary": "Input Schema", + "description": "Get input schema for tesseract apply function.", + "operationId": "input_schema_input_schema_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Input Schema Input Schema Get" + } + } + } + } + } + } + }, + "/output_schema": { + "get": { + "summary": "Output Schema", + "description": "Get output schema for tesseract apply function.", + "operationId": "output_schema_output_schema_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Output Schema Output Schema Get" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApplyInputSchema": { + "properties": { + "inputs": { + "$ref": "#/components/schemas/Apply_InputSchema", + "description": "The input data to apply the Tesseract to." + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "inputs" + ], + "title": "ApplyInputSchema", + "differentiable_arrays": {} + }, + "ApplyOutputSchema": { + "$ref": "#/components/schemas/Apply_OutputSchema", + "title": "ApplyOutputSchema", + "description": "The output data from applying the Tesseract.", + "differentiable_arrays": {} + }, + "Apply_Hobby": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the activity." + }, + "active": { + "type": "boolean", + "title": "Active", + "description": "Does the person actively engage with it?" + }, + "experience": { + "type": "integer", + "title": "Experience", + "description": "Experience practising it in years.", + "minimum": 0, + "maximum": 120 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "active", + "experience" + ], + "title": "Apply_Hobby" + }, + "Apply_InputSchema": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the person you want to greet.", + "default": "John Doe" + }, + "age": { + "type": "integer", + "title": "Age", + "description": "Age of person in years.", + "default": 30, + "minimum": 0, + "maximum": 125 + }, + "height": { + "type": "number", + "title": "Height", + "description": "Height of person in cm.", + "default": 175, + "minimum": 0, + "maximum": 300 + }, + "alive": { + "type": "boolean", + "title": "Alive", + "description": "Whether the person is (currently) alive.", + "default": true + }, + "weight": { + "$ref": "#/components/schemas/EncodedArrayModel__scalar__float32__noflags", + "title": "Weight", + "description": "The person's weight in kg.", + "minimum": 0 + }, + "leg_lengths": { + "$ref": "#/components/schemas/EncodedArrayModel__2__float32__noflags", + "title": "Leg Lengths", + "description": "The length of the person's left and right legs in cm." + }, + "hobby": { + "$ref": "#/components/schemas/Apply_Hobby", + "description": "The person's only hobby." + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "weight", + "leg_lengths", + "hobby" + ], + "title": "Apply_InputSchema" + }, + "Apply_OutputSchema": { + "properties": { + "greeting": { + "type": "string", + "title": "Greeting", + "description": "A greeting!" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "greeting" + ], + "title": "Apply_OutputSchema" + }, + "Base64ArrayData": { + "properties": { + "buffer": { + "type": "string", + "title": "Buffer", + "description": "Base64 encoded binary buffer", + "examples": [ + "" + ] + }, + "encoding": { + "type": "string", + "const": "base64", + "title": "Encoding" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "buffer", + "encoding" + ], + "title": "Base64ArrayData", + "description": "Data structure for base64 encoded binary buffers." + }, + "BinrefArrayData": { + "properties": { + "buffer": { + "type": "string", + "pattern": "^.+?(\\:\\d+)?$", + "title": "Buffer" + }, + "encoding": { + "type": "string", + "const": "binref", + "title": "Encoding" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "buffer", + "encoding" + ], + "title": "BinrefArrayData", + "description": "Data structure that dumps array data to binary file." + }, + "EncodedArrayModel__2__float32__noflags": { + "properties": { + "object_type": { + "type": "string", + "const": "array", + "title": "Object Type", + "description": "Indicates that this dict can be parsed to an array.", + "default": "array" + }, + "shape": { + "prefixItems": [ + { + "type": "integer", + "enum": [ + 1, + 2 + ] + } + ], + "items": { + "type": "integer" + }, + "type": "array", + "maxItems": 1, + "minItems": 1, + "title": "Shape", + "description": "Shape of the array", + "examples": [ + [ + 2 + ] + ] + }, + "dtype": { + "type": "string", + "enum": [ + "float16", + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "bool", + "uint8", + "uint16", + "uint32", + "uint64" + ], + "title": "Dtype", + "description": "Data type of the array", + "examples": [ + "float32" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BinrefArrayData" + }, + { + "$ref": "#/components/schemas/Base64ArrayData" + }, + { + "$ref": "#/components/schemas/JsonArrayData" + } + ], + "title": "Data", + "discriminator": { + "propertyName": "encoding", + "mapping": { + "base64": "#/components/schemas/Base64ArrayData", + "binref": "#/components/schemas/BinrefArrayData", + "json": "#/components/schemas/JsonArrayData" + } + } + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "shape", + "dtype", + "data" + ], + "title": "EncodedArrayModel__2__float32__noflags", + "array_flags": [] + }, + "EncodedArrayModel__scalar__float32__noflags": { + "properties": { + "object_type": { + "type": "string", + "const": "array", + "title": "Object Type", + "description": "Indicates that this dict can be parsed to an array.", + "default": "array" + }, + "shape": { + "items": { + "type": "integer" + }, + "type": "array", + "maxItems": 0, + "minItems": 0, + "title": "Shape", + "description": "Shape of the array", + "examples": [ + [] + ] + }, + "dtype": { + "type": "string", + "enum": [ + "float16", + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "bool", + "uint8", + "uint16", + "uint32", + "uint64" + ], + "title": "Dtype", + "description": "Data type of the array", + "examples": [ + "float32" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BinrefArrayData" + }, + { + "$ref": "#/components/schemas/Base64ArrayData" + }, + { + "$ref": "#/components/schemas/JsonArrayData" + } + ], + "title": "Data", + "discriminator": { + "propertyName": "encoding", + "mapping": { + "base64": "#/components/schemas/Base64ArrayData", + "binref": "#/components/schemas/BinrefArrayData", + "json": "#/components/schemas/JsonArrayData" + } + } + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "shape", + "dtype", + "data" + ], + "title": "EncodedArrayModel__scalar__float32__noflags", + "array_flags": [] + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "JsonArrayData": { + "properties": { + "buffer": { + "$ref": "#/components/schemas/JsonValue" + }, + "encoding": { + "type": "string", + "const": "json", + "title": "Encoding" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "buffer", + "encoding" + ], + "title": "JsonArrayData", + "description": "Data structure for json buffers (list of decimal numbers)." + }, + "JsonValue": {}, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file From 60a2676be9fd337cc3a53f0c7cb7b2f9e6dbdae4 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 4 Nov 2025 16:41:24 +0000 Subject: [PATCH 02/11] linting :lipstick: --- tests/mock-schema-fields.json | 2 +- tests/mock-schema.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index bc7d228..86c7bf5 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -102,4 +102,4 @@ "minimum": 0, "multiple_of": null } -] \ No newline at end of file +] diff --git a/tests/mock-schema.json b/tests/mock-schema.json index 8e74ee5..d010ace 100644 --- a/tests/mock-schema.json +++ b/tests/mock-schema.json @@ -529,4 +529,4 @@ } } } -} \ No newline at end of file +} From 24220ba10567a0ace79b41a1669067803bc8fbff Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 4 Nov 2025 23:33:52 +0000 Subject: [PATCH 03/11] rename fields and nits --- tesseract_streamlit/parse.py | 24 ++++++++++++------------ tests/mock-schema-fields.json | 24 ++++++++++++------------ tests/mock-schema.json | 3 ++- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 3b9bcfb..942b115 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -273,9 +273,9 @@ class _InputField(typing.TypedDict): description: str ancestors: list[str] default: NotRequired[typing.Any] - minimum: NotRequired[float | int] - maximum: NotRequired[float | int] - multiple_of: NotRequired[float | int] + min_value: NotRequired[float | int] + max_value: NotRequired[float | int] + step: NotRequired[float | int] def _key_to_title(key: str) -> str: @@ -316,9 +316,9 @@ def _format_field( default_val = "" field["default"] = default_val if field_data["type"] in ("number", "integer"): - field["minimum"] = field_data.get("minimum", None) - field["maximum"] = field_data.get("maximum", None) - field["multiple_of"] = field_data.get("multipleOf", None) + field["min_value"] = field_data.get("minimum", None) + field["max_value"] = field_data.get("maximum", None) + field["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"]): @@ -326,9 +326,9 @@ def _format_field( if _is_scalar(field_data["properties"]["shape"]): data_type = "number" field["default"] = field_data.get("default", None) - field["minimum"] = field_data.get("minimum", None) - field["maximum"] = field_data.get("maximum", None) - field["multiple_of"] = field_data.get("multipleOf", None) + field["min_value"] = field_data.get("minimum", None) + field["max_value"] = field_data.get("maximum", None) + field["step"] = field_data.get("multipleOf", None) field["type"] = data_type return field # at this point, not an array or primitive, so must be composite @@ -415,9 +415,9 @@ class JinjaField(typing.TypedDict): description: str title: str default: NotRequired[typing.Any] - minimum: NotRequired[float | int] - maximum: NotRequired[float | int] - multiple_of: NotRequired[float | int] + min_value: NotRequired[float | int] + max_value: NotRequired[float | int] + step: NotRequired[float | int] def _input_to_jinja(field: _InputField) -> JinjaField: diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index 86c7bf5..58be8d8 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -16,9 +16,9 @@ "age" ], "default": 30, - "maximum": 125, - "minimum": 0, - "multiple_of": null + "max_value": 125, + "min_value": 0, + "step": null }, { "type": "number", @@ -28,9 +28,9 @@ "height" ], "default": 175, - "maximum": 300, - "minimum": 0, - "multiple_of": null + "max_value": 300, + "min_value": 0, + "step": 0.1 }, { "type": "boolean", @@ -49,9 +49,9 @@ "weight" ], "default": null, - "maximum": null, - "minimum": 0, - "multiple_of": null + "max_value": null, + "min_value": 0, + "step": null }, { "type": "array", @@ -98,8 +98,8 @@ "experience" ], "default": null, - "maximum": 120, - "minimum": 0, - "multiple_of": null + "max_value": 120, + "min_value": 0, + "step": null } ] diff --git a/tests/mock-schema.json b/tests/mock-schema.json index d010ace..9dcc9ca 100644 --- a/tests/mock-schema.json +++ b/tests/mock-schema.json @@ -200,7 +200,8 @@ "description": "Height of person in cm.", "default": 175, "minimum": 0, - "maximum": 300 + "maximum": 300, + "multipleOf": 0.1 }, "alive": { "type": "boolean", From 63014620b4a6aaec85f7855dcddc25d63dfd57cf Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Tue, 4 Nov 2025 23:45:44 +0000 Subject: [PATCH 04/11] nest NumberConstraints TypedDict --- tesseract_streamlit/parse.py | 32 ++++++++++++++--------- tesseract_streamlit/templates/template.j2 | 14 +++++----- tests/mock-schema-fields.json | 32 ++++++++++++++--------- 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/tesseract_streamlit/parse.py b/tesseract_streamlit/parse.py index 942b115..606d577 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -261,6 +261,14 @@ def _is_scalar(shape_dict: dict[str, typing.Any]) -> bool: return False +class NumberConstraints(typing.TypedDict): + """Container for constraints on scalar numeric values""" + + min_value: NotRequired[float] + max_value: NotRequired[float] + step: NotRequired[float] + + class _InputField(typing.TypedDict): """Simplified schema for an input field in the Streamlit template. @@ -273,9 +281,7 @@ class _InputField(typing.TypedDict): description: str ancestors: list[str] default: NotRequired[typing.Any] - min_value: NotRequired[float | int] - max_value: NotRequired[float | int] - step: NotRequired[float | int] + number_constraints: NumberConstraints def _key_to_title(key: str) -> str: @@ -316,9 +322,11 @@ def _format_field( default_val = "" field["default"] = default_val if field_data["type"] in ("number", "integer"): - field["min_value"] = field_data.get("minimum", None) - field["max_value"] = field_data.get("maximum", None) - field["step"] = field_data.get("multipleOf", None) + 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"]): @@ -326,9 +334,11 @@ def _format_field( if _is_scalar(field_data["properties"]["shape"]): data_type = "number" field["default"] = field_data.get("default", None) - field["min_value"] = field_data.get("minimum", None) - field["max_value"] = field_data.get("maximum", None) - field["step"] = field_data.get("multipleOf", 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["type"] = data_type return field # at this point, not an array or primitive, so must be composite @@ -415,9 +425,7 @@ class JinjaField(typing.TypedDict): description: str title: str default: NotRequired[typing.Any] - min_value: NotRequired[float | int] - max_value: NotRequired[float | int] - step: NotRequired[float | int] + number_constraints: NumberConstraints def _input_to_jinja(field: _InputField) -> JinjaField: diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index 4f2c3ad..288ccb6 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -84,9 +84,9 @@ st.markdown({% if not udf.docs %}""{% else %} {% elif field.type == 'integer' %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", - min_value={{ field.get("minimum", None) }}, - max_value={{ field.get("maximum", None) }}, - step={{ field.get("multiple_of", 1) }}, + min_value={{ field.get("number_constraints", {}).get("min_value", None) }}, + max_value={{ field.get("number_constraints", {}).get("max_value", None) }}, + step={{ field.get("number_constraints", {}).get("step", 1) }}, format="%d", key="int.{{ field.key }}", value=_cast_type({{ field.get("default", None) }}, int), @@ -94,10 +94,10 @@ st.markdown({% if not udf.docs %}""{% else %} {% elif field.type == 'number' %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", - min_value={{ field.get("minimum", None) }}, - max_value={{ field.get("maximum", None) }}, - {% if field.get('multiple_of') is not none %} - step={{ field.multiple_of }}, + min_value={{ field.get("number_constraints", {}).get("min_value", None) }}, + max_value={{ field.get("number_constraints", {})get("max_value", None) }}, + {% if field.get("number_constraints", {}).get('step') is not none %} + step={{ field.number_constraints.step }}, {% endif %} key="number.{{ field.key }}", format="%f", diff --git a/tests/mock-schema-fields.json b/tests/mock-schema-fields.json index 58be8d8..76a0fdf 100644 --- a/tests/mock-schema-fields.json +++ b/tests/mock-schema-fields.json @@ -16,9 +16,11 @@ "age" ], "default": 30, - "max_value": 125, - "min_value": 0, - "step": null + "number_constraints": { + "max_value": 125, + "min_value": 0, + "step": null + } }, { "type": "number", @@ -28,9 +30,11 @@ "height" ], "default": 175, - "max_value": 300, - "min_value": 0, - "step": 0.1 + "number_constraints": { + "max_value": 300, + "min_value": 0, + "step": 0.1 + } }, { "type": "boolean", @@ -49,9 +53,11 @@ "weight" ], "default": null, - "max_value": null, - "min_value": 0, - "step": null + "number_constraints": { + "max_value": null, + "min_value": 0, + "step": null + } }, { "type": "array", @@ -98,8 +104,10 @@ "experience" ], "default": null, - "max_value": 120, - "min_value": 0, - "step": null + "number_constraints": { + "max_value": 120, + "min_value": 0, + "step": null + } } ] From 0de9188decc16432de8f59706abd3d9d65afebfa Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Wed, 5 Nov 2025 00:18:54 +0000 Subject: [PATCH 05/11] add missing dot --- tesseract_streamlit/templates/template.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index 288ccb6..3efbc6e 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -95,7 +95,7 @@ st.markdown({% if not udf.docs %}""{% else %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", min_value={{ field.get("number_constraints", {}).get("min_value", None) }}, - max_value={{ field.get("number_constraints", {})get("max_value", None) }}, + max_value={{ field.get("number_constraints", {}).get("max_value", None) }}, {% if field.get("number_constraints", {}).get('step') is not none %} step={{ field.number_constraints.step }}, {% endif %} From fcec2d20ed7d020fef4e1ff218ea5373af6577f6 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Wed, 5 Nov 2025 00:43:08 +0000 Subject: [PATCH 06/11] add missing_cast_type --- tesseract_streamlit/templates/template.j2 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index 3efbc6e..e495f1a 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -84,9 +84,9 @@ st.markdown({% if not udf.docs %}""{% else %} {% elif field.type == 'integer' %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", - min_value={{ field.get("number_constraints", {}).get("min_value", None) }}, - max_value={{ field.get("number_constraints", {}).get("max_value", None) }}, - step={{ field.get("number_constraints", {}).get("step", 1) }}, + 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), @@ -94,10 +94,10 @@ st.markdown({% if not udf.docs %}""{% else %} {% elif field.type == 'number' %} {{ field.uid }} = {{ field.container }}.number_input( "{{ field.get('title', field.uid) }}", - min_value={{ field.get("number_constraints", {}).get("min_value", None) }}, - max_value={{ field.get("number_constraints", {}).get("max_value", None) }}, + 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), {% if field.get("number_constraints", {}).get('step') is not none %} - step={{ field.number_constraints.step }}, + step=_cast_type({{ field.number_constraints.step }}, float), {% endif %} key="number.{{ field.key }}", format="%f", From a38442fae4974b8550239c0b4a4829417b82d02d Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Wed, 5 Nov 2025 00:43:26 +0000 Subject: [PATCH 07/11] add some constraints to example tesseracts --- tests/goodbyeworld/tesseract_api.py | 16 ++++++++++++---- tests/zerodim/tesseract_api.py | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/goodbyeworld/tesseract_api.py b/tests/goodbyeworld/tesseract_api.py index a23626a..bf395b6 100644 --- a/tests/goodbyeworld/tesseract_api.py +++ b/tests/goodbyeworld/tesseract_api.py @@ -8,19 +8,27 @@ class Hobby(BaseModel): name: str = Field(description="Name of the activity.") active: bool = Field(description="Does the person actively engage with it?") - experience: int = Field(description="Experience practising it in years.") + experience: int = Field(description="Experience practising it in years.", minimum=0) class InputSchema(BaseModel): name: str = Field( description="Name of the person you want to greet.", default="John Doe" ) - age: int = Field(description="Age of person in years.", default=30) - height: float = Field(description="Height of person in cm.", default=175.0) + age: int = Field( + description="Age of person in years.", default=30, minimum=0, maximum=125 + ) + height: float = Field( + description="Height of person in cm.", + default=175.0, + minimum=0, + maximum=300.0, + multiple_of=0.1, + ) alive: bool = Field( description="Whether the person is (currently) alive.", default=True ) - weight: Float32 = Field(description="The person's weight in kg.") + weight: Float32 = Field(description="The person's weight in kg.", minimum=0.0) leg_lengths: Array[(2,), Float32] = Field( description="The length of the person's left and right legs in cm." ) diff --git a/tests/zerodim/tesseract_api.py b/tests/zerodim/tesseract_api.py index b1ea934..efcaa20 100644 --- a/tests/zerodim/tesseract_api.py +++ b/tests/zerodim/tesseract_api.py @@ -11,7 +11,9 @@ class InputSchema(BaseModel): - max_num: int = Field(description="Endpoint for a sum of natural numbers.") + max_num: int = Field( + description="Endpoint for a sum of natural numbers.", minimum=0 + ) class OutputSchema(BaseModel): From c60741aa52234233a660488bdab08046a365aa24 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Wed, 5 Nov 2025 01:15:01 +0000 Subject: [PATCH 08/11] limitation for UI validation --- tests/test_cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0fcdd44..8d6835d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -38,6 +38,10 @@ def test_app(goodbyeworld_url: str) -> None: assert result.output != "" app = AppTest.from_string(result.output, default_timeout=3) app.run() + + # Note: AppTest.set_value() bypasses UI validation, so we only test valid values + # The min_value/max_value constraints are enforced by the browser UI, not Python + 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() From cf0463b3df7a7a1cc10c761c49201508944576a2 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Wed, 5 Nov 2025 01:18:11 +0000 Subject: [PATCH 09/11] added punctuation to doc string --- 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 606d577..cf1c0d8 100644 --- a/tesseract_streamlit/parse.py +++ b/tesseract_streamlit/parse.py @@ -262,7 +262,7 @@ def _is_scalar(shape_dict: dict[str, typing.Any]) -> bool: class NumberConstraints(typing.TypedDict): - """Container for constraints on scalar numeric values""" + """Container for constraints on scalar numeric values.""" min_value: NotRequired[float] max_value: NotRequired[float] From 7b82d1dfe461d4b2eae20bb8249d283b6b7c4ae3 Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Wed, 5 Nov 2025 14:58:38 +0000 Subject: [PATCH 10/11] default step to none --- tesseract_streamlit/templates/template.j2 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index e495f1a..1f9097b 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -96,9 +96,7 @@ st.markdown({% if not udf.docs %}""{% else %} "{{ field.get('title', field.uid) }}", 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), - {% if field.get("number_constraints", {}).get('step') is not none %} - step=_cast_type({{ field.number_constraints.step }}, float), - {% endif %} + 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), From cb584ecd2defd79f1fe50ac1e714c1c0875f73dc Mon Sep 17 00:00:00 2001 From: Jonathan Brodrick Date: Wed, 5 Nov 2025 15:08:27 +0000 Subject: [PATCH 11/11] test step behaviour with increment --- tests/test_cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8d6835d..43592e4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,6 +42,15 @@ def test_app(goodbyeworld_url: str) -> None: # Note: AppTest.set_value() bypasses UI validation, so we only test valid values # The min_value/max_value constraints are enforced by the browser UI, not Python + # Test manually set step + app.number_input(key="number.height").increment().run() + height_after_decrement = app.number_input(key="number.height").value + assert height_after_decrement == 175.1, ( + f"Height should be incremented in steps 0.1, but got {height_after_decrement - 175}" + ) + # reset to original default + app.number_input(key="number.height").decrement().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="int.hobby.name").input("hula hoop").run()