git: 46c495e6e8db - main - Tools/scripts/repology-get-maintainer-stats.py: add convenience script

From: Yuri Victorovich <yuri_at_FreeBSD.org>
Date: Fri, 25 Sep 2026 23:51:06 UTC
The branch main has been updated by yuri:

URL: https://cgit.FreeBSD.org/ports/commit/?id=46c495e6e8dbbba2854a30dac5335c747fe9bdbf

commit 46c495e6e8dbbba2854a30dac5335c747fe9bdbf
Author:     Yuri Victorovich <yuri@FreeBSD.org>
AuthorDate: 2026-09-25 22:35:18 +0000
Commit:     Yuri Victorovich <yuri@FreeBSD.org>
CommitDate: 2026-09-25 23:50:05 +0000

    Tools/scripts/repology-get-maintainer-stats.py: add convenience script
    
    It retrieves and displays aggregate statistics from Repology, same
    that is displayed on the web site.
---
 Tools/scripts/repology-get-maintainer-stats.py | 202 +++++++++++++++++++++++++
 1 file changed, 202 insertions(+)

diff --git a/Tools/scripts/repology-get-maintainer-stats.py b/Tools/scripts/repology-get-maintainer-stats.py
new file mode 100755
index 000000000000..9a59a428f750
--- /dev/null
+++ b/Tools/scripts/repology-get-maintainer-stats.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+#
+# repology-get-maintainer-stats.py - print aggregate FreeBSD ports maintainer
+# statistics as reported by repology.org.
+#
+# MAINTAINER= yuri@FreeBSD.org
+#
+
+import argparse
+import getpass
+import importlib.util
+import re
+import sys
+from urllib.parse import quote
+
+REPOLOGY_MAINTAINER_URL = "https://repology.org/maintainer/{maintainer}"
+USER_AGENT = (
+    "repology-get-maintainer-stats.py "
+    "(FreeBSD ports tools; https://www.freebsd.org/ports/)"
+)
+
+
+def check_dependencies() -> None:
+    """Verify that all non-stdlib dependencies are available."""
+    missing = []
+    for module, package in (("requests", "requests"), ("bs4", "beautifulsoup4")):
+        if importlib.util.find_spec(module) is None:
+            missing.append(package)
+    if missing:
+        print(
+            "Error: missing required package(s): " + ", ".join(missing),
+            file=sys.stderr,
+        )
+        print(
+            "Install with: sudo pkg install " + " ".join(missing),
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Print FreeBSD ports maintainer statistics from repology.org."
+    )
+    parser.add_argument(
+        "maintainer",
+        nargs="?",
+        default=f"{getpass.getuser()}@FreeBSD.org",
+        help="maintainer e-mail address (default: %(default)s)",
+    )
+    args = parser.parse_args()
+    if len(sys.argv) > 2:
+        parser.error("at most one maintainer argument is allowed")
+    return args
+
+
+def fetch_maintainer_page(maintainer: str) -> str:
+    import requests
+
+    url = REPOLOGY_MAINTAINER_URL.format(maintainer=quote(maintainer, safe="@"))
+    try:
+        response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=30)
+    except requests.RequestException as exc:
+        print(f"Error fetching {url}: {exc}", file=sys.stderr)
+        sys.exit(1)
+    if response.status_code == 404:
+        print(
+            f"Error: maintainer '{maintainer}' was not found on repology.org.",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+    response.raise_for_status()
+    return response.text
+
+
+def _cell_text(cell) -> str:
+    """Return stripped text from a BeautifulSoup table cell."""
+    return " ".join(cell.stripped_strings)
+
+
+def extract_freebsd_stats(html: str) -> dict[str, str]:
+    """Parse the repology maintainer page and return FreeBSD Ports aggregate stats."""
+    from bs4 import BeautifulSoup
+
+    soup = BeautifulSoup(html, "html.parser")
+
+    # The page contains multiple tables; the one we need has a header with
+    # "Repository" and "Packages".
+    table = None
+    for tbl in soup.find_all("table"):
+        header_cells = tbl.find_all("th")
+        texts = [_cell_text(c).lower() for c in header_cells]
+        if "repository" in texts and "packages" in texts:
+            table = tbl
+            break
+
+    if table is None:
+        print(
+            "Error: could not locate the repository activity table.",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+    freebsd_row = None
+    total_row = None
+    for row in table.find("tbody").find_all("tr"):
+        cells = row.find_all(["td", "th"])
+        if not cells:
+            continue
+        first = _cell_text(cells[0]).strip()
+        if re.search(r"FreeBSD\s+Ports", first, re.IGNORECASE):
+            freebsd_row = cells
+        elif first.lower() == "total":
+            total_row = cells
+
+    row = freebsd_row or total_row
+    if row is None:
+        print(
+            "Error: no FreeBSD Ports or Total statistics row found.",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+    # Cells layout: Repository, Packages, Projects Total, Newest, Newest%,
+    # Outdated, Outdated%, Problematic, Problematic%, Potentially vulnerable,
+    # Potentially vulnerable%, Other lists, Feeds.
+    # We extract the first 11 meaningful cells.
+    texts = [_cell_text(c) for c in row]
+    if len(texts) < 11:
+        print(
+            "Error: unexpected table row format on repology.org.",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+    return {
+        "repository": texts[0],
+        "packages": texts[1],
+        "projects_total": texts[2],
+        "newest": texts[3],
+        "newest_pct": texts[4],
+        "outdated": texts[5],
+        "outdated_pct": texts[6],
+        "problematic": texts[7],
+        "problematic_pct": texts[8],
+        "vulnerable": texts[9],
+        "vulnerable_pct": texts[10],
+    }
+
+
+def format_table(stats: dict[str, str]) -> str:
+    headers = [
+        "Packages",
+        "Projects Total",
+        "Newest",
+        "Outdated",
+        "Problematic",
+        "Potentially Vulnerable",
+    ]
+    values = [
+        stats["packages"],
+        stats["projects_total"],
+        f"{stats['newest']} ({stats['newest_pct']})",
+        f"{stats['outdated']} ({stats['outdated_pct']})",
+        f"{stats['problematic']} ({stats['problematic_pct']})",
+        f"{stats['vulnerable']} ({stats['vulnerable_pct']})",
+    ]
+
+    widths = [
+        max(len(h), len(v)) for h, v in zip(headers, values)
+    ]
+
+    def horizontal() -> str:
+        return "+" + "+".join("-" * (w + 2) for w in widths) + "+"
+
+    def line(cells) -> str:
+        return (
+            "|"
+            + "|".join(
+                f" {cell.ljust(width)} " for cell, width in zip(cells, widths)
+            )
+            + "|"
+        )
+
+    return "\n".join(
+        [horizontal(), line(headers), horizontal(), line(values), horizontal()]
+    )
+
+
+def main() -> None:
+    check_dependencies()
+    args = parse_args()
+    maintainer = args.maintainer
+
+    print(f"Maintainer: {maintainer}")
+    html = fetch_maintainer_page(maintainer)
+    stats = extract_freebsd_stats(html)
+    print(format_table(stats))
+
+
+if __name__ == "__main__":
+    main()