This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 74fa4e5 Add descriptions and other small form improvements
74fa4e5 is described below
commit 74fa4e5f9bfa9156d1e2be74bbee32af39c66465
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Nov 7 19:56:22 2025 +0000
Add descriptions and other small form improvements
---
atr/form.py | 85 +++++++++++++++++++++++++++++++++++++++++++--------------
atr/get/test.py | 8 +++---
2 files changed, 69 insertions(+), 24 deletions(-)
diff --git a/atr/form.py b/atr/form.py
index db121bd..6736468 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -107,8 +107,14 @@ def json_suitable(field_value: Any) -> Any:
return field_value
-def label(description: str, *, default: Any = ..., widget: Widget | None =
None) -> Any:
- extra: dict[str, Any] = {"widget": widget.value} if widget else {}
+def label(
+ description: str, documentation: str | None = None, *, default: Any = ...,
widget: Widget | None = None
+) -> Any:
+ extra: dict[str, Any] = {}
+ if widget is not None:
+ extra["widget"] = widget.value
+ if documentation is not None:
+ extra["documentation"] = documentation
return pydantic.Field(default, description=description,
json_schema_extra=extra)
@@ -158,12 +164,26 @@ async def quart_request() -> dict[str, Any]:
return combined_data
-async def render_columns(
+def _get_flash_error_data() -> dict[str, Any]:
+ flashed_error_messages =
quart.get_flashed_messages(category_filter=["form-error-data"])
+ if flashed_error_messages:
+ try:
+ first_message = flashed_error_messages[0]
+ if isinstance(first_message, str):
+ return json.loads(first_message)
+ except (json.JSONDecodeError, IndexError):
+ pass
+ return {}
+
+
+async def render(
model_cls: type[Form],
action: str | None = None,
form_classes: str = ".atr-canary",
submit_classes: str = "btn-primary",
submit_label: str = "Submit",
+ cancel_url: str | None = None,
+ textarea_rows: int = 18,
defaults: dict[str, Any] | None = None,
errors: dict[str, list[str]] | None = None,
use_error_data: bool = True,
@@ -171,16 +191,7 @@ async def render_columns(
if action is None:
action = quart.request.path
- flash_error_data: dict[str, Any] = {}
- if use_error_data:
- flashed_error_messages =
quart.get_flashed_messages(category_filter=["form-error-data"])
- if flashed_error_messages:
- try:
- first_message = flashed_error_messages[0]
- if isinstance(first_message, str):
- flash_error_data = json.loads(first_message)
- except (json.JSONDecodeError, IndexError):
- pass
+ flash_error_data: dict[str, Any] = _get_flash_error_data() if
use_error_data else {}
field_rows: list[htm.Element] = []
hidden_fields: list[htm.Element | htm.VoidElement | markupsafe.Markup] = []
@@ -192,7 +203,14 @@ async def render_columns(
if field_name == "csrf_token":
continue
- hidden_field, row = _render_row(field_info, field_name,
flash_error_data, defaults, errors)
+ hidden_field, row = _render_row(
+ field_info,
+ field_name,
+ flash_error_data,
+ defaults,
+ errors,
+ textarea_rows,
+ )
if hidden_field:
hidden_fields.append(hidden_field)
if row:
@@ -201,8 +219,12 @@ async def render_columns(
form_children: list[htm.Element | htm.VoidElement | markupsafe.Markup] =
hidden_fields + field_rows
submit_button = htpy.button(type="submit", class_=f"btn
{submit_classes}")[submit_label]
+ submit_div_contents: list[htm.Element | htm.VoidElement] = [submit_button]
+ if cancel_url:
+ cancel_link = htpy.a(href=cancel_url, class_="btn btn-link
text-secondary")["Cancel"]
+ submit_div_contents.append(cancel_link)
submit_div = htm.div(".col-sm-9.offset-sm-3")
- submit_row = htm.div(".row")[submit_div[submit_button]]
+ submit_row = htm.div(".row")[submit_div[submit_div_contents]]
form_children.append(submit_row)
return htm.form(form_classes, action=action, method="post",
enctype="multipart/form-data")[form_children]
@@ -332,6 +354,7 @@ def _render_widget( # noqa: C901
field_value: Any,
field_errors: list[str] | None,
is_required: bool,
+ textarea_rows: int,
) -> htm.Element | htm.VoidElement:
widget_type = _get_widget_type(field_info)
widget_classes = _get_widget_classes(widget_type, field_errors)
@@ -395,7 +418,13 @@ def _render_widget( # noqa: C901
widget = htpy.input(**attrs)
case Widget.RADIO:
- choices = _get_choices(field_info)
+ if isinstance(field_value, list):
+ choices = [(val, val) for val in field_value]
+ selected_value = field_value[0] if field_value else None
+ else:
+ choices = _get_choices(field_info)
+ selected_value = field_value
+
radios = []
for val, label in choices:
radio_id = f"{field_name}_{val}"
@@ -408,7 +437,7 @@ def _render_widget( # noqa: C901
}
if is_required:
radio_attrs["required"] = ""
- if val == field_value:
+ if val == selected_value:
radio_attrs["checked"] = ""
radio_input = htpy.input(**radio_attrs)
radio_label = htpy.label(for_=radio_id,
class_="form-check-label")[label]
@@ -417,11 +446,17 @@ def _render_widget( # noqa: C901
widget = htm.div[radios]
case Widget.SELECT:
- choices = _get_choices(field_info)
+ if isinstance(field_value, list):
+ choices = [(val, val) for val in field_value]
+ selected_value = field_value[0] if field_value else None
+ else:
+ choices = _get_choices(field_info)
+ selected_value = field_value
+
options = [
htpy.option(
value=val,
- selected="" if (val == field_value) else None,
+ selected="" if (val == selected_value) else None,
)[label]
for val, label in choices
]
@@ -434,7 +469,8 @@ def _render_widget( # noqa: C901
widget = htpy.input(**attrs)
case Widget.TEXTAREA:
- widget = htpy.textarea(**base_attrs)[field_value or ""]
+ attrs = {**base_attrs, "rows": str(textarea_rows)}
+ widget = htpy.textarea(**attrs)[field_value or ""]
case Widget.URL:
attrs = {**base_attrs, "type": "url"}
@@ -583,6 +619,7 @@ def _render_row(
flash_error_data: dict[str, Any],
defaults: dict[str, Any] | None,
errors: dict[str, list[str]] | None,
+ textarea_rows: int,
) -> tuple[htm.VoidElement | None, htm.Element | None]:
widget_type = _get_widget_type(field_info)
has_flash_error = field_name in flash_error_data
@@ -613,6 +650,7 @@ def _render_row(
field_value=field_value,
field_errors=field_errors,
is_required=is_required,
+ textarea_rows=textarea_rows,
)
row_div = htm.div(".mb-3.row")
@@ -623,5 +661,12 @@ def _render_row(
error_msg = flash_error_data[field_name]["msg"]
error_div = htm.div(".text-danger.mt-1")[f"Error: {error_msg}"]
widget_div_contents.append(error_div)
+ else:
+ json_schema_extra = field_info.json_schema_extra or {}
+ if isinstance(json_schema_extra, dict):
+ documentation = json_schema_extra.get("documentation")
+ if isinstance(documentation, str):
+ doc_div = htm.div(".text-muted.mt-1.form-text")[documentation]
+ widget_div_contents.append(doc_div)
return None, row_div[label_elem, widget_div[widget_div_contents]]
diff --git a/atr/get/test.py b/atr/get/test.py
index 35cbe2f..8998d93 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -30,7 +30,7 @@ import atr.web as web
@get.public("/test/empty")
async def test_empty(session: web.Committer | None) -> str:
- empty_form = await form.render_columns(
+ empty_form = await form.render(
model_cls=form.Empty,
submit_label="Submit empty form",
action="/test/empty",
@@ -67,13 +67,13 @@ async def test_login(session: web.Committer | None) ->
web.WerkzeugResponse:
@get.public("/test/multiple")
async def test_multiple(session: web.Committer | None) -> str:
- apple_form = await form.render_columns(
+ apple_form = await form.render(
model_cls=shared.test.AppleForm,
submit_label="Order apples",
action="/test/multiple",
)
- banana_form = await form.render_columns(
+ banana_form = await form.render(
model_cls=shared.test.BananaForm,
submit_label="Order bananas",
action="/test/multiple",
@@ -91,7 +91,7 @@ async def test_multiple(session: web.Committer | None) -> str:
@get.public("/test/single")
async def test_single(session: web.Committer | None) -> str:
- single_form = await form.render_columns(
+ single_form = await form.render(
model_cls=shared.test.SingleForm,
submit_label="Submit",
action="/test/single",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]