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-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 9ff9014  Make distribution details optional, and allow outcome pattern 
matching
9ff9014 is described below

commit 9ff9014d2f4ff9f7894be30a29a1a0ff8df4227d
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Aug 6 19:56:17 2025 +0100

    Make distribution details optional, and allow outcome pattern matching
---
 atr/forms.py             |  25 ++++++++++++
 atr/models/basic.py      |  28 +++++++++++++
 atr/routes/distribute.py | 103 +++++++++++++++++++++++++++++------------------
 atr/storage/__init__.py  |   5 ++-
 atr/storage/types.py     |  53 ++++++++++++++----------
 5 files changed, 150 insertions(+), 64 deletions(-)

diff --git a/atr/forms.py b/atr/forms.py
index cb903dd..02e9f9c 100644
--- a/atr/forms.py
+++ b/atr/forms.py
@@ -102,6 +102,18 @@ def boolean(
     return wtforms.BooleanField(label, **kwargs)
 
 
+def checkbox(
+    label: str, optional: bool = False, validators: list[Any] | None = None, 
**kwargs: Any
+) -> wtforms.BooleanField:
+    if validators is None:
+        validators = []
+    if optional is False:
+        validators.append(REQUIRED_DATA)
+    else:
+        validators.append(OPTIONAL)
+    return wtforms.BooleanField(label, **kwargs)
+
+
 def checkboxes(
     label: str, optional: bool = False, validators: list[Any] | None = None, 
**kwargs: Any
 ) -> wtforms.SelectMultipleField:
@@ -450,11 +462,24 @@ def _render_elements(
             field_elements.append((label, widget))
             continue
 
+        # wtforms.SubmitField is a subclass of wtforms.BooleanField
+        # So we need to check for it before BooleanField
         if isinstance(field, wtforms.SubmitField):
             button_class = "btn " + submit_classes
             submit_element = markupsafe.Markup(str(field(class_=button_class)))
             continue
 
+        if isinstance(field, wtforms.BooleanField):
+            # Replacing col-form-label with form-check-label moves the label up
+            # This aligns it properly with the checkbox
+            # TODO: Move the widget down instead of the label up
+            classes = label_classes.replace("col-form-label", 
"form-check-label")
+            label = markupsafe.Markup(str(field.label(class_=classes)))
+            widget = markupsafe.Markup(str(field(class_="form-check-input")))
+            field_elements.append((label, widget))
+            # TODO: Errors and description
+            continue
+
         raise TypeError(f"Unsupported field type: {type(field).__name__}")
 
     return Elements(hidden_elements, field_elements, submit_element)
diff --git a/atr/models/basic.py b/atr/models/basic.py
new file mode 100644
index 0000000..748d1cc
--- /dev/null
+++ b/atr/models/basic.py
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any, Final
+
+import pydantic
+
+type JSON = pydantic.JsonValue
+
+_JSON_TYPE_ADAPTER: Final[pydantic.TypeAdapter[JSON]] = 
pydantic.TypeAdapter(JSON)
+
+
+def as_json(value: Any) -> JSON:
+    return _JSON_TYPE_ADAPTER.validate_python(value)
diff --git a/atr/routes/distribute.py b/atr/routes/distribute.py
index 7c056d7..0f0c6bf 100644
--- a/atr/routes/distribute.py
+++ b/atr/routes/distribute.py
@@ -29,9 +29,11 @@ import quart
 import atr.db as db
 import atr.forms as forms
 import atr.htm as htm
+import atr.models.basic as basic
 import atr.models.schema as schema
 import atr.models.sql as sql
 import atr.routes as routes
+import atr.storage.types as types
 import atr.template as template
 
 
@@ -81,15 +83,15 @@ class Platform(enum.Enum):
 
 class DistributeForm(forms.Typed):
     platform = forms.select("Platform", choices=Platform)
-    owner_namespace = forms.string(
+    owner_namespace = forms.optional(
         "Owner or Namespace",
-        optional=True,
-        placeholder="E.g. com.example or @scope or library",
+        placeholder="E.g. com.example or scope or library",
         description="Who owns or names the package (Maven groupId, npm @scope, 
"
         "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
     )
     package = forms.string("Package", placeholder="E.g. artifactId or 
package-name")
     version = forms.string("Version", placeholder="E.g. 1.2.3, without a 
leading v")
+    details = forms.checkbox("Include details", description="Include the 
details of the distribution in the response")
     submit = forms.submit()
 
     async def validate(self, extra_validators: dict | None = None) -> bool:
@@ -114,6 +116,23 @@ class DistributeForm(forms.Typed):
         return True
 
 
+# Lax to ignore csrf_token and submit
+# WTForms types platform as Any, which is insufficient
+# And this way we also get nice JSON from the Pydantic model dump
+# Including all of the enum properties
+class DistributeData(schema.Lax):
+    platform: Platform
+    owner_namespace: str | None = None
+    package: str
+    version: str
+    details: bool
+
+    @pydantic.field_validator("owner_namespace", mode="before")
+    @classmethod
+    def empty_to_none(cls, v):
+        return None if v is None or (isinstance(v, str) and v.strip() == "") 
else v
+
+
 @routes.committer("/distribute/<project>/<version>", methods=["GET"])
 async def distribute(session: routes.CommitterSession, project: str, version: 
str) -> str:
     form = await DistributeForm.create_form(data={"package": project, 
"version": version})
@@ -158,61 +177,65 @@ async def _distribute_page(*, project: str, version: str, 
form: DistributeForm)
     return await template.blank("Distribute", content=content)
 
 
-# Lax to ignore csrf_token and submit
-class Data(schema.Lax):
-    platform: Platform
-    owner_namespace: str | None = None
-    package: str
-    version: str
-
-    @pydantic.field_validator("owner_namespace", mode="before")
-    @classmethod
-    def empty_to_none(cls, v):
-        return None if v is None or (isinstance(v, str) and v.strip() == "") 
else v
+async def _distribute_post_api(api_url: str) -> types.Outcome[basic.JSON]:
+    try:
+        async with aiohttp.ClientSession() as session:
+            async with session.get(api_url) as response:
+                response.raise_for_status()
+                response_json = await response.json()
+        return types.OutcomeResult(basic.as_json(response_json))
+    except aiohttp.ClientError as e:
+        # Can be 404
+        return types.OutcomeException(e)
 
 
 async def _distribute_post_validated(form: DistributeForm) -> str:
+    dd = DistributeData.model_validate(form.data)
+    api_url = form.platform.data.value.template_url.format(
+        owner_namespace=dd.owner_namespace,
+        package=dd.package,
+        version=dd.version,
+    )
+
     block = htm.Block()
 
-    # Submitted values
-    block.h2["Submitted values"]
-    data = Data.model_validate(form.data)
-    _distribute_post_table(block, data)
+    if dd.details:
+        ## Details
+        block.h2["Details"]
 
-    # As JSON
-    block.h2["As JSON"]
-    block.pre[data.model_dump_json(indent=2)]
+        ### Submitted values
+        block.h3["Submitted values"]
+        _distribute_post_table(block, dd)
 
-    # API URL
-    block.h2["API URL"]
-    api_url = form.platform.data.value.template_url.format(
-        owner_namespace=data.owner_namespace,
-        package=data.package,
-        version=data.version,
-    )
-    block.pre[api_url]
+        ### As JSON
+        block.h3["As JSON"]
+        block.pre[dd.model_dump_json(indent=2)]
+
+        ### API URL
+        block.h3["API URL"]
+        block.pre[api_url]
 
-    # API response
+    ## API response
     block.h2["API response"]
-    async with aiohttp.ClientSession() as session:
-        async with session.get(api_url) as response:
-            response.raise_for_status()
-            json_results = await response.json()
-    block.pre[json.dumps(json_results, indent=2)]
+    match await _distribute_post_api(api_url):
+        case types.OutcomeResult(result):
+            block.pre[json.dumps(result, indent=2)]
+        case types.OutcomeException(exception):
+            block.pre[f"Error: {exception}"]
 
     content = _page("Distribution submitted", block.collect())
     return await template.blank("Distribution submitted", content=content)
 
 
-def _distribute_post_table(block: htm.Block, data: Data) -> None:
+def _distribute_post_table(block: htm.Block, dd: DistributeData) -> None:
     def row(label: str, value: str) -> htpy.Element:
         return htpy.tr[htpy.th[label], htpy.td[value]]
 
     tbody = htpy.tbody[
-        row("Platform", data.platform.name),
-        row("Owner or Namespace", data.owner_namespace or "(blank)"),
-        row("Package", data.package),
-        row("Version", data.version),
+        row("Platform", dd.platform.name),
+        row("Owner or Namespace", dd.owner_namespace or "(blank)"),
+        row("Package", dd.package),
+        row("Version", dd.version),
     ]
     block.table(".table.table-striped.table-bordered")[tbody]
 
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index f174a5c..c4cbcc4 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
 
 import atr.db as db
 import atr.log as log
+import atr.models.basic as basic
 import atr.models.sql as sql
 import atr.principal as principal
 import atr.storage.readers as readers
@@ -42,7 +43,7 @@ import atr.user as user
 
 # Do not rename this interface
 # It is named to reserve the atr.storage.audit logger name
-def audit(**kwargs: types.JSON) -> None:
+def audit(**kwargs: basic.JSON) -> None:
     now = 
datetime.datetime.now(datetime.UTC).isoformat(timespec="milliseconds")
     now = now.replace("+00:00", "Z")
     action = log.caller_name(depth=2)
@@ -55,7 +56,7 @@ def audit(**kwargs: types.JSON) -> None:
 
 
 class AccessAs:
-    def log_auditable_event(self, **kwargs: types.JSON) -> None:
+    def log_auditable_event(self, **kwargs: basic.JSON) -> None:
         audit(**kwargs)
 
 
diff --git a/atr/storage/types.py b/atr/storage/types.py
index 8acf7ea..7f8884e 100644
--- a/atr/storage/types.py
+++ b/atr/storage/types.py
@@ -31,24 +31,25 @@ T = TypeVar("T", bound=object)
 
 
 class OutcomeResult[T]:
+    __match_args__ = ("_result",)
     __result: T
 
     def __init__(self, result: T, name: str | None = None):
         self.__result = result
         self.__name = name
 
-    @property
-    def ok(self) -> bool:
-        return True
-
     @property
     def name(self) -> str | None:
         return self.__name
 
-    def result_or_none(self) -> T | None:
-        return self.__result
+    @property
+    def ok(self) -> bool:
+        return True
 
-    def result_or_raise(self, exception_class: type[Exception] | None = None) 
-> T:
+    @property
+    def _result(self) -> T:
+        # This is only available on OutcomeResult
+        # It is intended for pattern matching only
         return self.__result
 
     def exception_or_none(self) -> Exception | None:
@@ -62,29 +63,34 @@ class OutcomeResult[T]:
     def exception_type_or_none(self) -> type[Exception] | None:
         return None
 
+    def result_or_none(self) -> T | None:
+        return self.__result
+
+    def result_or_raise(self, exception_class: type[Exception] | None = None) 
-> T:
+        return self.__result
+
 
 class OutcomeException[T, E: Exception = Exception]:
+    __match_args__ = ("_exception",)
     __exception: E
 
     def __init__(self, exception: E, name: str | None = None):
         self.__exception = exception
         self.__name = name
 
-    @property
-    def ok(self) -> bool:
-        return False
-
     @property
     def name(self) -> str | None:
         return self.__name
 
-    def result_or_none(self) -> T | None:
-        return None
+    @property
+    def ok(self) -> bool:
+        return False
 
-    def result_or_raise(self, exception_class: type[Exception] | None = None) 
-> NoReturn:
-        if exception_class is not None:
-            raise exception_class(str(self.__exception)) from self.__exception
-        raise self.__exception
+    @property
+    def _exception(self) -> E:
+        # This is only available on OutcomeException
+        # It is intended for pattern matching only
+        return self.__exception
 
     def exception_or_none(self) -> E | None:
         return self.__exception
@@ -97,6 +103,14 @@ class OutcomeException[T, E: Exception = Exception]:
     def exception_type_or_none(self) -> type[E] | None:
         return type(self.__exception)
 
+    def result_or_none(self) -> T | None:
+        return None
+
+    def result_or_raise(self, exception_class: type[Exception] | None = None) 
-> NoReturn:
+        if exception_class is not None:
+            raise exception_class(str(self.__exception)) from self.__exception
+        raise self.__exception
+
 
 type Outcome[T, E: Exception = Exception] = OutcomeResult[T] | 
OutcomeException[T, E]
 
@@ -226,11 +240,6 @@ class Outcomes[T, E: Exception = Exception]:
                     self.__outcomes[i] = OutcomeResult[T](result, outcome.name)
 
 
-# TODO: Or we could use pydantic.types.JsonValue
-# Also this should be moved to models, so that the client can also use it
-type JSON = None | bool | int | float | str | list[JSON] | dict[str, JSON]
-
-
 @dataclasses.dataclass
 class CheckResults:
     primary_results_list: list[sql.CheckResult]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to