214 lines
6.1 KiB
Python
Executable File
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()
|