This is an automated email from the ASF dual-hosted git repository.

tn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-atr-experiments.git

commit ca1bf870c4d78fcb8b33f75d88c5eaa1029a3277
Author: Thomas Neidhart <t...@apache.org>
AuthorDate: Thu Feb 20 12:10:12 2025 +0100

    add apache module for retrieving apache specific data from whimsy
---
 .pre-commit-config.yaml         |  2 ++
 atr/apache.py                   | 55 +++++++++++++++++++++++++++++++++++++++++
 atr/blueprints/secret/secret.py | 37 ++++++++++++---------------
 atr/util.py                     | 52 ++++++++++++++++++++++++++++++++++++++
 pyproject.toml                  | 12 ++++++++-
 tests/__init__.py               |  0
 tests/test_apache.py            | 34 +++++++++++++++++++++++++
 7 files changed, 170 insertions(+), 22 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 35483d4..ef043df 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -63,6 +63,7 @@ repos:
       entry: mypy
       language: system
       types: [python]
+      exclude: "tests" # see 
https://github.com/pre-commit/pre-commit/issues/2967
       args:
         - --config-file=pyproject.toml
     - id: pyright
@@ -70,3 +71,4 @@ repos:
       entry: pyright
       language: system
       types: [python]
+      exclude: "tests"
diff --git a/atr/apache.py b/atr/apache.py
new file mode 100644
index 0000000..aad9e7c
--- /dev/null
+++ b/atr/apache.py
@@ -0,0 +1,55 @@
+# 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 __future__ import annotations
+
+from typing import Annotated
+
+import httpx
+from pydantic import BaseModel
+
+from atr.util import DictToList
+
+_WHIMSY_COMMITTEE_URL = "https://whimsy.apache.org/public/committee-info.json";
+_WHIMSY_PROJECTS_URL = 
"https://whimsy.apache.org/public/public_ldap_projects.json";
+
+
+class ApacheProjects(BaseModel):
+    lastTimestamp: str
+    project_count: int
+    projects: Annotated[list[ApacheProject], DictToList(key="name")]
+
+
+class ApacheProject(BaseModel):
+    name: str
+    createTimestamp: str
+    modifyTimestamp: str
+    member_count: int
+    owner_count: int
+    members: list[str]
+    owners: list[str]
+    pmc: bool = False
+    podling: str | None = None
+
+
+async def get_apache_project_data() -> ApacheProjects:
+    async with httpx.AsyncClient() as client:
+        response = await client.get(_WHIMSY_PROJECTS_URL)
+        response.raise_for_status()
+        data = response.json()
+
+    return ApacheProjects.model_validate(data)
diff --git a/atr/blueprints/secret/secret.py b/atr/blueprints/secret/secret.py
index 5cb0e2c..c17f599 100644
--- a/atr/blueprints/secret/secret.py
+++ b/atr/blueprints/secret/secret.py
@@ -26,6 +26,7 @@ from werkzeug.wrappers.response import Response
 
 from asfquart.base import ASFQuartException
 from asfquart.session import read as session_read
+from atr.apache import get_apache_project_data
 from atr.db import get_session
 from atr.db.models import (
     PMC,
@@ -99,44 +100,38 @@ async def secret_pmcs_update() -> str | Response:
 
     if request.method == "POST":
         # Fetch committee-info.json from Whimsy
-        async with httpx.AsyncClient() as client:
-            try:
-                response = await client.get(_WHIMSY_COMMITTEE_URL)
-                response.raise_for_status()
-                data = response.json()
-            except (httpx.RequestError, json.JSONDecodeError) as e:
-                await flash(f"Failed to fetch committee data: {e!s}", "error")
-                return redirect(url_for("secret_blueprint.secret_pmcs_update"))
-
-        committees = data.get("committees", {})
+        try:
+            apache_projects = await get_apache_project_data()
+        except (httpx.RequestError, json.JSONDecodeError) as e:
+            await flash(f"Failed to fetch committee data: {e!s}", "error")
+            return redirect(url_for("secret_blueprint.secret_pmcs_update"))
+
         updated_count = 0
 
         try:
             async with get_session() as db_session:
                 async with db_session.begin():
-                    for committee_id, info in committees.items():
+                    for project in apache_projects.projects:
+                        name = project.name
                         # Skip non-PMC committees
-                        if not info.get("pmc", False):
+                        if not project.pmc:
                             continue
 
                         # Get or create PMC
-                        statement = select(PMC).where(PMC.project_name == 
committee_id)
+                        statement = select(PMC).where(PMC.project_name == name)
                         pmc = (await 
db_session.execute(statement)).scalar_one_or_none()
                         if not pmc:
-                            pmc = PMC(project_name=committee_id)
+                            pmc = PMC(project_name=name)
                             db_session.add(pmc)
 
                         # Update PMC data
-                        roster = info.get("roster", {})
-                        # TODO: Here we say that roster == pmc_members == 
committers
-                        # We ought to do this more accurately instead
-                        pmc.pmc_members = list(roster.keys())
-                        pmc.committers = list(roster.keys())
+                        pmc.pmc_members = project.owners
+                        pmc.committers = project.members
 
                         # Mark chairs as release managers
                         # TODO: Who else is a release manager? How do we know?
-                        chairs = [m["id"] for m in info.get("chairs", [])]
-                        pmc.release_managers = chairs
+                        #       lets assume for now that all owners are also 
release managers
+                        pmc.release_managers = project.owners
 
                         updated_count += 1
 
diff --git a/atr/util.py b/atr/util.py
index fd40943..1e44012 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -16,9 +16,13 @@
 # under the License.
 
 import hashlib
+from dataclasses import dataclass
 from functools import cache
 from pathlib import Path
+from typing import Annotated, Any
 
+from pydantic import GetCoreSchemaHandler, TypeAdapter, create_model
+from pydantic_core import CoreSchema, core_schema
 from quart import current_app
 
 
@@ -50,3 +54,51 @@ def compute_sha512(file_path: Path) -> str:
         for chunk in iter(lambda: f.read(4096), b""):
             sha512.update(chunk)
     return sha512.hexdigest()
+
+
+def _get_dict_to_list_inner_type_adapter(source_type: Any, key: str) -> 
TypeAdapter[dict[Any, Any]]:
+    root_adapter = TypeAdapter(source_type)
+    schema = root_adapter.core_schema
+
+    assert schema["type"] == "list"
+    assert (item_schema := schema["items_schema"])
+    assert item_schema["type"] == "model"
+    assert (cls := item_schema["cls"])  # noqa: RUF018
+
+    fields = cls.model_fields
+
+    assert (key_field := fields.get(key))  # noqa: RUF018
+    assert (other_fields := {k: v for k, v in fields.items() if k != key})  # 
noqa: RUF018
+
+    model_name = f"{cls.__name__}Inner"
+    inner_model = create_model(model_name, **{k: (Any, v) for k, v in 
other_fields.items()})  # type: ignore
+    return TypeAdapter(dict[Annotated[Any, key_field], inner_model])  # type: 
ignore
+
+
+def _get_dict_to_list_validator(inner_adapter: TypeAdapter[dict[Any, Any]], 
key: str) -> Any:
+    def validator(val: Any) -> Any:
+        if isinstance(val, dict):
+            validated = inner_adapter.validate_python(val)
+            return [{key: k, **{f: getattr(v, f) for f in v.model_fields}} for 
k, v in validated.items()]
+
+        return val
+
+    return validator
+
+
+# from 
https://github.com/pydantic/pydantic/discussions/8755#discussioncomment-8417979
+@dataclass
+class DictToList:
+    key: str
+
+    def __get_pydantic_core_schema__(
+        self,
+        source_type: Any,
+        handler: GetCoreSchemaHandler,
+    ) -> CoreSchema:
+        adapter = _get_dict_to_list_inner_type_adapter(source_type, self.key)
+
+        return core_schema.no_info_before_validator_function(
+            _get_dict_to_list_validator(adapter, self.key),
+            handler(source_type),
+        )
diff --git a/pyproject.toml b/pyproject.toml
index 6948f1b..b347a80 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,7 +72,14 @@ asfquart = { path = "./asfquart", editable = true }
 
 [tool.pyright]
 include = ["atr"]
-exclude = ["**/node_modules", "**/__pycache__", ".venv*", "asfquart", "tests"]
+exclude = [
+  "**/node_modules",
+  "**/__pycache__",
+  ".venv*",
+  "asfquart",
+  "tests",
+  "atr/util.py"
+]
 ignore = []
 defineConstant = { DEBUG = true }
 stubPath = "typestubs"
@@ -100,6 +107,9 @@ lint.ignore = []
 line-length = 120
 exclude = ["asfquart"]
 
+[tool.ruff.lint.per-file-ignores]
+"atr/apache.py" = ["N815"]
+
 [tool.mypy]
 python_version = "3.13"
 exclude = ["asfquart", "tests"]
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_apache.py b/tests/test_apache.py
new file mode 100644
index 0000000..8dd38ea
--- /dev/null
+++ b/tests/test_apache.py
@@ -0,0 +1,34 @@
+import json
+
+from atr.apache import ApacheProjects
+
+
+def test_model():
+    json_data = """
+{
+  "lastTimestamp": "20250219115218Z",
+  "project_count": 1,
+  "projects": {
+    "tooling": {
+      "createTimestamp": "20170713020428Z",
+      "modifyTimestamp": "20240725001829Z",
+      "member_count": 3,
+      "owner_count": 3,
+      "members": [
+        "wave",
+        "sbp",
+        "tn"
+      ],
+      "owners": [
+        "wave",
+        "sbp",
+        "tn"
+      ]
+    }
+  }
+}"""
+    projects = ApacheProjects.model_validate(json.loads(json_data))
+
+    assert projects is not None
+    assert projects.project_count == 1
+    assert projects.projects[0].name == "tooling"


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscr...@tooling.apache.org
For additional commands, e-mail: dev-h...@tooling.apache.org

Reply via email to