diff --git a/website/build.py b/website/build.py index e3e45ad9..67346cec 100644 --- a/website/build.py +++ b/website/build.py @@ -11,7 +11,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import TypedDict -from fetch_pypi_downloads_via_clickpy import normalize +from fetch_pypi_downloads_via_clickpy import OVERRIDES_FILE, normalize from jinja2 import Environment, FileSystemLoader from readme_parser import AlsoSee, ParsedGroup, ParsedSection, parse_readme, parse_sponsors, slugify @@ -60,6 +60,7 @@ class TemplateEntry(TypedDict): last_commit_at: str | None source_type: str | None bundled: bool + badge: str | None also_see: list[AlsoSee] @@ -101,6 +102,17 @@ def load_stars(path: Path) -> dict[str, dict]: return {} +def load_pypi_badges() -> dict[str, str]: + """Load the badge each entry shows in place of a download count. + + Keyed by normalized README name. Only entries whose missing count needs + explaining beyond "Not on PyPI" carry one, e.g. the SDK monorepos that do + ship on PyPI, just as many per-service packages. + """ + raw = json.loads(OVERRIDES_FILE.read_text()) + return {name: entry["badge"] for name, entry in raw.items() if entry.get("badge")} + + def load_downloads(path: Path) -> dict[str, int]: """Load last-30-day download counts from the TSV cache, keyed by normalized README name. @@ -524,6 +536,7 @@ def extract_entries( last_commit_at=None, source_type=detect_source_type(entry["url"]), bundled=bool(BUNDLED_PREFIX_RE.match(entry["description"])), + badge=None, also_see=entry["also_see"], ) seen[key] = existing @@ -576,6 +589,7 @@ def build(repo_root: Path) -> None: stars_data = load_stars(website / "data" / "github_stars.json") downloads_data = load_downloads(website / "data" / "pypi_downloads.tsv") + pypi_badges = load_pypi_badges() repo_self = stars_data.get("vinta/awesome-python", {}) repo_stars = None @@ -595,6 +609,7 @@ def build(repo_root: Path) -> None: # Stdlib entries would hit same-named PyPI backports (e.g. asyncio), not the stdlib. if entry.get("source_type") != BUILTIN_FILTER: entry["downloads"] = downloads_data.get(normalize(entry["name"])) + entry["badge"] = pypi_badges.get(normalize(entry["name"])) entries = sort_entries(entries) category_urls = {cat["name"]: category_path(cat) for cat in categories} diff --git a/website/data/pypi_name_overrides.json b/website/data/pypi_name_overrides.json index 8c8a5f95..f024689a 100644 --- a/website/data/pypi_name_overrides.json +++ b/website/data/pypi_name_overrides.json @@ -1,6 +1,6 @@ { "autobahn-python": { "package": "autobahn", "reason": null }, - "azure-sdk-for-python": { "package": null, "reason": "monorepo umbrella; ships as many azure-* packages, no single package represents it" }, + "azure-sdk-for-python": { "package": null, "reason": "monorepo umbrella; ships as many azure-* packages, no single package represents it", "badge": "Multiple on PyPI" }, "cloud-init": { "package": null, "reason": "installed via distro images, not pip" }, "concurrent-futures": { "package": null, "reason": "stdlib module" }, "cpython": { "package": null, "reason": "not pip-installable; the cpython PyPI name was squatted" }, @@ -12,7 +12,7 @@ "django-rules": { "package": "rules", "reason": "PyPI django-rules is an abandoned unrelated fork; dfunckt's project publishes as rules" }, "fasthtml": { "package": "python-fasthtml", "reason": null }, "geodjango": { "package": null, "reason": "part of Django (django.contrib.gis), not a package" }, - "google-cloud-python": { "package": null, "reason": "monorepo umbrella; ships as many google-cloud-* packages" }, + "google-cloud-python": { "package": null, "reason": "monorepo umbrella; ships as many google-cloud-* packages", "badge": "Multiple on PyPI" }, "graphify": { "package": null, "reason": "GitHub-only so far; young project, remap if it publishes" }, "httpx-url": { "package": null, "reason": "class in the httpx package, not a package" }, "jinja": { "package": "jinja2", "reason": "PyPI jinja is Jinja1; current releases publish as jinja2" }, diff --git a/website/fetch_pypi_downloads_via_clickpy.py b/website/fetch_pypi_downloads_via_clickpy.py index c7c979f2..a708a6c6 100644 --- a/website/fetch_pypi_downloads_via_clickpy.py +++ b/website/fetch_pypi_downloads_via_clickpy.py @@ -56,8 +56,10 @@ def normalize(name: str) -> str: def load_overrides() -> dict[str, str | None]: """Load curated overrides: normalized README name -> PyPI package to measure (None = never query). - Each file entry is {"package": str | null, "reason": str | null}; the reason is - documentation only — required for null packages, optional for remaps. + Each file entry is {"package": str | null, "reason": str | null} plus an + optional "badge" the website renders in place of a download count; the + reason is documentation only — required for null packages, optional for + remaps. """ raw = json.loads(OVERRIDES_FILE.read_text()) return {name: entry["package"] for name, entry in raw.items()} diff --git a/website/templates/category.html b/website/templates/category.html index 2eca8665..0a868bc2 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -157,8 +157,9 @@ {% if entry.downloads is not none %}{{ "{:,}".format(entry.downloads) }}{% elif entry.source_type == 'Stdlib' %}Stdlib{% elif - entry.bundled %}Bundled{% else - %}Not on PyPI{% endif %} + entry.bundled %}Bundled{% elif + entry.badge %}{{ entry.badge }}{% + else %}Not on PyPI{% endif %} {% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{% diff --git a/website/templates/index.html b/website/templates/index.html index 85eea0f3..b5540624 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -194,8 +194,9 @@ {% if entry.downloads is not none %}{{ "{:,}".format(entry.downloads) }}{% elif entry.source_type == 'Stdlib' %}Stdlib{% elif - entry.bundled %}Bundled{% else - %}Not on PyPI{% endif %} + entry.bundled %}Bundled{% elif + entry.badge %}{{ entry.badge }}{% + else %}Not on PyPI{% endif %} {% if entry.stars is not none %}{{ "{:,}".format(entry.stars) }}{% diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 15e8d085..2f0af663 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -18,6 +18,7 @@ from build import ( extract_entries, extract_github_repo, load_downloads, + load_pypi_badges, load_stars, sort_entries, subcategory_path, @@ -1012,6 +1013,16 @@ class TestLoadStars: # --------------------------------------------------------------------------- +class TestLoadPypiBadges: + def test_only_entries_with_a_badge_are_returned(self): + badges = load_pypi_badges() + assert badges["azure-sdk-for-python"] == "Multiple on PyPI" + assert badges["google-cloud-python"] == "Multiple on PyPI" + # Entries whose missing count needs no explaining stay off the map + assert "cpython" not in badges + assert "tomllib" not in badges + + def _template_entry(name: str, stars: int | None, source_type: str | None = None) -> TemplateEntry: return TemplateEntry( name=name, @@ -1026,6 +1037,7 @@ def _template_entry(name: str, stars: int | None, source_type: str | None = None last_commit_at=None, source_type=source_type, bundled=False, + badge=None, also_see=[], )