Skip to content
12 changes: 11 additions & 1 deletion tesseract_streamlit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ def main(
exists=True,
),
] = None,
pretty_headings: typing.Annotated[
bool,
typer.Option(
"--pretty-headings/--no-pretty-headings",
is_flag=True,
help=(
"Formats schema parameters as headings, with spaces and capitalisation."
),
),
] = True,
) -> None:
"""Generates a Streamlit app from Tesseract OpenAPI schemas.

Expand All @@ -70,7 +80,7 @@ def main(
)
template = env.get_template("templates/template.j2")
try:
render_kwargs = extract_template_data(url, user_code)
render_kwargs = extract_template_data(url, user_code, pretty_headings)
except ConnectionError as e:
err_console.print(
"[bold red]Error: [/bold red]"
Expand Down
102 changes: 72 additions & 30 deletions tesseract_streamlit/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,61 @@ class _InputField(typing.TypedDict):
default: NotRequired[typing.Any]


def _key_to_title(key: str) -> str:
"""Formats an OAS key to a title for the web UI."""
return key.replace("_", " ").title()


def _format_field(
field_key: str,
field_data: dict[str, typing.Any],
ancestors: list[str],
use_title: bool,
) -> _InputField:
"""Formats a node of the OAS tree representing an input field.

Args:
field_key: key of the node in the OAS tree.
field_data: dictionary of data representing the field.
ancestors: ordered list of ancestors in which the field is
nested.
use_title: whether to use the OAS formatted title, or the
field_key.

Returns:
Formatted input field data.
"""
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],
)
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
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["type"] = data_type
return field
# at this point, not an array or primitive, so must be composite
field["type"] = "composite"
return field


def _simplify_schema(
schema_node: dict[str, typing.Any],
accum: list | None = None,
ancestors: list | None = None,
use_title: bool = True,
) -> list[_InputField]:
"""Returns a flat simplified representation of the ``InputSchema``.

Expand All @@ -299,6 +350,10 @@ def _simplify_schema(
accum: List containing the inputs we are accumulating.
ancestors: Ancestors which the parent node is nested beneath,
*eg.* the names of the parent schemas, in order.
use_title: Sets whether to use the OAS generated title. These
are the parameter names, with spaces instead of underscores,
and capitalised. If False, will use the parameter name
without formatting. Default is True.

Returns:
List of ``_InputField`` instances, describing the structure of
Expand All @@ -309,35 +364,14 @@ def _simplify_schema(
if ancestors is None:
ancestors = []
for child_key, child_val in schema_node.items():
child_data = _InputField(
type=child_val["type"],
title=child_val.get("title", child_key),
description=child_val.get("description", None),
ancestors=[*ancestors, child_key],
)
if "properties" not in child_val: # signals a Python primitive type
if child_data["type"] != "object":
default_val = child_val.get("default", None)
if (child_val["type"] == "string") and (default_val is None):
default_val = ""
child_data["default"] = default_val
accum.append(child_data)
# TODO: dicts in InputSchema use additionalProperties
continue
child_data["title"] = child_key.capitalize()
if ARRAY_PROPS <= set(child_val["properties"]):
data_type = "array"
if _is_scalar(child_val["properties"]["shape"]):
data_type = "number"
child_data["default"] = child_val.get("default", None)
child_data["type"] = data_type
accum.append(child_data)
continue
# at this point, not an array or primitive, so must be composite
child_data["type"] = "composite"
child_data = _format_field(child_key, child_val, ancestors, use_title)
accum.append(child_data)
if child_data["type"] != "composite":
continue
accum.extend(
_simplify_schema(child_val["properties"], [], child_data["ancestors"])
_simplify_schema(
child_val["properties"], [], child_data["ancestors"], use_title
)
)
return accum

Expand Down Expand Up @@ -403,7 +437,7 @@ def _input_to_jinja(field: _InputField) -> JinjaField:


def _parse_tesseract_oas(
oas_data: bytes,
oas_data: bytes, pretty_headings: bool = True
) -> tuple[TesseractMetadata, list[JinjaField]]:
"""Parses Tesseract OAS into a flat list of dictionaries.

Expand All @@ -413,6 +447,8 @@ def _parse_tesseract_oas(

Args:
oas_data: the JSON data as an unparsed string.
pretty_headings: whether to format parameter names as headings.
Default is True.

Returns:
TesseractMetadata:
Expand All @@ -429,7 +465,9 @@ def _parse_tesseract_oas(
}
input_schema = data["components"]["schemas"]["Apply_InputSchema"]
resolved_schema = _resolve_refs(input_schema, data)
input_fields = _simplify_schema(resolved_schema["properties"])
input_fields = _simplify_schema(
resolved_schema["properties"], use_title=pretty_headings
)
jinja_fields = [_input_to_jinja(field) for field in input_fields]
return metadata, jinja_fields

Expand Down Expand Up @@ -466,6 +504,7 @@ class TemplateData(typing.TypedDict):
def extract_template_data(
url: str,
user_code: Path | None,
pretty_headings: bool,
) -> TemplateData:
"""Formats Tesseract and user-defined function inputs for template.

Expand All @@ -477,14 +516,17 @@ def extract_template_data(
Args:
url: URI of the running Tesseract instance.
user_code: path of the user-defined plotting function module.
pretty_headings: whether to format parameters names as headings.

Returns:
TemplateData:
Preprocessed data describing the Streamlit app based on the
``InputSchema``, ready for injection into the app template.
"""
response = requests.get(f"{url}/openapi.json")
metadata, schema = _parse_tesseract_oas(response.content)
metadata, schema = _parse_tesseract_oas(
response.content, pretty_headings=pretty_headings
)
render_kwargs = TemplateData(
metadata=metadata,
schema=schema,
Expand Down
2 changes: 1 addition & 1 deletion tests/mock-schema-fields.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"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},{"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}]
Loading