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 cf1c0d8..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. @@ -280,6 +300,7 @@ class _InputField(typing.TypedDict): title: str description: str ancestors: list[str] + optional: bool default: NotRequired[typing.Any] number_constraints: NumberConstraints @@ -289,6 +310,80 @@ def _key_to_title(key: str) -> str: return key.replace("_", " ").title() +def _is_union_type(field_data: dict[str, typing.Any]) -> bool: + """Check if a field uses union type (anyOf). + + Args: + field_data: dictionary of data representing the field. + + Returns: + True if the field uses anyOf (union type), False otherwise. + """ + return "anyOf" in field_data and "type" not in field_data + + +_SupportedTypes: typing.TypeAlias = typing.Literal[ + "integer", "number", "boolean", "string", "union", "array" +] + + +def _is_composite(member: dict[str, typing.Any]) -> bool: + """Checks if a node in the OAS represents a composite type.""" + return "$ref" in member or ("properties" in member and "type" not in member) + + +def _resolve_union_type( + field_data: dict[str, typing.Any], +) -> tuple[_SupportedTypes, bool]: + """Resolve union type, preserving base type for simple optionals. + + Args: + field_data: Node in OAS representing a field with key "anyOf". + + Returns: + resolved_type: + String representation of the type's name. For optional + types, this will simply be the name of the non-null type, + *eg.* `boolean | null` will return "boolean". For unions + between `array` and `number` or `integer`, this returns + "array". All other combinations of types return `union`. + is_optional: + Boolean determining if the union contains `null`. + + Raises: + ValueError: If union includes composite types (not supported) + """ + any_of = field_data.get("anyOf", []) + + types = set() + is_optional = False + for member in any_of: + if _is_composite(member): + raise ValueError( + "Unions including composite types (e.g., Model | int, Model1 " + "| Model2) are not currently supported. Use one of the " + "composite types directly." + ) + if "type" not in member: + continue + if member["type"] == "null": + is_optional = True + continue + types.add(member["type"]) + + if len(types) == 1: + return types.pop(), is_optional + + has_array = "array" in types + non_array_types = types - {"array"} + is_all_numeric = non_array_types <= {"integer", "number"} + + if has_array and is_all_numeric: + return "array", is_optional + + return "union", is_optional + + def _format_field( field_key: str, field_data: dict[str, typing.Any], @@ -308,37 +403,55 @@ def _format_field( Returns: Formatted input field data. """ + # Handle union types (anyOf) + is_optional = False + if _is_union_type(field_data): + resolved_type, is_optional = _resolve_union_type(field_data) + # Inject resolved type into field_data so rest of function works normally + field_data = {**field_data, "type": resolved_type} + + # Check for collections of composite types (unsupported) + if field_data.get("type") == "array": + if _is_composite(field_data.get("items", {})): + raise ValueError( + f"Collections of composite types (e.g., list[Model]) are not currently " + f"supported for field '{field_key}'. Consider restructuring your schema." + ) + field = _InputField( type=field_data["type"], title=field_data.get("title", field_key) if use_title else field_key, description=field_data.get("description", None), ancestors=[*ancestors, field_key], + optional=is_optional, ) if "properties" not in field_data: # signals a Python primitive type if field["type"] != "object": default_val = field_data.get("default", None) - if (field_data["type"] == "string") and (default_val is None): + # For non-optional strings, convert None default to empty string + if ( + (field_data["type"] == "string") + and (default_val is None) + and not is_optional + ): default_val = "" field["default"] = default_val - if field_data["type"] in ("number", "integer"): - field["number_constraints"] = { - "min_value": field_data.get("minimum", None), - "max_value": field_data.get("maximum", None), - "step": field_data.get("multipleOf", None), - } + # Only add number_constraints if constraints actually exist + if field_data["type"] in {"number", "integer"}: + if {"minimum", "maximum", "multipleOf"}.intersection(field_data): + field["number_constraints"] = _num_constraints_from_field( + field_data + ) return field + field["title"] = _key_to_title(field_key) if use_title else field_key if ARRAY_PROPS <= set(field_data["properties"]): data_type = "array" if _is_scalar(field_data["properties"]["shape"]): data_type = "number" field["default"] = field_data.get("default", None) - field["number_constraints"] = { - "min_value": field_data.get("minimum", None), - "max_value": field_data.get("maximum", None), - "step": field_data.get("multipleOf", None), - } + field["number_constraints"] = _num_constraints_from_field(field_data) field["type"] = data_type return field # at this point, not an array or primitive, so must be composite @@ -424,6 +537,7 @@ class JinjaField(typing.TypedDict): type: str description: str title: str + optional: bool default: NotRequired[typing.Any] number_constraints: NumberConstraints diff --git a/tesseract_streamlit/templates/template.j2 b/tesseract_streamlit/templates/template.j2 index 1f9097b..070b302 100644 --- a/tesseract_streamlit/templates/template.j2 +++ b/tesseract_streamlit/templates/template.j2 @@ -67,81 +67,109 @@ 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 }} = {{ field.container }}.text_input( - "{{ field.get('title', field.uid) }}", - key="int.{{ field.key }}", - value="{{ field.get('default', '') }}", -) +{# 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' %} -{{ field.uid }} = {{ field.container }}.number_input( - "{{ field.get('title', field.uid) }}", - 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), -) +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' %} -{{ field.uid }} = {{ field.container }}.number_input( - "{{ 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), - 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), -) +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' %} -{{ field.uid }} = {{ field.container }}.checkbox( - "{{ field.get('title', field.uid) }}", - key="boolean.{{ field.key }}", - value={{ field.get("default", None) }}, -) +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' %} -# Array input -{{ field.container }}.markdown("**Enter JSON array or upload file:**") -{{ field.uid }}_text = {{ field.container }}.text_area( - "Paste {{ field.uid.replace("_", ".") }} JSON", - height=100, - key="textarea.{{ field.key }}" -) -{{ field.uid }}_file = {{ field.container }}.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: - {{ field.container }}.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}") - -if {{ field.uid }} is not None: - try: - arr = np.array({{ field.uid }}, dtype=np.float64) - {{ field.container }}.write("Array preview:") - {{ field.container }}.code(arr) - except Exception as e: - {{ field.container }}.error(f"Invalid array input for {{ field.uid }}: {e}") +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 %} @@ -215,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 bf395b6..c1a19bb 100644 --- a/tests/goodbyeworld/tesseract_api.py +++ b/tests/goodbyeworld/tesseract_api.py @@ -12,11 +12,11 @@ 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 = Field( - description="Age of person in years.", default=30, minimum=0, maximum=125 + age: int | None = Field( + description="Age of person in years.", minimum=0, maximum=125 ) height: float = Field( description="Height of person in cm.", @@ -41,9 +41,21 @@ class OutputSchema(BaseModel): def apply(inputs: InputSchema) -> OutputSchema: """Greet a person whose name is given as input.""" + 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 = 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}! You are {inputs.age} years old. " + 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, " @@ -55,6 +67,5 @@ def apply(inputs: InputSchema) -> OutputSchema: 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 76a0fdf..28012d7 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,10 +17,10 @@ "age" ], "default": 30, + "optional": false, "number_constraints": { "max_value": 125, - "min_value": 0, - "step": null + "min_value": 0 } }, { @@ -30,6 +31,7 @@ "height" ], "default": 175, + "optional": false, "number_constraints": { "max_value": 300, "min_value": 0, @@ -43,7 +45,8 @@ "ancestors": [ "alive" ], - "default": true + "default": true, + "optional": false }, { "type": "number", @@ -53,10 +56,9 @@ "weight" ], "default": null, + "optional": false, "number_constraints": { - "max_value": null, - "min_value": 0, - "step": null + "min_value": 0 } }, { @@ -65,7 +67,8 @@ "description": "The length of the person's left and right legs in cm.", "ancestors": [ "leg_lengths" - ] + ], + "optional": false }, { "type": "composite", @@ -73,7 +76,8 @@ "description": "The person's only hobby.", "ancestors": [ "hobby" - ] + ], + "optional": false }, { "type": "string", @@ -83,7 +87,8 @@ "hobby", "name" ], - "default": "" + "default": "", + "optional": false }, { "type": "boolean", @@ -93,7 +98,8 @@ "hobby", "active" ], - "default": null + "default": null, + "optional": false }, { "type": "integer", @@ -104,10 +110,74 @@ "experience" ], "default": null, + "optional": false, "number_constraints": { "max_value": 120, - "min_value": 0, - "step": null + "min_value": 0 } + }, + { + "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 + } + }, + { + "type": "string", + "title": "Optional Name", + "description": "Optional name field for testing str | None union.", + "ancestors": [ + "optional_name" + ], + "default": null, + "optional": true + }, + { + "type": "union", + "title": "Number Or Int", + "description": "Field that accepts int or float for testing number coercion.", + "ancestors": [ + "number_or_int" + ], + "default": 42, + "optional": false + }, + { + "type": "union", + "title": "Threshold", + "description": "Threshold as number or special string like 'auto' for testing float | str union.", + "ancestors": [ + "threshold" + ], + "default": null, + "optional": false + }, + { + "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 + }, + { + "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 9dcc9ca..35093f9 100644 --- a/tests/mock-schema.json +++ b/tests/mock-schema.json @@ -223,6 +223,90 @@ "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." + }, + "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 + }, + "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, @@ -230,7 +314,9 @@ "required": [ "weight", "leg_lengths", - "hobby" + "hobby", + "threshold", + "scalar_or_array" ], "title": "Apply_InputSchema" }, diff --git a/tests/test_cli.py b/tests/test_cli.py index 43592e4..7d799e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -51,9 +51,10 @@ 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="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..8bdc61c 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -155,3 +155,47 @@ 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_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"])