Files
ports/Tools/scripts/repology-get-maintainer-stats.py
2026-10-04 04:07:15 +00:00

214 lines
6.1 KiB
Python
Executable File

#!/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:
pkg_prefix = f" py{sys.version_info.major}{sys.version_info.minor}-"
print(
"Error: missing required package(s): " + ", ".join(missing),
file=sys.stderr,
)
print(
f"Install with: sudo pkg install{pkg_prefix}" + pkg_prefix.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
tbody = table.find("tbody") or table
for row in 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)
# Repology collapses empty (count, percentage) pairs into a single cell
# with colspan=2. Expand those cells so each logical column has its own
# entry: Repository, Packages, Projects Total, Newest, Newest%, Outdated,
# Outdated%, Problematic, Problematic%, Potentially vulnerable,
# Potentially vulnerable%, Other lists, Feeds.
def _expand_cells(cells):
expanded = []
for cell in cells:
text = _cell_text(cell).strip()
colspan = int(cell.get("colspan", 1) or 1)
expanded.extend([text] * colspan)
return expanded
texts = _expand_cells(row)
if len(texts) < 13:
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()