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]