You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/11/09 11:51:46 UTC
(tooling-trusted-releases) 01/01: Allow custom field errors after Pydantic validation
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
commit 7a8534d11bfb2f0e716013595908c591b16e9fdf
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Sun Nov 9 11:29:36 2025 +0000
Allow custom field errors after Pydantic validation
---
atr/blueprints/post.py | 63 ++++++++++++++++++++++++++++-------------------
atr/form.py | 17 ++++++++++++-
atr/htm.py | 7 ++++--
atr/post/test.py | 5 ++++
atr/principal.py | 2 +-
atr/web.py | 67 ++++++++++++++++++++++++++++++++++++++++----------
6 files changed, 119 insertions(+), 42 deletions(-)
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index 1e1286d..5498b3e 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -24,12 +24,10 @@ from typing import Any
import asfquart.auth as auth
import asfquart.base as base
import asfquart.session
-import markupsafe
import pydantic
import quart
import atr.form
-import atr.htm as htm
import atr.log as log
import atr.web as web
@@ -108,21 +106,34 @@ def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable
# pass
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
async def wrapper(session: web.Committer | None, *args: Any, **kwargs: Any) -> Any:
+ match session:
+ case web.Committer() as committer:
+ form_data = await committer.form_data()
+ case None:
+ form_data = await atr.form.quart_request()
try:
- form_data = await atr.form.quart_request()
- context = {"session": session}
- atr.form.validate(atr.form.Empty, form_data, context)
+ context = {
+ "args": args,
+ "kwargs": kwargs,
+ "session": session,
+ }
+ match session:
+ case web.Committer() as committer:
+ await committer.form_validate(atr.form.Empty, context=context)
+ case None:
+ atr.form.validate(atr.form.Empty, form_data, context=context)
return await func(session, *args, **kwargs)
except pydantic.ValidationError:
- # This presumably should not happen, because the CSRF token checker reaches it first
- msg = "Sorry, your form session expired for security reasons. Please try again."
+ # This could happen if the form was tampered with
+ # It should not happen if the CSRF token is invalid
+ msg = "Sorry, there was an empty form validation error. Please try again."
await quart.flash(msg, "error")
return quart.redirect(quart.request.path)
- wrapper.__name__ = func.__name__
- wrapper.__module__ = func.__module__
- wrapper.__doc__ = func.__doc__
wrapper.__annotations__ = func.__annotations__.copy()
+ wrapper.__doc__ = func.__doc__
+ wrapper.__module__ = func.__module__
+ wrapper.__name__ = func.__name__
return wrapper
return decorator
@@ -133,31 +144,33 @@ def form(
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
async def wrapper(session: web.Committer | None, *args: Any, **kwargs: Any) -> Any:
- form_data = await atr.form.quart_request()
+ match session:
+ case web.Committer() as committer:
+ form_data = await committer.form_data()
+ case None:
+ form_data = await atr.form.quart_request()
try:
- context = {"session": session}
- validated_form = atr.form.validate(form_cls, form_data, context)
+ context = {
+ "args": args,
+ "kwargs": kwargs,
+ "session": session,
+ }
+ match session:
+ case web.Committer() as committer:
+ validated_form = await committer.form_validate(form_cls, context)
+ case None:
+ validated_form = atr.form.validate(form_cls, form_data, context=context)
return await func(session, validated_form, *args, **kwargs)
except pydantic.ValidationError as e:
errors = e.errors()
if len(errors) == 0:
raise RuntimeError("Validation failed, but no errors were reported")
flash_data = atr.form.flash_error_data(form_cls, errors, form_data)
-
- plural = len(errors) > 1
- summary = f"Please fix the following issue{'s' if plural else ''}:"
- ul = htm.Block(htm.ul, classes=".mt-2.mb-0")
- for i, flash_datum in enumerate(flash_data.values()):
- if i > 9:
- ul.li["And more, not shown here..."]
- break
- if "msg" in flash_datum:
- ul.li[htm.strong[flash_datum["label"]], ": ", flash_datum["msg"]]
- summary = f"{summary}\n{ul.collect()}"
+ summary = atr.form.flash_error_summary(errors, flash_data)
# TODO: Centralise all uses of markupsafe.Markup
# log.info(f"Flash data: {flash_data}")
- await quart.flash(markupsafe.Markup(summary), category="error")
+ await quart.flash(summary, category="error")
await quart.flash(json.dumps(flash_data), category="form-error-data")
return quart.redirect(quart.request.path)
diff --git a/atr/form.py b/atr/form.py
index 06744e4..81ad0d7 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -23,6 +23,7 @@ import types
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, get_args, get_origin
import htpy
+import markupsafe
import pydantic
import pydantic.functional_validators as functional_validators
import quart
@@ -35,7 +36,6 @@ import atr.models.schema as schema
if TYPE_CHECKING:
from collections.abc import Iterator
- import markupsafe
import pydantic_core
import atr.web as web
@@ -96,6 +96,21 @@ def flash_error_data(
return flash_data
+def flash_error_summary(errors: list[pydantic_core.ErrorDetails], flash_data: dict[str, Any]) -> markupsafe.Markup:
+ div = htm.Block()
+ plural = len(errors) > 1
+ div.text(f"Please fix the following issue{'s' if plural else ''}:")
+ with div.block(htm.ul, classes=".mt-2.mb-0") as ul:
+ for i, flash_datum in enumerate(flash_data.values()):
+ if i > 9:
+ ul.li["And more, not shown here..."]
+ break
+ if "msg" in flash_datum:
+ ul.li[htm.strong[flash_datum["label"]], ": ", flash_datum["msg"]]
+ summary = div.collect()
+ return markupsafe.Markup(summary)
+
+
def json_suitable(field_value: Any) -> Any:
if isinstance(field_value, datastructures.FileStorage):
return field_value.filename
diff --git a/atr/htm.py b/atr/htm.py
index 720febe..530800d 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -129,9 +129,12 @@ class Block:
@contextlib.contextmanager
def block(
- self, element: Element | None = None, separator: Element | VoidElement | str | None = None
+ self,
+ element: Element | None = None,
+ classes: str | None = None,
+ separator: Element | VoidElement | str | None = None,
) -> Generator[Block, Any, Any]:
- block = Block(element)
+ block = Block(element, classes=classes)
yield block
self.append(block.collect(separator=separator))
diff --git a/atr/post/test.py b/atr/post/test.py
index 3e3ef1e..d5a4a01 100644
--- a/atr/post/test.py
+++ b/atr/post/test.py
@@ -55,6 +55,11 @@ async def test_multiple(session: web.Committer | None, form: shared.test.Multipl
async def test_single(session: web.Committer | None, form: shared.test.SingleForm) -> web.WerkzeugResponse:
file_names = [f.filename for f in form.files] if form.files else []
compatibility_names = [f.value for f in form.compatibility] if form.compatibility else []
+ if (form.message == "Forbidden message!") and (session is not None):
+ return await session.form_error(
+ "message",
+ "You are not permitted to submit the forbidden message",
+ )
msg = (
f"Single form received:"
f" name={form.name},"
diff --git a/atr/principal.py b/atr/principal.py
index 66c7d99..911dce1 100644
--- a/atr/principal.py
+++ b/atr/principal.py
@@ -356,7 +356,7 @@ class Authorisation(AsyncObject):
case ArgumentNoneType() | web.Committer():
match asf_uid:
case web.Committer():
- asfquart_session = asf_uid._session
+ asfquart_session = asf_uid.session
case _:
asfquart_session = await asfquart.session.read()
# asfquart_session = await session.read()
diff --git a/atr/web.py b/atr/web.py
index a843fde..da2c0b5 100644
--- a/atr/web.py
+++ b/atr/web.py
@@ -17,16 +17,19 @@
from __future__ import annotations
+import json
import urllib.parse
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
import asfquart.base as base
import asfquart.session as session
+import pydantic_core
import quart
import werkzeug.datastructures.headers
import atr.config as config
import atr.db as db
+import atr.form as form
import atr.htm as htm
import atr.models.sql as sql
import atr.user as user
@@ -35,9 +38,9 @@ import atr.util as util
if TYPE_CHECKING:
from collections.abc import Awaitable, Sequence
+ import pydantic
import werkzeug.wrappers.response as response
-
R = TypeVar("R", covariant=True)
type WerkzeugResponse = response.Response
@@ -58,19 +61,25 @@ class Committer:
"""Session with extra information about committers."""
def __init__(self, web_session: session.ClientSession) -> None:
- self._projects: list[sql.Project] | None = None
- self._session = web_session
+ self.__form_cls: type[form.Form] | None = None
+ self.__form_data: dict[str, Any] | None = None
+ self.__projects: list[sql.Project] | None = None
+ self.session = web_session
@property
def asf_uid(self) -> str:
- if self._session.uid is None:
+ if self.session.uid is None:
raise base.ASFQuartException("Not authenticated", errorcode=401)
- return self._session.uid
+ return self.session.uid
def __getattr__(self, name: str) -> Any:
# TODO: Not type safe, should subclass properly if possible
# For example, we can access session.no_such_attr and the type checkers won't notice
- return getattr(self._session, name)
+ return getattr(self.session, name)
+
+ @property
+ def app_host(self) -> str:
+ return config.get().APP_HOST
async def check_access(self, project_name: str) -> None:
if not any((p.name == project_name) for p in (await self.user_projects)):
@@ -93,9 +102,37 @@ class Committer:
return
raise base.ASFQuartException("You do not have access to this committee", errorcode=403)
- @property
- def app_host(self) -> str:
- return config.get().APP_HOST
+ async def form_data(self) -> dict[str, Any]:
+ if self.__form_data is None:
+ self.__form_data = await form.quart_request()
+ # Avoid mutations from writing back to our copy
+ return self.__form_data.copy()
+
+ async def form_error(self, field_name: str, error_msg: str) -> WerkzeugResponse:
+ if self.__form_cls is None:
+ raise ValueError("Form class not set")
+ if self.__form_data is None:
+ raise ValueError("Form data not set")
+ errors = [
+ pydantic_core.ErrorDetails(
+ loc=(field_name,),
+ msg=error_msg,
+ input=self.__form_data[field_name],
+ type="atr_error",
+ )
+ ]
+ flash_data = form.flash_error_data(self.__form_cls, errors, self.__form_data)
+ summary = form.flash_error_summary(errors, flash_data)
+
+ await quart.flash(summary, category="error")
+ await quart.flash(json.dumps(flash_data), category="form-error-data")
+ return quart.redirect(quart.request.path)
+
+ async def form_validate(self, form_cls: type[form.Form], context: dict[str, Any]) -> pydantic.BaseModel:
+ self.__form_cls = form_cls
+ if self.__form_data is None:
+ self.__form_data = await form.quart_request()
+ return form.validate(form_cls, self.__form_data.copy(), context=context)
@property
def host(self) -> str:
@@ -165,7 +202,7 @@ class Committer:
@property
async def user_candidate_drafts(self) -> list[sql.Release]:
- return await user.candidate_drafts(self.uid, user_projects=self._projects)
+ return await user.candidate_drafts(self.uid, user_projects=self.__projects)
# @property
# async def user_committees(self) -> list[models.Committee]:
@@ -173,9 +210,9 @@ class Committer:
@property
async def user_projects(self) -> list[sql.Project]:
- if self._projects is None:
- self._projects = await user.projects(self.uid)
- return self._projects[:]
+ if self.__projects is None:
+ self.__projects = await user.projects(self.uid)
+ return self.__projects[:]
class ElementResponse(quart.Response):
@@ -241,6 +278,10 @@ class ZipResponse(quart.Response):
super().__init__(response, status=status, headers=raw_headers, mimetype="application/zip")
+async def form_error(error: str) -> None:
+ pass
+
+
async def redirect[R](
route: RouteFunction[R], success: str | None = None, error: str | None = None, **kwargs: Any
) -> WerkzeugResponse:
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org