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 51094de  Move outcome types to their own module
51094de is described below

commit 51094dee2d57e52e2cde1fab8d4b73712c520046
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Aug 6 20:18:37 2025 +0100

    Move outcome types to their own module
---
 atr/blueprints/admin/admin.py |   9 +-
 atr/blueprints/api/api.py     |  28 ++---
 atr/routes/distribute.py      |  12 +--
 atr/routes/keys.py            |  29 +++---
 atr/storage/__init__.py       |  64 ++++++------
 atr/storage/outcome.py        | 230 ++++++++++++++++++++++++++++++++++++++++++
 atr/storage/types.py          | 221 +---------------------------------------
 atr/storage/writers/keys.py   |  69 ++++++-------
 docs/storage-interface.html   |   4 +-
 docs/storage-interface.md     |   4 +-
 10 files changed, 344 insertions(+), 326 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index be8741d..cda0249 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -46,6 +46,7 @@ import atr.log as log
 import atr.models.sql as sql
 import atr.routes.mapping as mapping
 import atr.storage as storage
+import atr.storage.outcome as outcome
 import atr.storage.types as types
 import atr.template as template
 import atr.util as util
@@ -417,7 +418,7 @@ async def admin_keys_regenerate_all() -> quart.Response:
     if asf_uid is None:
         raise base.ASFQuartException("Invalid session, uid is None", 500)
 
-    outcomes = types.Outcomes[str]()
+    outcomes = outcome.List[str]()
     async with storage.write() as write:
         for committee_name in committee_names:
             wacm_outcome = write.as_committee_member_outcome(committee_name)
@@ -429,7 +430,7 @@ async def admin_keys_regenerate_all() -> quart.Response:
     response_lines = []
     for ocr in outcomes.results():
         response_lines.append(f"Regenerated: {ocr}")
-    for oce in outcomes.exceptions():
+    for oce in outcomes.errors():
         response_lines.append(f"Error regenerating: {type(oce).__name__} 
{oce}")
 
     return quart.Response("\n".join(response_lines), mimetype="text/plain")
@@ -668,12 +669,12 @@ async def admin_test() -> 
quart.wrappers.response.Response:
     async with storage.write() as write:
         wacm = write.as_committee_member("tooling")
         start = time.perf_counter_ns()
-        outcomes: types.Outcomes[types.Key] = await 
wacm.keys.ensure_stored(keys_file_text)
+        outcomes: outcome.List[types.Key] = await 
wacm.keys.ensure_stored(keys_file_text)
         end = time.perf_counter_ns()
         log.info(f"Upload of {outcomes.result_count} keys took {end - start} 
ns")
     for ocr in outcomes.results():
         log.info(f"Uploaded key: {type(ocr)} {ocr.key_model.fingerprint}")
-    for oce in outcomes.exceptions():
+    for oce in outcomes.errors():
         log.error(f"Error uploading key: {type(oce)} {oce}")
     parsed_count = outcomes.result_predicate_count(lambda k: k.status == 
types.KeyStatus.PARSED)
     inserted_count = outcomes.result_predicate_count(lambda k: k.status == 
types.KeyStatus.INSERTED)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index a5eae63..a252770 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -47,6 +47,7 @@ import atr.routes.start as start
 import atr.routes.vote as vote
 import atr.routes.voting as voting
 import atr.storage as storage
+import atr.storage.outcome as outcome
 import atr.storage.types as types
 import atr.tabulate as tabulate
 import atr.tasks.vote as tasks_vote
@@ -365,15 +366,15 @@ async def key_add(data: models.api.KeyAddArgs) -> 
DictResponse:
 
     async with storage.write(asf_uid) as write:
         wafc = write.as_foundation_committer()
-        ocr: types.Outcome[types.Key] = await 
wafc.keys.ensure_stored_one(data.key)
+        ocr: outcome.Outcome[types.Key] = await 
wafc.keys.ensure_stored_one(data.key)
         key = ocr.result_or_raise()
 
         for selected_committee_name in selected_committee_names:
             wacm = write.as_committee_member(selected_committee_name)
-            outcome: types.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
+            oc: outcome.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
                 key.key_model.fingerprint
             )
-            outcome.result_or_raise()
+            oc.result_or_raise()
 
     return models.api.KeyAddResults(
         endpoint="/key/add",
@@ -395,11 +396,11 @@ async def key_delete(data: models.api.KeyDeleteArgs) -> 
DictResponse:
     asf_uid = _jwt_asf_uid()
     fingerprint = data.fingerprint.lower()
 
-    outcomes = types.Outcomes[str]()
+    outcomes = outcome.List[str]()
     async with storage.write(asf_uid) as write:
         wafc = write.as_foundation_committer()
-        outcome: types.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
-        key = outcome.result_or_raise()
+        oc: outcome.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
+        key = oc.result_or_raise()
 
         for committee in key.committees:
             wacm = 
write.as_committee_member_outcome(committee.name).result_or_none()
@@ -447,25 +448,24 @@ async def keys_upload(data: models.api.KeysUploadArgs) -> 
DictResponse:
     selected_committee_name = data.committee
     async with storage.write(asf_uid) as write:
         wacm = write.as_committee_member(selected_committee_name)
-        outcomes: types.Outcomes[types.Key] = await 
wacm.keys.ensure_associated(filetext)
+        outcomes: outcome.List[types.Key] = await 
wacm.keys.ensure_associated(filetext)
 
         # TODO: It would be nice to serialise the actual outcomes
         # Or, perhaps better yet, to have a standard datatype mapping
         # This would be specified in models.api, then imported into 
storage.types
         # Or perhaps it should go in models.storage or models.outcomes
         api_outcomes = []
-        for outcome in outcomes.outcomes():
+        for oc in outcomes.outcomes():
             api_outcome: models.api.KeysUploadOutcome | None = None
-            match outcome:
-                case types.OutcomeResult() as ocr:
-                    result: types.Key = ocr.result_or_raise()
+            match oc:
+                case outcome.Result(result):
                     api_outcome = models.api.KeysUploadResult(
                         status="success",
                         key=result.key_model,
                     )
-                case types.OutcomeException() as oce:
+                case outcome.Error(error):
                     # TODO: This branch means we must improve the return type
-                    match oce.exception_or_none():
+                    match error:
                         case types.PublicKeyError() as pke:
                             api_outcome = models.api.KeysUploadException(
                                 status="error",
@@ -486,7 +486,7 @@ async def keys_upload(data: models.api.KeysUploadArgs) -> 
DictResponse:
         endpoint="/keys/upload",
         results=api_outcomes,
         success_count=outcomes.result_count,
-        error_count=outcomes.exception_count,
+        error_count=outcomes.error_count,
         submitted_committee=selected_committee_name,
     ).model_dump(), 200
 
diff --git a/atr/routes/distribute.py b/atr/routes/distribute.py
index 0f0c6bf..9fb2d1b 100644
--- a/atr/routes/distribute.py
+++ b/atr/routes/distribute.py
@@ -33,7 +33,7 @@ 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.storage.outcome as outcome
 import atr.template as template
 
 
@@ -177,16 +177,16 @@ async def _distribute_page(*, project: str, version: str, 
form: DistributeForm)
     return await template.blank("Distribute", content=content)
 
 
-async def _distribute_post_api(api_url: str) -> types.Outcome[basic.JSON]:
+async def _distribute_post_api(api_url: str) -> outcome.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))
+        return outcome.Result(basic.as_json(response_json))
     except aiohttp.ClientError as e:
         # Can be 404
-        return types.OutcomeException(e)
+        return outcome.Error(e)
 
 
 async def _distribute_post_validated(form: DistributeForm) -> str:
@@ -218,9 +218,9 @@ async def _distribute_post_validated(form: DistributeForm) 
-> str:
     ## API response
     block.h2["API response"]
     match await _distribute_post_api(api_url):
-        case types.OutcomeResult(result):
+        case outcome.Result(result):
             block.pre[json.dumps(result, indent=2)]
-        case types.OutcomeException(exception):
+        case outcome.Error(exception):
             block.pre[f"Error: {exception}"]
 
     content = _page("Distribution submitted", block.collect())
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index c815b40..64ff85f 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -40,6 +40,7 @@ import atr.models.sql as sql
 import atr.routes as routes
 import atr.routes.compose as compose
 import atr.storage as storage
+import atr.storage.outcome as outcome
 import atr.storage.types as types
 import atr.template as template
 import atr.user as user
@@ -162,17 +163,17 @@ async def add(session: routes.CommitterSession) -> str:
 
             async with storage.write() as write:
                 wafc = write.as_foundation_committer()
-                ocr: types.Outcome[types.Key] = await 
wafc.keys.ensure_stored_one(key_text)
+                ocr: outcome.Outcome[types.Key] = await 
wafc.keys.ensure_stored_one(key_text)
                 key = ocr.result_or_raise()
 
                 for selected_committee_name in selected_committee_names:
                     # TODO: Should this be committee member or committee 
participant?
                     # Also, should we emit warnings and continue here?
                     wacp = 
write.as_committee_participant(selected_committee_name)
-                    outcome: types.Outcome[types.LinkedCommittee] = await 
wacp.keys.associate_fingerprint(
+                    oc: outcome.Outcome[types.LinkedCommittee] = await 
wacp.keys.associate_fingerprint(
                         key.key_model.fingerprint
                     )
-                    outcome.result_or_raise()
+                    oc.result_or_raise()
 
                 await quart.flash(f"OpenPGP key 
{key.key_model.fingerprint.upper()} added successfully.", "success")
             # Clear form data on success by creating a new empty form instance
@@ -222,12 +223,12 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
         wafc = write.as_foundation_committer_outcome().result_or_none()
         if wafc is None:
             return await session.redirect(keys, error="Key not found or not 
owned by you")
-        outcome: types.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
-        match outcome:
-            case types.OutcomeResult():
+        oc: outcome.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
+        match oc:
+            case outcome.Result():
                 return await session.redirect(keys, success="Key deleted 
successfully")
-            case types.OutcomeException():
-                return await session.redirect(keys, error=f"Error deleting 
key: {outcome.exception_or_raise()}")
+            case outcome.Error(error):
+                return await session.redirect(keys, error=f"Error deleting 
key: {error}")
 
     return await session.redirect(keys, error="Key not found or not owned by 
you")
 
@@ -307,11 +308,11 @@ async def import_selected_revision(
 
     async with storage.write() as write:
         wacm = await write.as_project_committee_member(project_name)
-        outcomes: types.Outcomes[types.Key] = await 
wacm.keys.import_keys_file(project_name, version_name)
+        outcomes: outcome.List[types.Key] = await 
wacm.keys.import_keys_file(project_name, version_name)
 
     message = f"Uploaded {outcomes.result_count} keys,"
-    if outcomes.exception_count > 0:
-        message += f" failed to upload {outcomes.exception_count} keys for 
{wacm.committee_name}"
+    if outcomes.error_count > 0:
+        message += f" failed to upload {outcomes.error_count} keys for 
{wacm.committee_name}"
     return await session.redirect(
         compose.selected,
         success=message,
@@ -438,11 +439,11 @@ async def update_committee_keys(session: 
routes.CommitterSession, committee_name
     async with storage.write() as write:
         wacm = write.as_committee_member(committee_name)
         match await wacm.keys.autogenerate_keys_file():
-            case types.OutcomeResult():
+            case outcome.Result():
                 await quart.flash(
                     f'Successfully regenerated the KEYS file for the 
"{committee_name}" committee.', "success"
                 )
-            case types.OutcomeException():
+            case outcome.Error():
                 await quart.flash(f"Error regenerating the KEYS file for the 
{committee_name} committee.", "error")
 
     return await session.redirect(keys)
@@ -474,7 +475,7 @@ async def upload(session: routes.CommitterSession) -> str:
         )
 
     form = await UploadKeyForm.create_form()
-    results: types.Outcomes[types.Key] | None = None
+    results: outcome.List[types.Key] | None = None
 
     async def render(
         error: str | None = None,
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index c4cbcc4..ccc2d14 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -31,8 +31,8 @@ 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.outcome as outcome
 import atr.storage.readers as readers
-import atr.storage.types as types
 import atr.storage.writers as writers
 import atr.user as user
 
@@ -111,22 +111,22 @@ class Read:
     def as_foundation_committer(self) -> ReadAsFoundationCommitter:
         return self.as_foundation_committer_outcome().result_or_raise()
 
-    def as_foundation_committer_outcome(self) -> 
types.Outcome[ReadAsFoundationCommitter]:
+    def as_foundation_committer_outcome(self) -> 
outcome.Outcome[ReadAsFoundationCommitter]:
         try:
             rafc = ReadAsFoundationCommitter(self, self.__data)
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(rafc)
+            return outcome.Error(e)
+        return outcome.Result(rafc)
 
     def as_general_public(self) -> ReadAsGeneralPublic:
         return self.as_general_public_outcome().result_or_raise()
 
-    def as_general_public_outcome(self) -> types.Outcome[ReadAsGeneralPublic]:
+    def as_general_public_outcome(self) -> 
outcome.Outcome[ReadAsGeneralPublic]:
         try:
             ragp = ReadAsGeneralPublic(self, self.__data)
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(ragp)
+            return outcome.Error(e)
+        return outcome.Result(ragp)
 
 
 # Write
@@ -208,58 +208,58 @@ class Write:
     def as_committee_member(self, committee_name: str) -> 
WriteAsCommitteeMember:
         return 
self.as_committee_member_outcome(committee_name).result_or_raise()
 
-    def as_committee_member_outcome(self, committee_name: str) -> 
types.Outcome[WriteAsCommitteeMember]:
+    def as_committee_member_outcome(self, committee_name: str) -> 
outcome.Outcome[WriteAsCommitteeMember]:
         if self.__authorisation.asf_uid is None:
-            return types.OutcomeException(AccessError("No ASF UID"))
+            return outcome.Error(AccessError("No ASF UID"))
         if not self.__authorisation.is_member_of(committee_name):
-            return types.OutcomeException(
+            return outcome.Error(
                 AccessError(f"ASF UID {self.__authorisation.asf_uid} is not a 
member of {committee_name}")
             )
         try:
             wacm = WriteAsCommitteeMember(self, self.__data, committee_name)
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(wacm)
+            return outcome.Error(e)
+        return outcome.Result(wacm)
 
     def as_committee_participant(self, committee_name: str) -> 
WriteAsCommitteeParticipant:
         return 
self.as_committee_participant_outcome(committee_name).result_or_raise()
 
-    def as_committee_participant_outcome(self, committee_name: str) -> 
types.Outcome[WriteAsCommitteeParticipant]:
+    def as_committee_participant_outcome(self, committee_name: str) -> 
outcome.Outcome[WriteAsCommitteeParticipant]:
         if self.__authorisation.asf_uid is None:
-            return types.OutcomeException(AccessError("No ASF UID"))
+            return outcome.Error(AccessError("No ASF UID"))
         if not self.__authorisation.is_participant_of(committee_name):
-            return types.OutcomeException(AccessError(f"Not a participant of 
{committee_name}"))
+            return outcome.Error(AccessError(f"Not a participant of 
{committee_name}"))
         try:
             wacp = WriteAsCommitteeParticipant(self, self.__data, 
committee_name)
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(wacp)
+            return outcome.Error(e)
+        return outcome.Result(wacp)
 
     def as_foundation_committer(self) -> WriteAsFoundationCommitter:
         return self.as_foundation_committer_outcome().result_or_raise()
 
-    def as_foundation_committer_outcome(self) -> 
types.Outcome[WriteAsFoundationCommitter]:
+    def as_foundation_committer_outcome(self) -> 
outcome.Outcome[WriteAsFoundationCommitter]:
         if self.__authorisation.asf_uid is None:
-            return types.OutcomeException(AccessError("No ASF UID"))
+            return outcome.Error(AccessError("No ASF UID"))
         try:
             wafm = WriteAsFoundationCommitter(self, self.__data)
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(wafm)
+            return outcome.Error(e)
+        return outcome.Result(wafm)
 
     def as_foundation_admin(self, committee_name: str) -> 
WriteAsFoundationAdmin:
         return 
self.as_foundation_admin_outcome(committee_name).result_or_raise()
 
-    def as_foundation_admin_outcome(self, committee_name: str) -> 
types.Outcome[WriteAsFoundationAdmin]:
+    def as_foundation_admin_outcome(self, committee_name: str) -> 
outcome.Outcome[WriteAsFoundationAdmin]:
         if self.__authorisation.asf_uid is None:
-            return types.OutcomeException(AccessError("No ASF UID"))
+            return outcome.Error(AccessError("No ASF UID"))
         if not user.is_admin(self.__authorisation.asf_uid):
-            return types.OutcomeException(AccessError("Not an admin"))
+            return outcome.Error(AccessError("Not an admin"))
         try:
             wafa = WriteAsFoundationAdmin(self, self.__data, committee_name)
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(wafa)
+            return outcome.Error(e)
+        return outcome.Result(wafa)
 
     # async def as_key_owner(self) -> types.Outcome[WriteAsKeyOwner]:
     #     ...
@@ -268,21 +268,21 @@ class Write:
         write_as_outcome = await 
self.as_project_committee_member_outcome(project_name)
         return write_as_outcome.result_or_raise()
 
-    async def as_project_committee_member_outcome(self, project_name: str) -> 
types.Outcome[WriteAsCommitteeMember]:
+    async def as_project_committee_member_outcome(self, project_name: str) -> 
outcome.Outcome[WriteAsCommitteeMember]:
         project = await self.__data.project(project_name, 
_committee=True).demand(
             AccessError(f"Project not found: {project_name}")
         )
         if project.committee is None:
-            return types.OutcomeException(AccessError("No committee found for 
project"))
+            return outcome.Error(AccessError("No committee found for project"))
         if self.__authorisation.asf_uid is None:
-            return types.OutcomeException(AccessError("No ASF UID"))
+            return outcome.Error(AccessError("No ASF UID"))
         if not self.__authorisation.is_member_of(project.committee.name):
-            return types.OutcomeException(AccessError(f"Not a member of 
{project.committee.name}"))
+            return outcome.Error(AccessError(f"Not a member of 
{project.committee.name}"))
         try:
             wacm = WriteAsCommitteeMember(self, self.__data, 
project.committee.name)
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(wacm)
+            return outcome.Error(e)
+        return outcome.Result(wacm)
 
     @property
     def member_of(self) -> frozenset[str]:
diff --git a/atr/storage/outcome.py b/atr/storage/outcome.py
new file mode 100644
index 0000000..447b91f
--- /dev/null
+++ b/atr/storage/outcome.py
@@ -0,0 +1,230 @@
+# 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 collections.abc import Callable, Sequence
+from typing import NoReturn, TypeVar
+
+R = TypeVar("R", bound=object)
+E = TypeVar("E", bound=Exception)
+
+
+class Result[R]:
+    __match_args__ = ("_result",)
+    __result: R
+
+    def __init__(self, result: R, name: str | None = None):
+        self.__result = result
+        self.__name = name
+
+    @property
+    def name(self) -> str | None:
+        return self.__name
+
+    @property
+    def ok(self) -> bool:
+        return True
+
+    @property
+    def _result(self) -> R:
+        # This is only available on Result
+        # It is intended for pattern matching only
+        return self.__result
+
+    def error_or_none(self) -> Exception | None:
+        return None
+
+    def error_or_raise(self, exception_class: type[Exception] | None = None) 
-> NoReturn:
+        if exception_class is not None:
+            raise exception_class(f"Asked for error on a result: 
{self.__result}")
+        raise RuntimeError(f"Asked for error on a result: {self.__result}")
+
+    def error_type_or_none(self) -> type[Exception] | None:
+        return None
+
+    def result_or_none(self) -> R | None:
+        return self.__result
+
+    def result_or_raise(self, exception_class: type[Exception] | None = None) 
-> R:
+        return self.__result
+
+
+class Error[R, E: Exception = Exception]:
+    __match_args__ = ("_error",)
+    __error: E
+
+    def __init__(self, error: E, name: str | None = None):
+        self.__error = error
+        self.__name = name
+
+    @property
+    def name(self) -> str | None:
+        return self.__name
+
+    @property
+    def ok(self) -> bool:
+        return False
+
+    @property
+    def _error(self) -> E:
+        # This is only available on Error
+        # It is intended for pattern matching only
+        return self.__error
+
+    def error_or_none(self) -> E | None:
+        return self.__error
+
+    def error_or_raise(self, exception_class: type[E] | None = None) -> 
NoReturn:
+        if exception_class is not None:
+            raise exception_class(str(self.__error)) from self.__error
+        raise self.__error
+
+    def error_type_or_none(self) -> type[E] | None:
+        return type(self.__error)
+
+    def result_or_none(self) -> R | 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.__error)) from self.__error
+        raise self.__error
+
+
+type Outcome[R, E: Exception = Exception] = Result[R] | Error[R, E]
+
+
+class List[R, E: Exception = Exception]:
+    __outcomes: list[Outcome[R, E]]
+
+    def __init__(self, *outcomes: Outcome[R, E]):
+        self.__outcomes = list(outcomes)
+
+    def __str__(self) -> str:
+        return f"Outcomes({self.__outcomes})"
+
+    @property
+    def any_error(self) -> bool:
+        return any((not outcome.ok) for outcome in self.__outcomes)
+
+    @property
+    def any_result(self) -> bool:
+        return any(outcome.ok for outcome in self.__outcomes)
+
+    @property
+    def all_errors(self) -> bool:
+        return all((not outcome.ok) for outcome in self.__outcomes)
+
+    @property
+    def all_results(self) -> bool:
+        return all(outcome.ok for outcome in self.__outcomes)
+
+    def append(self, outcome: Outcome[R, E]) -> None:
+        self.__outcomes.append(outcome)
+
+    def append_error(self, error: E, name: str | None = None) -> None:
+        self.__outcomes.append(Error[R, E](error, name))
+
+    def append_result(self, result: R, name: str | None = None) -> None:
+        self.__outcomes.append(Result[R](result, name))
+
+    def append_roe(self, exception_type: type[E], roe: R | E, name: str | None 
= None) -> None:
+        if isinstance(roe, exception_type):
+            self.__outcomes.append(Error[R, E](roe, name))
+        elif isinstance(roe, Exception):
+            self.__outcomes.append(Error[R, E](exception_type(str(roe)), name))
+        else:
+            self.__outcomes.append(Result[R](roe, name))
+
+    @property
+    def error_count(self) -> int:
+        return sum(1 for outcome in self.__outcomes if isinstance(outcome, 
Error))
+
+    def errors(self) -> list[E]:
+        errors_list = []
+        for outcome in self.__outcomes:
+            if isinstance(outcome, Error):
+                error_or_none = outcome.error_or_none()
+                if error_or_none is not None:
+                    errors_list.append(error_or_none)
+        return errors_list
+
+    def errors_print(self) -> None:
+        for error in self.errors():
+            # traceback.print_exception(error)
+            print(error.__class__.__name__ + ":", error)
+
+    def extend_errors(self, errors: Sequence[E]) -> None:
+        for error in errors:
+            self.append_error(error)
+
+    def extend_results(self, results: Sequence[R]) -> None:
+        for result in results:
+            self.append_result(result)
+
+    def extend_roes(self, exception_type: type[E], roes: Sequence[R | E]) -> 
None:
+        # The name "roe" is short for "result or error"
+        # It looks opaque and jargonistic, but it has an advantage when 
forming plurals
+        # The long form plural is "result or errors", which is ambiguous
+        # I.e. we mean Seq[Result | Error], but it also looks like Result | 
Seq[Error]
+        # The short form, however, encapsulates it so that ROE = Result | Error
+        # Then clearly the short form plural, "roes", means Seq[ROE]
+        for roe in roes:
+            self.append_roe(exception_type, roe)
+
+    def named_results(self) -> dict[str, R]:
+        named = {}
+        for outcome in self.__outcomes:
+            if (outcome.name is not None) and outcome.ok:
+                named[outcome.name] = outcome.result_or_raise()
+        return named
+
+    def names(self) -> list[str | None]:
+        return [outcome.name for outcome in self.__outcomes if (outcome.name 
is not None)]
+
+    # def replace(self, a: R, b: R) -> None:
+    #     for i, outcome in enumerate(self.__outcomes):
+    #         if isinstance(outcome, Result):
+    #             if outcome.result_or_raise() == a:
+    #                 self.__outcomes[i] = Result(b, outcome.name)
+
+    def results_or_raise(self, exception_type: type[Exception] | None = None) 
-> list[R]:
+        return [outcome.result_or_raise(exception_type) for outcome in 
self.__outcomes]
+
+    def results(self) -> list[R]:
+        return [outcome.result_or_raise() for outcome in self.__outcomes if 
outcome.ok]
+
+    @property
+    def result_count(self) -> int:
+        return sum(1 for outcome in self.__outcomes if outcome.ok)
+
+    def outcomes(self) -> list[Outcome[R, E]]:
+        return self.__outcomes
+
+    def result_predicate_count(self, predicate: Callable[[R], bool]) -> int:
+        return sum(
+            1 for outcome in self.__outcomes if isinstance(outcome, Result) 
and predicate(outcome.result_or_raise())
+        )
+
+    def update_roes(self, exception_type: type[E], f: Callable[[R], R]) -> 
None:
+        for i, outcome in enumerate(self.__outcomes):
+            if isinstance(outcome, Result):
+                try:
+                    result = f(outcome.result_or_raise())
+                except exception_type as e:
+                    self.__outcomes[i] = Error[R, E](e, outcome.name)
+                else:
+                    self.__outcomes[i] = Result[R](result, outcome.name)
diff --git a/atr/storage/types.py b/atr/storage/types.py
index 7f8884e..4087674 100644
--- a/atr/storage/types.py
+++ b/atr/storage/types.py
@@ -18,226 +18,11 @@
 import dataclasses
 import enum
 import pathlib
-from collections.abc import Callable, Sequence
-from typing import NoReturn, TypeVar
+from collections.abc import Callable
 
 import atr.models.schema as schema
 import atr.models.sql as sql
-
-# Outcome
-
-E = TypeVar("E", bound=Exception)
-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 name(self) -> str | None:
-        return self.__name
-
-    @property
-    def ok(self) -> bool:
-        return True
-
-    @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:
-        return None
-
-    def exception_or_raise(self, exception_class: type[Exception] | None = 
None) -> NoReturn:
-        if exception_class is not None:
-            raise exception_class(f"Asked for exception on a result: 
{self.__result}")
-        raise RuntimeError(f"Asked for exception on a result: {self.__result}")
-
-    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 name(self) -> str | None:
-        return self.__name
-
-    @property
-    def ok(self) -> bool:
-        return False
-
-    @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
-
-    def exception_or_raise(self, exception_class: type[E] | None = None) -> 
NoReturn:
-        if exception_class is not None:
-            raise exception_class(str(self.__exception)) from self.__exception
-        raise self.__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]
-
-
-class Outcomes[T, E: Exception = Exception]:
-    __outcomes: list[Outcome[T, E]]
-
-    def __init__(self, *outcomes: Outcome[T, E]):
-        self.__outcomes = list(outcomes)
-
-    def __str__(self) -> str:
-        return f"Outcomes({self.__outcomes})"
-
-    @property
-    def any_exception(self) -> bool:
-        return any((not outcome.ok) for outcome in self.__outcomes)
-
-    @property
-    def any_result(self) -> bool:
-        return any(outcome.ok for outcome in self.__outcomes)
-
-    @property
-    def all_exceptions(self) -> bool:
-        return all((not outcome.ok) for outcome in self.__outcomes)
-
-    @property
-    def all_results(self) -> bool:
-        return all(outcome.ok for outcome in self.__outcomes)
-
-    def append(self, outcome: Outcome[T, E]) -> None:
-        self.__outcomes.append(outcome)
-
-    def append_exception(self, exception: E, name: str | None = None) -> None:
-        self.__outcomes.append(OutcomeException[T, E](exception, name))
-
-    def append_result(self, result: T, name: str | None = None) -> None:
-        self.__outcomes.append(OutcomeResult[T](result, name))
-
-    def append_roe(self, exception_type: type[E], roe: T | E, name: str | None 
= None) -> None:
-        if isinstance(roe, exception_type):
-            self.__outcomes.append(OutcomeException[T, E](roe, name))
-        elif isinstance(roe, Exception):
-            self.__outcomes.append(OutcomeException[T, 
E](exception_type(str(roe)), name))
-        else:
-            self.__outcomes.append(OutcomeResult[T](roe, name))
-
-    @property
-    def exception_count(self) -> int:
-        return sum(1 for outcome in self.__outcomes if isinstance(outcome, 
OutcomeException))
-
-    def exceptions(self) -> list[E]:
-        exceptions_list = []
-        for outcome in self.__outcomes:
-            if isinstance(outcome, OutcomeException):
-                exception_or_none = outcome.exception_or_none()
-                if exception_or_none is not None:
-                    exceptions_list.append(exception_or_none)
-        return exceptions_list
-
-    def exceptions_print(self) -> None:
-        for exception in self.exceptions():
-            # traceback.print_exception(exception)
-            print(exception.__class__.__name__ + ":", exception)
-
-    def extend_exceptions(self, exceptions: Sequence[E]) -> None:
-        for exception in exceptions:
-            self.append_exception(exception)
-
-    def extend_results(self, results: Sequence[T]) -> None:
-        for result in results:
-            self.append_result(result)
-
-    def extend_roes(self, exception_type: type[E], roes: Sequence[T | E]) -> 
None:
-        # The name "roe" is short for "result or exception"
-        # It looks opaque and jargonistic, but it has an advantage when 
forming plurals
-        # The long form plural is "result or exceptions", which is ambiguous
-        # I.e. we mean Seq[Result | Exception], but it also looks like Result 
| Seq[Exception]
-        # The short form, however, encapsulates it so that ROE = Result | 
Exception
-        # Then clearly the short form plural, "roes", means Seq[ROE]
-        for roe in roes:
-            self.append_roe(exception_type, roe)
-
-    def named_results(self) -> dict[str, T]:
-        named = {}
-        for outcome in self.__outcomes:
-            if (outcome.name is not None) and outcome.ok:
-                named[outcome.name] = outcome.result_or_raise()
-        return named
-
-    def names(self) -> list[str | None]:
-        return [outcome.name for outcome in self.__outcomes if (outcome.name 
is not None)]
-
-    # def replace(self, a: T, b: T) -> None:
-    #     for i, outcome in enumerate(self.__outcomes):
-    #         if isinstance(outcome, OutcomeResult):
-    #             if outcome.result_or_raise() == a:
-    #                 self.__outcomes[i] = OutcomeResult(b, outcome.name)
-
-    def results_or_raise(self, exception_class: type[Exception] | None = None) 
-> list[T]:
-        return [outcome.result_or_raise(exception_class) for outcome in 
self.__outcomes]
-
-    def results(self) -> list[T]:
-        return [outcome.result_or_raise() for outcome in self.__outcomes if 
outcome.ok]
-
-    @property
-    def result_count(self) -> int:
-        return sum(1 for outcome in self.__outcomes if outcome.ok)
-
-    def outcomes(self) -> list[Outcome[T, E]]:
-        return self.__outcomes
-
-    def result_predicate_count(self, predicate: Callable[[T], bool]) -> int:
-        return sum(
-            1
-            for outcome in self.__outcomes
-            if isinstance(outcome, OutcomeResult) and 
predicate(outcome.result_or_raise())
-        )
-
-    def update_roes(self, exception_type: type[E], f: Callable[[T], T]) -> 
None:
-        for i, outcome in enumerate(self.__outcomes):
-            if isinstance(outcome, OutcomeResult):
-                try:
-                    result = f(outcome.result_or_raise())
-                except exception_type as e:
-                    self.__outcomes[i] = OutcomeException[T, E](e, 
outcome.name)
-                else:
-                    self.__outcomes[i] = OutcomeResult[T](result, outcome.name)
+import atr.storage.outcome as outcome
 
 
 @dataclasses.dataclass
@@ -262,7 +47,7 @@ class Key(schema.Strict):
 @dataclasses.dataclass
 class LinkedCommittee:
     name: str
-    autogenerated_keys_file: Outcome[str]
+    autogenerated_keys_file: outcome.Outcome[str]
 
 
 class PathInfo(schema.Strict):
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index 50f3a76..6b0cdce 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -38,6 +38,7 @@ import atr.db as db
 import atr.log as log
 import atr.models.sql as sql
 import atr.storage as storage
+import atr.storage.outcome as outcome
 import atr.storage.types as types
 import atr.user as user
 import atr.util as util
@@ -101,7 +102,7 @@ class FoundationCommitter(GeneralPublic):
         self.__key_block_models_cache = {}
 
     @performance_async
-    async def delete_key(self, fingerprint: str) -> 
types.Outcome[sql.PublicSigningKey]:
+    async def delete_key(self, fingerprint: str) -> 
outcome.Outcome[sql.PublicSigningKey]:
         try:
             key = await self.__data.public_signing_key(
                 fingerprint=fingerprint,
@@ -115,12 +116,12 @@ class FoundationCommitter(GeneralPublic):
                 if wacm is None:
                     continue
                 await wacm.keys.autogenerate_keys_file()
-            return types.OutcomeResult(key)
+            return outcome.Result(key)
         except Exception as e:
-            return types.OutcomeException(e)
+            return outcome.Error(e)
 
     @performance_async
-    async def ensure_stored_one(self, key_file_text: str) -> 
types.Outcome[types.Key]:
+    async def ensure_stored_one(self, key_file_text: str) -> 
outcome.Outcome[types.Key]:
         return await self.__ensure_one(key_file_text, associate=False)
 
     @performance
@@ -239,7 +240,7 @@ class FoundationCommitter(GeneralPublic):
     async def __database_add_model(
         self,
         key: types.Key,
-    ) -> types.Outcome[types.Key]:
+    ) -> outcome.Outcome[types.Key]:
         via = sql.validate_instrumented_attribute
 
         await self.__data.begin_immediate()
@@ -258,24 +259,24 @@ class FoundationCommitter(GeneralPublic):
 
         await self.__data.commit()
         # TODO: PARSED now acts as "ALREADY_ADDED"
-        return types.OutcomeResult(types.Key(status=types.KeyStatus.INSERTED, 
key_model=key.key_model))
+        return outcome.Result(types.Key(status=types.KeyStatus.INSERTED, 
key_model=key.key_model))
 
     @performance_async
-    async def __ensure_one(self, key_file_text: str, associate: bool = True) 
-> types.Outcome[types.Key]:
+    async def __ensure_one(self, key_file_text: str, associate: bool = True) 
-> outcome.Outcome[types.Key]:
         try:
             key_blocks = util.parse_key_blocks(key_file_text)
         except Exception as e:
-            return types.OutcomeException(e)
+            return outcome.Error(e)
         if len(key_blocks) != 1:
-            return types.OutcomeException(ValueError("Expected one key block, 
got none or multiple"))
+            return outcome.Error(ValueError("Expected one key block, got none 
or multiple"))
         key_block = key_blocks[0]
         try:
             ldap_data = await util.email_to_uid_map()
             key = await asyncio.to_thread(self.__block_model, key_block, 
ldap_data)
         except Exception as e:
-            return types.OutcomeException(e)
-        outcome = await self.__database_add_model(key)
-        return outcome
+            return outcome.Error(e)
+        oc = await self.__database_add_model(key)
+        return oc
 
     @performance_async
     async def __keys_file_format(
@@ -376,7 +377,7 @@ class CommitteeParticipant(FoundationCommitter):
         self.__key_block_models_cache = {}
 
     @performance_async
-    async def associate_fingerprint(self, fingerprint: str) -> 
types.Outcome[types.LinkedCommittee]:
+    async def associate_fingerprint(self, fingerprint: str) -> 
outcome.Outcome[types.LinkedCommittee]:
         via = sql.validate_instrumented_attribute
         link_values = [{"committee_name": self.__committee_name, 
"key_fingerprint": fingerprint}]
         try:
@@ -392,12 +393,12 @@ class CommitteeParticipant(FoundationCommitter):
                 pass
             await self.__data.commit()
         except Exception as e:
-            return types.OutcomeException(e)
+            return outcome.Error(e)
         try:
             autogenerated_outcome = await self.autogenerate_keys_file()
         except Exception as e:
-            return types.OutcomeException(e)
-        return types.OutcomeResult(
+            return outcome.Error(e)
+        return outcome.Result(
             types.LinkedCommittee(
                 name=self.__committee_name,
                 autogenerated_keys_file=autogenerated_outcome,
@@ -407,7 +408,7 @@ class CommitteeParticipant(FoundationCommitter):
     @performance_async
     async def autogenerate_keys_file(
         self,
-    ) -> types.Outcome[str]:
+    ) -> outcome.Outcome[str]:
         try:
             base_downloads_dir = util.get_downloads_dir()
 
@@ -421,7 +422,7 @@ class CommitteeParticipant(FoundationCommitter):
                 committee_keys_dir = base_downloads_dir / self.__committee_name
             committee_keys_path = committee_keys_dir / "KEYS"
         except Exception as e:
-            return types.OutcomeException(e)
+            return outcome.Error(e)
 
         try:
             await asyncio.to_thread(committee_keys_dir.mkdir, parents=True, 
exist_ok=True)
@@ -429,12 +430,12 @@ class CommitteeParticipant(FoundationCommitter):
             await asyncio.to_thread(committee_keys_path.write_text, 
full_keys_file_content, encoding="utf-8")
         except OSError as e:
             error_msg = f"Failed to write KEYS file for committee 
{self.__committee_name}: {e}"
-            return types.OutcomeException(storage.AccessError(error_msg))
+            return outcome.Error(storage.AccessError(error_msg))
         except Exception as e:
             error_msg = f"An unexpected error occurred writing KEYS for 
committee {self.__committee_name}: {e}"
             log.exception(f"An unexpected error occurred writing KEYS for 
committee {self.__committee_name}: {e}")
-            return types.OutcomeException(storage.AccessError(error_msg))
-        return types.OutcomeResult(str(committee_keys_path))
+            return outcome.Error(storage.AccessError(error_msg))
+        return outcome.Result(str(committee_keys_path))
 
     @performance_async
     async def committee(self) -> sql.Committee:
@@ -447,21 +448,21 @@ class CommitteeParticipant(FoundationCommitter):
         return self.__committee_name
 
     @performance_async
-    async def ensure_associated(self, keys_file_text: str) -> 
types.Outcomes[types.Key]:
-        outcomes: types.Outcomes[types.Key] = await 
self.__ensure(keys_file_text, associate=True)
+    async def ensure_associated(self, keys_file_text: str) -> 
outcome.List[types.Key]:
+        outcomes: outcome.List[types.Key] = await 
self.__ensure(keys_file_text, associate=True)
         if outcomes.any_result:
             await self.autogenerate_keys_file()
         return outcomes
 
     @performance_async
-    async def ensure_stored(self, keys_file_text: str) -> 
types.Outcomes[types.Key]:
-        outcomes: types.Outcomes[types.Key] = await 
self.__ensure(keys_file_text, associate=False)
+    async def ensure_stored(self, keys_file_text: str) -> 
outcome.List[types.Key]:
+        outcomes: outcome.List[types.Key] = await 
self.__ensure(keys_file_text, associate=False)
         if outcomes.any_result:
             await self.autogenerate_keys_file()
         return outcomes
 
     @performance_async
-    async def import_keys_file(self, project_name: str, version_name: str) -> 
types.Outcomes[types.Key]:
+    async def import_keys_file(self, project_name: str, version_name: str) -> 
outcome.List[types.Key]:
         import atr.revision as revision
 
         release = await self.__data.release(
@@ -517,8 +518,8 @@ class CommitteeParticipant(FoundationCommitter):
 
     @performance_async
     async def __database_add_models(
-        self, outcomes: types.Outcomes[types.Key], associate: bool = True
-    ) -> types.Outcomes[types.Key]:
+        self, outcomes: outcome.List[types.Key], associate: bool = True
+    ) -> outcome.List[types.Key]:
         # Try to upsert all models and link to the committee in one transaction
         try:
             outcomes = await self.__database_add_models_core(outcomes, 
associate=associate)
@@ -538,9 +539,9 @@ class CommitteeParticipant(FoundationCommitter):
     @performance_async
     async def __database_add_models_core(
         self,
-        outcomes: types.Outcomes[types.Key],
+        outcomes: outcome.List[types.Key],
         associate: bool = True,
-    ) -> types.Outcomes[types.Key]:
+    ) -> outcome.List[types.Key]:
         via = sql.validate_instrumented_attribute
         key_list = outcomes.results()
 
@@ -599,13 +600,13 @@ class CommitteeParticipant(FoundationCommitter):
         return outcomes
 
     @performance_async
-    async def __ensure(self, keys_file_text: str, associate: bool = True) -> 
types.Outcomes[types.Key]:
-        outcomes = types.Outcomes[types.Key]()
+    async def __ensure(self, keys_file_text: str, associate: bool = True) -> 
outcome.List[types.Key]:
+        outcomes = outcome.List[types.Key]()
         try:
             ldap_data = await util.email_to_uid_map()
             key_blocks = util.parse_key_blocks(keys_file_text)
         except Exception as e:
-            outcomes.append_exception(e)
+            outcomes.append_error(e)
             return outcomes
         for key_block in key_blocks:
             try:
@@ -613,7 +614,7 @@ class CommitteeParticipant(FoundationCommitter):
                 key_models = await asyncio.to_thread(self.__block_models, 
key_block, ldap_data)
                 outcomes.extend_roes(Exception, key_models)
             except Exception as e:
-                outcomes.append_exception(e)
+                outcomes.append_error(e)
         # Try adding the keys to the database
         # If not, all keys will be replaced with a PostParseError
         outcomes = await self.__database_add_models(outcomes, 
associate=associate)
diff --git a/docs/storage-interface.html b/docs/storage-interface.html
index 549d55f..fdec4dd 100644
--- a/docs/storage-interface.html
+++ b/docs/storage-interface.html
@@ -15,10 +15,10 @@
 
     for selected_committee_name in selected_committee_names:
         wacm = write.as_committee_member(selected_committee_name)
-        outcome: types.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
+        oc: types.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
             key.key_model.fingerprint
         )
-        outcome.result_or_raise()
+        oc.result_or_raise()
 </code></pre>
 <p>The <code>wafm</code> (<strong>w</strong>rite <strong>a</strong>s 
<strong>f</strong>oundation <strong>m</strong>ember) object exposes 
functionality which is only available to foundation members. The 
<code>wafm.keys.ensure_stored_one</code> method is an example of such 
functionality. The <code>wacm</code> object goes further and exposes 
functionality only available to committee members.</p>
 <p>In this case we decide to raise as soon as there is any error. We could 
also choose instead to display a warning, ignore the error, etc.</p>
diff --git a/docs/storage-interface.md b/docs/storage-interface.md
index 84f8b1a..5d64e85 100644
--- a/docs/storage-interface.md
+++ b/docs/storage-interface.md
@@ -20,10 +20,10 @@ async with storage.write(asf_uid) as write:
 
     for selected_committee_name in selected_committee_names:
         wacm = write.as_committee_member(selected_committee_name)
-        outcome: types.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
+        oc: types.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
             key.key_model.fingerprint
         )
-        outcome.result_or_raise()
+        oc.result_or_raise()
 ```
 
 The `wafm` (**w**rite **a**s **f**oundation **m**ember) object exposes 
functionality which is only available to foundation members. The 
`wafm.keys.ensure_stored_one` method is an example of such functionality. The 
`wacm` object goes further and exposes functionality only available to 
committee members.


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

Reply via email to