blob: d3342f1f0e4cdc2ea88503953ef8adebba11eec0 [file] [edit]
#!/usr/bin/env python3
# Copyright (c) 2009, Giampaolo Rodola. All rights reserved.
# Use of this source code is governed by a BSD-style license that can
# be found in the LICENSE file.
"""Refresh the dynamic numbers in docs/adoption.rst and README.rst
(monthly downloads and GitHub repository dependents).
Intended to run locally before tagging a release. RTD's build does NOT
call this.
"""
import json
import pathlib
import re
import sys
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parents[2]
TARGETS = [
ROOT / "docs/adoption.rst",
ROOT / "docs/index.rst",
ROOT / "README.rst",
]
DEPENDENTS_URL = "https://github.com/giampaolo/psutil/network/dependents"
DOWNLOADS_URL = "https://pypistats.org/api/packages/psutil/recent"
def fetch(url, accept="text/html"):
# GitHub serves a stripped page (without the dependent-counts
# markup) to non-browser User-Agents, so we pretend to be one.
req = urllib.request.Request(
url,
headers={
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0 Safari/537.36"
),
"Accept": accept,
},
)
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode("utf-8", errors="replace")
def round_millions(n):
"""338018876 -> '330+ million'. Floor to 10M."""
millions = (n // 10_000_000) * 10
return f"{millions}+ million"
def floor_to(n, bucket):
"""Floor n to a multiple of `bucket`. 769412, 10_000 -> 760000."""
return (n // bucket) * bucket
def fetch_monthly_downloads():
data = json.loads(fetch(DOWNLOADS_URL, accept="application/json"))
return data["data"]["last_month"]
def fetch_github_dependents():
"""Scrape the 'Used by' repository count from GitHub's dependents
graph.
"""
html = fetch(DEPENDENTS_URL)
# The page renders the count as:
# <number>
# Repositories
# ...with the number on one line and the label on the next.
repos_re = re.search(r"([\d,]+)\s+Repositories", html)
if not repos_re:
sys.exit("could not parse GitHub dependents page")
return int(repos_re.group(1).replace(",", ""))
def main():
print(f"fetching {DOWNLOADS_URL} ...")
monthly = fetch_monthly_downloads()
print(f" monthly downloads: {monthly:,}")
print(f"fetching {DEPENDENTS_URL} ...")
repos = fetch_github_dependents()
print(f" repos: {repos:,}")
# Repos are at the 100k+ scale. Floor to a bucket size that hides
# week-to-week noise but still moves visibly between releases.
new_downloads = round_millions(monthly) # "330+ million"
new_repos = f"{floor_to(repos, 10_000):,}+" # "760,000+"
# In adoption.rst and README.rst the numbers are wrapped in
# **bold**; in index.rst they live inside a <span class=
# "home-stat-num">. Patterns match both forms so re-runs preserve
# the markup.
subs = [
# adoption.rst / README.rst: **330+ million** downloads ...
(
re.compile(r"\*\*\d+\+\s+million\*\*"),
f"**{new_downloads}**",
),
(
re.compile(r"\*\*[\d,]+\+\*\*(?=\s+`?GitHub repositories)"),
f"**{new_repos}**",
),
# index.rst home-stats banner.
(
re.compile(
r'(<span class="home-stat-num">)\d+\+\s+million(</span>)'
),
rf"\g<1>{new_downloads}\g<2>",
),
(
re.compile(
r'(<span class="home-stat-num">)[\d,]+\+(</span>\s*\n\s*'
r'<span class="home-stat-label">GitHub)'
),
rf"\g<1>{new_repos}\g<2>",
),
]
# Apply each sub to each file and count global matches per pattern.
# Each pattern is file-specific (some only match the **bold**
# prose form, some only match the index.rst HTML form), so we
# only require >= 1 match across all files combined.
totals = [0] * len(subs)
for path in TARGETS:
text = path.read_text()
new_text = text
for i, (pat, repl) in enumerate(subs):
new_text, n = pat.subn(repl, new_text)
totals[i] += n
if new_text == text:
print(f" {path.relative_to(ROOT)}: already current")
else:
path.write_text(new_text)
print(f" {path.relative_to(ROOT)}: updated")
for i, (pat, _) in enumerate(subs):
if totals[i] == 0:
sys.exit(
f"no file matched {pat.pattern!r} "
"(expected at least 1 match across all targets)"
)
if __name__ == "__main__":
main()