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 6b1dc4a Allow one task to specify multiple results
6b1dc4a is described below
commit 6b1dc4aee1367f880e5f09d783a0c01a9f10fc85
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Mar 28 20:25:12 2025 +0200
Allow one task to specify multiple results
---
atr/blueprints/admin/admin.py | 1 +
atr/db/__init__.py | 37 +++++++++++++++++++
atr/db/models.py | 22 +++++++++++
atr/tasks/archive.py | 11 ++++++
atr/tasks/task.py | 85 ++++++++++++++++++++++++++++++++++++++++++-
atr/util.py | 13 +++++++
6 files changed, 168 insertions(+), 1 deletion(-)
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index f6c501e..267bb69 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -128,6 +128,7 @@ async def admin_data(model: str = "Committee") -> str:
# Map of model names to their classes
# TODO: Add distribution channel, key link, and any others
model_methods: dict[str, Callable[[], db.Query[Any]]] = {
+ "CheckResult": data.check_result,
"Committee": data.committee,
"Package": data.package,
"Project": data.project,
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 55845fa..cda85c4 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -114,6 +114,43 @@ class Query(Generic[T]):
class Session(sqlalchemy.ext.asyncio.AsyncSession):
# TODO: Need to type all of these arguments correctly
+
+ def check_result(
+ self,
+ id: Opt[int] = NotSet,
+ release_name: Opt[str] = NotSet,
+ checker: Opt[str] = NotSet,
+ path: Opt[str | None] = NotSet,
+ created: Opt[datetime.datetime] = NotSet,
+ status: Opt[models.CheckResultStatus] = NotSet,
+ message: Opt[str] = NotSet,
+ data: Opt[Any] = NotSet,
+ _release: bool = False,
+ ) -> Query[models.CheckResult]:
+ query = sqlmodel.select(models.CheckResult)
+
+ if is_defined(id):
+ query = query.where(models.CheckResult.id == id)
+ if is_defined(release_name):
+ query = query.where(models.CheckResult.release_name ==
release_name)
+ if is_defined(checker):
+ query = query.where(models.CheckResult.checker == checker)
+ if is_defined(path):
+ query = query.where(models.CheckResult.path == path)
+ if is_defined(created):
+ query = query.where(models.CheckResult.created == created)
+ if is_defined(status):
+ query = query.where(models.CheckResult.status == status)
+ if is_defined(message):
+ query = query.where(models.CheckResult.message == message)
+ if is_defined(data):
+ query = query.where(models.CheckResult.data == data)
+
+ if _release:
+ query = query.options(select_in_load(models.CheckResult.release))
+
+ return Query(self, query)
+
def committee(
self,
id: Opt[int] = NotSet,
diff --git a/atr/db/models.py b/atr/db/models.py
index 689f6e2..e27b4a6 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -329,6 +329,9 @@ class Release(sqlmodel.SQLModel, table=True):
back_populates="release", sa_relationship_kwargs={"cascade": "all,
delete-orphan"}
)
+ # One-to-many: A release can have multiple check results
+ check_results: list["CheckResult"] =
sqlmodel.Relationship(back_populates="release")
+
# The combination of project_id and version must be unique
# Technically we want (project.name, version) to be unique
# But project.name is already unique, so project_id works as a proxy
thereof
@@ -353,3 +356,22 @@ class SSHKey(sqlmodel.SQLModel, table=True):
fingerprint: str = sqlmodel.Field(primary_key=True)
key: str
asf_uid: str
+
+
+class CheckResultStatus(str, enum.Enum):
+ EXCEPTION = "exception"
+ FAILURE = "failure"
+ SUCCESS = "success"
+ WARNING = "warning"
+
+
+class CheckResult(sqlmodel.SQLModel, table=True):
+ id: int = sqlmodel.Field(default=None, primary_key=True)
+ release_name: str = sqlmodel.Field(foreign_key="release.name")
+ release: Release = sqlmodel.Relationship(back_populates="check_results")
+ checker: str
+ path: str | None = None
+ created: datetime.datetime
+ status: CheckResultStatus
+ message: str
+ data: Any = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
diff --git a/atr/tasks/archive.py b/atr/tasks/archive.py
index 493d367..6e70b67 100644
--- a/atr/tasks/archive.py
+++ b/atr/tasks/archive.py
@@ -25,6 +25,7 @@ import pydantic
import atr.db.models as models
import atr.tasks.task as task
+import atr.util as util
_LOGGER: Final = logging.getLogger(__name__)
@@ -42,6 +43,16 @@ async def check_integrity(args: dict[str, Any]) ->
tuple[models.TaskStatus, str
# Then we can have a single task wrapper for all tasks
# TODO: We should use task.TaskError as standard, and maybe typeguard each
function
data = CheckIntegrity(**args)
+ # TODO: Check arguments should have release_name and path as standard
+ # Followed by any necessary additional arguments
+ release_name, rel_path = util.abs_path_to_release_and_rel_path(data.path)
+ check = await task.Check.create(checker=check_integrity,
release_name=release_name, path=rel_path)
+ try:
+ size = await asyncio.to_thread(_check_integrity_core, data.path,
data.chunk_size)
+ await check.success("Able to read all entries of the archive using
tarfile", {"size": size})
+ except Exception as e:
+ await check.failure("Unable to read all entries of the archive using
tarfile", {"error": str(e)})
+ return task.FAILED, str(e), ()
task_results = task.results_as_tuple(await
asyncio.to_thread(_check_integrity_core, data.path, data.chunk_size))
_LOGGER.info(f"Verified {data.path} and computed size {task_results[0]}")
return task.COMPLETED, None, task_results
diff --git a/atr/tasks/task.py b/atr/tasks/task.py
index 78dbb74..833e48a 100644
--- a/atr/tasks/task.py
+++ b/atr/tasks/task.py
@@ -15,10 +15,19 @@
# specific language governing permissions and limitations
# under the License.
-from typing import Any, Final
+from __future__ import annotations
+import datetime
+from typing import TYPE_CHECKING, Any, Final
+
+import sqlmodel
+
+import atr.db as db
import atr.db.models as models
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
QUEUED: Final = models.TaskStatus.QUEUED
ACTIVE: Final = models.TaskStatus.ACTIVE
COMPLETED: Final = models.TaskStatus.COMPLETED
@@ -33,6 +42,80 @@ class Error(Exception):
self.result = tuple(result)
+class Check:
+ def __init__(
+ self, checker: Callable[..., Any], release_name: str, path: str | None
= None, afresh: bool = True
+ ) -> None:
+ self.checker = checker.__module__ + "." + checker.__name__
+ self.release_name = release_name
+ self.path = path
+ self.afresh = afresh
+ self._constructed = False
+
+ @classmethod
+ async def create(
+ cls, checker: Callable[..., Any], release_name: str, path: str | None
= None, afresh: bool = True
+ ) -> Check:
+ check = cls(checker, release_name, path, afresh)
+ if afresh is True:
+ # Clear outer path whether it's specified or not
+ await check._clear(path)
+ check._constructed = True
+ return check
+
+ async def _add(
+ self, status: models.CheckResultStatus, message: str, data: Any, path:
str | None = None
+ ) -> models.CheckResult:
+ if self._constructed is False:
+ raise RuntimeError("Cannot add check result to a check that has
not been constructed")
+ if path is not None:
+ if self.path is not None:
+ raise ValueError("Cannot specify path twice")
+ if self.afresh is True:
+ # Clear inner path only if it's specified
+ await self._clear(path)
+
+ result = models.CheckResult(
+ release_name=self.release_name,
+ checker=self.checker,
+ path=path or self.path,
+ created=datetime.datetime.now(),
+ status=status,
+ message=message,
+ data=data,
+ )
+
+ # It would be more efficient to keep a session open
+ # But, we prefer in this case to maintain a simpler interface
+ # If performance is unacceptable, we can revisit this design
+ async with db.session() as session:
+ session.add(result)
+ await session.commit()
+ return result
+
+ async def _clear(self, path: str | None = None) -> None:
+ async with db.session() as data:
+ stmt = sqlmodel.delete(models.CheckResult).where(
+
db.validate_instrumented_attribute(models.CheckResult.release_name) ==
self.release_name,
+ db.validate_instrumented_attribute(models.CheckResult.checker)
== self.checker,
+ db.validate_instrumented_attribute(models.CheckResult.path) ==
path,
+ )
+ await data.execute(stmt)
+ await data.commit()
+
+ async def exception(self, message: str, data: Any, path: str | None =
None) -> models.CheckResult:
+ return await self._add(models.CheckResultStatus.EXCEPTION, message,
data, path=path)
+
+ async def failure(self, message: str, data: Any, path: str | None = None)
-> models.CheckResult:
+ return await self._add(models.CheckResultStatus.FAILURE, message,
data, path=path)
+
+ async def success(self, message: str, data: Any, path: str | None = None)
-> models.CheckResult:
+ return await self._add(models.CheckResultStatus.SUCCESS, message,
data, path=path)
+
+ async def warning(self, message: str, data: Any, path: str | None = None)
-> models.CheckResult:
+ return await self._add(models.CheckResultStatus.WARNING, message,
data, path=path)
+
+
def results_as_tuple(item: Any) -> tuple[Any, ...]:
"""Ensure that returned results are structured as a tuple."""
if not isinstance(item, tuple):
diff --git a/atr/util.py b/atr/util.py
index 539b1b0..260df0c 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -92,6 +92,19 @@ class QuartFormTyped(quart_wtf.QuartForm):
return form
+def abs_path_to_release_and_rel_path(abs_path: str) -> tuple[str, str]:
+ """Return the release name and relative path for a given path."""
+ conf = config.get()
+ phase_dir = pathlib.Path(conf.PHASE_STORAGE_DIR)
+ phase_sub_dir = pathlib.Path(abs_path).relative_to(phase_dir)
+ # Skip the first component, which is the phase name
+ # The next two components are the project name and version name
+ project_name = phase_sub_dir.parts[1]
+ version_name = phase_sub_dir.parts[2]
+ release_name = f"{project_name}-{version_name}"
+ return release_name, str(pathlib.Path(*phase_sub_dir.parts[3:]))
+
+
def as_url(func: Callable, **kwargs: Any) -> str:
"""Return the URL for a function."""
return quart.url_for(func.__annotations__["endpoint"], **kwargs)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]