Skip to content

Commit 03613a0

Browse files
feat(cli): add --pretty-headings flag (#36)
### Relevant issue or PR Closes #22. ### Description of changes Add a `--pretty-headings/--no-pretty-headings` flag to the CLI, such that formatting on the parameter names in the Web UI can be toggled on and off. This is useful in instances where Tesseracts have parameter names which differ only in case. In the process, `_parse_tesseract_oas()` was refactored, so the formatting logic for input fields is easier to separate from the recursive logic of traversing the OAS tree. This was done by extracted formatting logic in a function called `_format_field()`. ### Testing done Manual testing. Example: `tesseract_api.py`: ```python from pydantic import BaseModel, Field class InputSchema(BaseModel): dummy_input: str = Field(description="Meaningless input", default="Dummy") this_is_a_long_one: str = Field(description="Pretty long", default="Oh my") THIS_IS_A_LONG_ONE: str = Field(description="Also long", default="Oh gosh") This_Is_A_Long_One: str = Field(description="Long, too", default="Oh darn") class OutputSchema(BaseModel): dummy_output: str = Field(description="Meaningless output") wizard: str = Field(description="Magic", default="kadabra") Wizard: str = Field(description="Runes and stuff", default="presto!") def apply(inputs: InputSchema) -> OutputSchema: """Run for 4 seconds before returning dummy output.""" return OutputSchema( dummy_output="This is meaningless" ) ``` #### Pretty headings on (default) ```bash tesseract-streamlit --pretty-headings "http://localhost:50262" app.py # this is default streamlit run app.py ``` <img width="584" height="860" alt="image" src="https://github.com/user-attachments/assets/c077c010-e3ff-477c-a845-03502686e0a7" /> #### Pretty headings off ```bash tesseract-streamlit --no-pretty-headings "http://localhost:50262" app.py streamlit run app.py ``` <img width="590" height="859" alt="image" src="https://github.com/user-attachments/assets/e8e19bbc-6ac2-4184-87e7-9e28678064ae" />
1 parent 1af8e67 commit 03613a0

File tree

3 files changed

+84
-32
lines changed

3 files changed

+84
-32
lines changed

tesseract_streamlit/cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ def main(
4848
exists=True,
4949
),
5050
] = None,
51+
pretty_headings: typing.Annotated[
52+
bool,
53+
typer.Option(
54+
"--pretty-headings/--no-pretty-headings",
55+
is_flag=True,
56+
help=(
57+
"Formats schema parameters as headings, with spaces and capitalisation."
58+
),
59+
),
60+
] = True,
5161
) -> None:
5262
"""Generates a Streamlit app from Tesseract OpenAPI schemas.
5363
@@ -70,7 +80,7 @@ def main(
7080
)
7181
template = env.get_template("templates/template.j2")
7282
try:
73-
render_kwargs = extract_template_data(url, user_code)
83+
render_kwargs = extract_template_data(url, user_code, pretty_headings)
7484
except ConnectionError as e:
7585
err_console.print(
7686
"[bold red]Error: [/bold red]"

tesseract_streamlit/parse.py

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,61 @@ class _InputField(typing.TypedDict):
275275
default: NotRequired[typing.Any]
276276

277277

278+
def _key_to_title(key: str) -> str:
279+
"""Formats an OAS key to a title for the web UI."""
280+
return key.replace("_", " ").title()
281+
282+
283+
def _format_field(
284+
field_key: str,
285+
field_data: dict[str, typing.Any],
286+
ancestors: list[str],
287+
use_title: bool,
288+
) -> _InputField:
289+
"""Formats a node of the OAS tree representing an input field.
290+
291+
Args:
292+
field_key: key of the node in the OAS tree.
293+
field_data: dictionary of data representing the field.
294+
ancestors: ordered list of ancestors in which the field is
295+
nested.
296+
use_title: whether to use the OAS formatted title, or the
297+
field_key.
298+
299+
Returns:
300+
Formatted input field data.
301+
"""
302+
field = _InputField(
303+
type=field_data["type"],
304+
title=field_data.get("title", field_key) if use_title else field_key,
305+
description=field_data.get("description", None),
306+
ancestors=[*ancestors, field_key],
307+
)
308+
if "properties" not in field_data: # signals a Python primitive type
309+
if field["type"] != "object":
310+
default_val = field_data.get("default", None)
311+
if (field_data["type"] == "string") and (default_val is None):
312+
default_val = ""
313+
field["default"] = default_val
314+
return field
315+
field["title"] = _key_to_title(field_key) if use_title else field_key
316+
if ARRAY_PROPS <= set(field_data["properties"]):
317+
data_type = "array"
318+
if _is_scalar(field_data["properties"]["shape"]):
319+
data_type = "number"
320+
field["default"] = field_data.get("default", None)
321+
field["type"] = data_type
322+
return field
323+
# at this point, not an array or primitive, so must be composite
324+
field["type"] = "composite"
325+
return field
326+
327+
278328
def _simplify_schema(
279329
schema_node: dict[str, typing.Any],
280330
accum: list | None = None,
281331
ancestors: list | None = None,
332+
use_title: bool = True,
282333
) -> list[_InputField]:
283334
"""Returns a flat simplified representation of the ``InputSchema``.
284335
@@ -299,6 +350,10 @@ def _simplify_schema(
299350
accum: List containing the inputs we are accumulating.
300351
ancestors: Ancestors which the parent node is nested beneath,
301352
*eg.* the names of the parent schemas, in order.
353+
use_title: Sets whether to use the OAS generated title. These
354+
are the parameter names, with spaces instead of underscores,
355+
and capitalised. If False, will use the parameter name
356+
without formatting. Default is True.
302357
303358
Returns:
304359
List of ``_InputField`` instances, describing the structure of
@@ -309,35 +364,14 @@ def _simplify_schema(
309364
if ancestors is None:
310365
ancestors = []
311366
for child_key, child_val in schema_node.items():
312-
child_data = _InputField(
313-
type=child_val["type"],
314-
title=child_val.get("title", child_key),
315-
description=child_val.get("description", None),
316-
ancestors=[*ancestors, child_key],
317-
)
318-
if "properties" not in child_val: # signals a Python primitive type
319-
if child_data["type"] != "object":
320-
default_val = child_val.get("default", None)
321-
if (child_val["type"] == "string") and (default_val is None):
322-
default_val = ""
323-
child_data["default"] = default_val
324-
accum.append(child_data)
325-
# TODO: dicts in InputSchema use additionalProperties
326-
continue
327-
child_data["title"] = child_key.capitalize()
328-
if ARRAY_PROPS <= set(child_val["properties"]):
329-
data_type = "array"
330-
if _is_scalar(child_val["properties"]["shape"]):
331-
data_type = "number"
332-
child_data["default"] = child_val.get("default", None)
333-
child_data["type"] = data_type
334-
accum.append(child_data)
335-
continue
336-
# at this point, not an array or primitive, so must be composite
337-
child_data["type"] = "composite"
367+
child_data = _format_field(child_key, child_val, ancestors, use_title)
338368
accum.append(child_data)
369+
if child_data["type"] != "composite":
370+
continue
339371
accum.extend(
340-
_simplify_schema(child_val["properties"], [], child_data["ancestors"])
372+
_simplify_schema(
373+
child_val["properties"], [], child_data["ancestors"], use_title
374+
)
341375
)
342376
return accum
343377

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

404438

405439
def _parse_tesseract_oas(
406-
oas_data: bytes,
440+
oas_data: bytes, pretty_headings: bool = True
407441
) -> tuple[TesseractMetadata, list[JinjaField]]:
408442
"""Parses Tesseract OAS into a flat list of dictionaries.
409443
@@ -413,6 +447,8 @@ def _parse_tesseract_oas(
413447
414448
Args:
415449
oas_data: the JSON data as an unparsed string.
450+
pretty_headings: whether to format parameter names as headings.
451+
Default is True.
416452
417453
Returns:
418454
TesseractMetadata:
@@ -429,7 +465,9 @@ def _parse_tesseract_oas(
429465
}
430466
input_schema = data["components"]["schemas"]["Apply_InputSchema"]
431467
resolved_schema = _resolve_refs(input_schema, data)
432-
input_fields = _simplify_schema(resolved_schema["properties"])
468+
input_fields = _simplify_schema(
469+
resolved_schema["properties"], use_title=pretty_headings
470+
)
433471
jinja_fields = [_input_to_jinja(field) for field in input_fields]
434472
return metadata, jinja_fields
435473

@@ -466,6 +504,7 @@ class TemplateData(typing.TypedDict):
466504
def extract_template_data(
467505
url: str,
468506
user_code: Path | None,
507+
pretty_headings: bool,
469508
) -> TemplateData:
470509
"""Formats Tesseract and user-defined function inputs for template.
471510
@@ -477,14 +516,17 @@ def extract_template_data(
477516
Args:
478517
url: URI of the running Tesseract instance.
479518
user_code: path of the user-defined plotting function module.
519+
pretty_headings: whether to format parameters names as headings.
480520
481521
Returns:
482522
TemplateData:
483523
Preprocessed data describing the Streamlit app based on the
484524
``InputSchema``, ready for injection into the app template.
485525
"""
486526
response = requests.get(f"{url}/openapi.json")
487-
metadata, schema = _parse_tesseract_oas(response.content)
527+
metadata, schema = _parse_tesseract_oas(
528+
response.content, pretty_headings=pretty_headings
529+
)
488530
render_kwargs = TemplateData(
489531
metadata=metadata,
490532
schema=schema,

tests/mock-schema-fields.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +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}]
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}]

0 commit comments

Comments
 (0)