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 7d5d517  Add a module to validate releases, and a script to use it
7d5d517 is described below

commit 7d5d517070a46961e18f29d7a7e47a41467c7949
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jun 25 15:38:57 2025 +0100

    Add a module to validate releases, and a script to use it
---
 atr/validate.py            | 176 +++++++++++++++++++++++++++++++++++++++++++++
 scripts/integrity_check.py |  30 ++++++++
 2 files changed, 206 insertions(+)

diff --git a/atr/validate.py b/atr/validate.py
new file mode 100644
index 0000000..6f450c5
--- /dev/null
+++ b/atr/validate.py
@@ -0,0 +1,176 @@
+# 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.
+
+import datetime
+from collections.abc import Callable, Generator, Iterable, Sequence
+from typing import NamedTuple, TypeVar
+
+import atr.db.models as models
+
+
+class Divergence(NamedTuple):
+    expected: str
+    actual: str
+
+
+class AnnotatedDivergence(NamedTuple):
+    source: str
+    validator: str
+    components: list[str]
+    divergence: Divergence
+
+
+Divergences = Generator[Divergence]
+AnnotatedDivergences = Generator[AnnotatedDivergence]
+ReleaseDivergences = Callable[[models.Release], Divergences]
+ReleaseAnnotatedDivergences = Callable[[models.Release], AnnotatedDivergences]
+
+T = TypeVar("T")
+
+
+def divergences[T](expected: T, actual: T) -> Divergences:
+    """Compare two values and yield the divergence if they differ."""
+    if expected != actual:
+        yield Divergence(repr(expected), repr(actual))
+
+
+def divergences_predicate[T](okay: Callable[[T], bool], expected: str, actual: 
T) -> Divergences:
+    """Apply a predicate to a value and yield the divergence if false."""
+    if not okay(actual):
+        yield Divergence(expected, repr(actual))
+
+
+def divergences_with_annotations(
+    source: str,
+    validator: str,
+    components: Sequence[str],
+    ds: Divergences,
+) -> AnnotatedDivergences:
+    """Wrap divergences with a source, validator, and components."""
+    for d in ds:
+        yield AnnotatedDivergence(source, validator, list(components), d)
+
+
+def release(r: models.Release) -> AnnotatedDivergences:
+    """Check that a release is valid."""
+    yield from release_created(r)
+    yield from release_name(r)
+    yield from release_package_managers(r)
+    yield from release_released(r)
+    yield from release_sboms(r)
+    yield from release_vote_logic(r)
+    yield from release_votes(r)
+
+
+def release_components(
+    *components: str,
+) -> Callable[[ReleaseDivergences], ReleaseAnnotatedDivergences]:
+    """Wrap a function that yields divergences to yield annotated 
divergences."""
+
+    def wrap(original: ReleaseDivergences) -> ReleaseAnnotatedDivergences:
+        def replacement(r: models.Release) -> AnnotatedDivergences:
+            yield from divergences_with_annotations(
+                r.name,
+                original.__name__,
+                components,
+                original(r),
+            )
+
+        return replacement
+
+    return wrap
+
+
+@release_components("Release.created")
+def release_created(r: models.Release) -> Divergences:
+    """Check that the release created date is in the past."""
+    now = datetime.datetime.now(datetime.UTC)
+
+    def predicate(dt: datetime.datetime) -> bool:
+        return dt < now
+
+    expected = "value to be in the past"
+    yield from divergences_predicate(predicate, expected, r.created)
+
+
+@release_components("Release.name")
+def release_name(r: models.Release) -> Divergences:
+    """Check that the release name is valid."""
+    expected = models.release_name(r.project_name, r.version)
+    actual = r.name
+    yield from divergences(expected, actual)
+
+
+@release_components("Release.package_managers")
+def release_package_managers(r: models.Release) -> Divergences:
+    """Check that the release package managers are empty."""
+    expected = []
+    actual = r.package_managers
+    yield from divergences(expected, actual)
+
+
+@release_components("Release.released")
+def release_released(r: models.Release) -> Divergences:
+    """Check that the release released date is in the past or None."""
+    now = datetime.datetime.now(datetime.UTC)
+
+    def okay(dt: datetime.datetime | None) -> bool:
+        if dt is None:
+            return True
+        return dt < now
+
+    expected = "value to be in the past or None"
+    yield from divergences_predicate(okay, expected, r.released)
+
+
+@release_components("Release.sboms")
+def release_sboms(r: models.Release) -> Divergences:
+    """Check that the release sboms are empty."""
+    expected = []
+    actual = r.sboms
+    yield from divergences(expected, actual)
+
+
+@release_components("Release.vote_started", "Release.vote_resolved")
+def release_vote_logic(r: models.Release) -> Divergences:
+    """Check that the release vote logic is valid."""
+
+    def okay(sr: tuple[datetime.datetime | None, datetime.datetime | None]) -> 
bool:
+        # The vote_resolved property must not be set unless vote_started is set
+        match sr:
+            case (None, None) | (_, None) | (_, _):
+                return True
+            case (None, _):
+                return False
+
+    expected = "vote_started to be set when vote_resolved is set"
+    actual = (r.vote_started, r.vote_resolved)
+    yield from divergences_predicate(okay, expected, actual)
+
+
+@release_components("Release.votes")
+def release_votes(r: models.Release) -> Divergences:
+    """Check that the release votes are empty."""
+    expected = []
+    actual = r.votes
+    yield from divergences(expected, actual)
+
+
+def releases(rs: Iterable[models.Release]) -> AnnotatedDivergences:
+    """Check that the releases are valid."""
+    for r in rs:
+        yield from release(r)
diff --git a/scripts/integrity_check.py b/scripts/integrity_check.py
new file mode 100644
index 0000000..2090dd2
--- /dev/null
+++ b/scripts/integrity_check.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+
+import asyncio
+import importlib.util
+import sys
+
+if not importlib.util.find_spec("atr"):
+    sys.path.append(".")
+
+import atr.db as db
+import atr.validate as validate
+
+
+async def amain() -> None:
+    await db.init_database_for_worker()
+    async with db.session() as data:
+        releases = await data.release().all()
+        divergences = 0
+        for divergence in validate.releases(releases):
+            print(divergence)
+            divergences += 1
+        print(len(releases), "releases,", divergences, "errors")
+
+
+def main() -> None:
+    asyncio.run(amain())
+
+
+if __name__ == "__main__":
+    main()


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

Reply via email to