diff --git a/.github/workflows/update-download-badges.yml b/.github/workflows/update-download-badges.yml new file mode 100644 index 0000000..cb6d005 --- /dev/null +++ b/.github/workflows/update-download-badges.yml @@ -0,0 +1,64 @@ +name: Update total downloads badge + +# Sums all-time PyPI downloads (pepy) and jsDelivr hits, then rewrites the +# TOTAL-DOWNLOADS badge block in profile/README.md. +# +# Optional: add a repo secret named PEPY_API_KEY (free key from +# https://pepy.tech -> account -> API key) for the reliable pepy v2 endpoint. +# Without it the job falls back to the keyless legacy endpoint. + +on: + schedule: + - cron: "17 4 * * *" # 04:17 UTC daily + workflow_dispatch: + pull_request: + paths: + - "scripts/**" + - ".github/workflows/update-download-badges.yml" + push: + branches: [main] + paths: + - "scripts/update-download-badges.py" + - ".github/workflows/update-download-badges.yml" + +permissions: + contents: write + +concurrency: + group: download-badges + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run the badge tests + run: python3 -m unittest discover -s scripts -p 'test_*.py' -v + + # Pull requests only run the tests above; the badge is rewritten on main. + - name: Tally downloads and rewrite the badge + if: github.event_name != 'pull_request' + env: + PEPY_API_KEY: ${{ secrets.PEPY_API_KEY }} + run: python3 scripts/update-download-badges.py --readme profile/README.md + + - name: Commit the updated badge + if: github.event_name != 'pull_request' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add profile/README.md + if git diff --cached --quiet; then + echo "Total downloads unchanged; nothing to commit." + else + git commit -m "chore: update total downloads badge [skip ci]" + git push + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/profile/README.md b/profile/README.md index 23b924f..0fdb9d8 100644 --- a/profile/README.md +++ b/profile/README.md @@ -8,6 +8,9 @@ [![Static Badge](https://img.shields.io/badge/YouTube-Subscribe-white?logo=youtube&labelColor=red)](https://www.youtube.com/@infinitodeltd) [![Static Badge](https://img.shields.io/badge/Huggingface-Visit-white?logo=huggingface&labelColor=white)](https://huggingface.co/InfinitodeLTD) ![](https://komarev.com/ghpvc/?username=infinitode&color=5d17eb) + +![Total Downloads](https://img.shields.io/badge/Total%20Downloads-updating...-5d17eb) + [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/M4J423SRBM) @@ -301,14 +304,52 @@ Welcome to Infinitode, a cutting-edge small technology company dedicated to deve [![GitHub stars](https://img.shields.io/github/stars/infinitode/open-arc.svg?style=social&label=Star)](https://github.com/infinitode/open-arc/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/open-arc) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/open-arc) -[**LocalShare**](https://github.com/Infinitode/LocalShare) +[**LocalShare**](https://github.com/johanbeytell1/LocalShare) -[![GitHub stars](https://img.shields.io/github/stars/infinitode/localshare.svg?style=social&label=Star)](https://github.com/infinitode/localshare/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/localshare) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/localshare) +[![GitHub stars](https://img.shields.io/github/stars/johanbeytell1/localshare.svg?style=social&label=Star)](https://github.com/johanbeytell1/localshare/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/johanbeytell1/localshare) ![GitHub last commit](https://img.shields.io/github/last-commit/johanbeytell1/localshare) + +> [!NOTE] +> Hosted under [@johanbeytell1](https://github.com/johanbeytell1) instead of the org due to Netlify's limitations with organization accounts. Still fully open-source. + +[**Alloy**](https://github.com/johanbeytell1/Alloy) + +[![GitHub stars](https://img.shields.io/github/stars/johanbeytell1/alloy.svg?style=social&label=Star)](https://github.com/johanbeytell1/alloy/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/johanbeytell1/alloy) ![GitHub last commit](https://img.shields.io/github/last-commit/johanbeytell1/alloy) [**Python Projects**](https://github.com/Infinitode/python-projects) [![GitHub stars](https://img.shields.io/github/stars/infinitode/python-projects.svg?style=social&label=Star)](https://github.com/infinitode/python-projects/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/python-projects) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/python-projects) +[**Scriptify**](https://github.com/Infinitode/Scriptify) + +[![GitHub stars](https://img.shields.io/github/stars/infinitode/scriptify.svg?style=social&label=Star)](https://github.com/infinitode/scriptify/stargazers) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/infinitode/scriptify/build.yml) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/scriptify) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/scriptify) + +[**Scikit-learn Model Updater**](https://github.com/Infinitode/Scikit-learn-Model-Updater) + +[![GitHub stars](https://img.shields.io/github/stars/infinitode/scikit-learn-model-updater.svg?style=social&label=Star)](https://github.com/infinitode/scikit-learn-model-updater/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/scikit-learn-model-updater) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/scikit-learn-model-updater) + +[**Distributables**](https://github.com/Infinitode/Distributables) + +[![GitHub stars](https://img.shields.io/github/stars/infinitode/distributables.svg?style=social&label=Star)](https://github.com/infinitode/distributables/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/distributables) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/distributables) + +[**Material Library**](https://github.com/Infinitode/Material-Library) + +[![GitHub stars](https://img.shields.io/github/stars/infinitode/material-library.svg?style=social&label=Star)](https://github.com/infinitode/material-library/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/material-library) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/material-library) + +[**PWLDS**](https://github.com/Infinitode/PWLDS) + +[![GitHub stars](https://img.shields.io/github/stars/infinitode/pwlds.svg?style=social&label=Star)](https://github.com/infinitode/pwlds/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/pwlds) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/pwlds) + +[**CRSD**](https://github.com/Infinitode/CRSD) + +[![GitHub stars](https://img.shields.io/github/stars/infinitode/crsd.svg?style=social&label=Star)](https://github.com/infinitode/crsd/stargazers) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/crsd) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/crsd) + +[**qrforge**](https://github.com/Infinitode/qrforge) + +![Pepy Total Downloads](https://img.shields.io/pepy/dt/qrforge) + ![PyPI - Downloads](https://img.shields.io/pypi/dm/qrforge) + ![PyPI Version](https://img.shields.io/pypi/v/qrforge) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/infinitode/qrforge/build_and_publish.yaml) + [![qrforge](https://snyk.io/advisor/python/qrforge/badge.svg)](https://snyk.io/advisor/python/qrforge) ![Libraries.io dependency status for latest release](https://img.shields.io/librariesio/release/pypi/qrforge) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/infinitode/qrforge) ![PyPI - Status](https://img.shields.io/pypi/status/qrforge) ![GitHub last commit](https://img.shields.io/github/last-commit/infinitode/qrforge) + [**LocalSiteMap**](https://github.com/Infinitode/localsitemap) ![Pepy Total Downloads](https://img.shields.io/pepy/dt/localsitemap) @@ -386,6 +427,7 @@ At Infinitode, we're passionate about creating open-source tools and platforms t We're proud to showcase some of our current projects: * **OPEN-ARC**: An open-source AI research project, containing base models, notebook implementations, and a community leaderboard. +* **qrforge**: An open-source Python library for generating and art-directing QR codes, with custom module shapes, gradients, shaders, logos, image textures, backgrounds, and shadows, exporting to PNG, SVG, PDF, terminal ANSI, ASCII, or Pillow. * **ValX**: An open-source Python package for text cleaning tasks, including profanity detection and removal, and sensitive information detection and removal. * **Hued**: An open-source Python library for color processing, random color generation, conversion between common types, and retrieval of common color properties, color palettes, and color information. * **DeepDefend**: An open-source Python library for adversarial attacks and defenses in deep learning models, enhancing the security and robustness of AI systems. diff --git a/scripts/test_update_download_badges.py b/scripts/test_update_download_badges.py new file mode 100644 index 0000000..c6eab53 --- /dev/null +++ b/scripts/test_update_download_badges.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Offline tests for scripts/update-download-badges.py. + +The download APIs are not reachable from every environment, so the network +layer is stubbed here with payloads shaped like the real pepy and jsDelivr +responses. Everything else -- count extraction, summing, formatting and the +README rewrite -- is the real production code. + +Run with: python3 -m unittest scripts.test_update_download_badges -v +""" + +from __future__ import annotations + +import io +import os +import sys +import tempfile +import unittest +import urllib.error + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import importlib.util + +_spec = importlib.util.spec_from_file_location( + "udb", os.path.join(os.path.dirname(os.path.abspath(__file__)), "update-download-badges.py") +) +udb = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(udb) + + +def http_error(url): + return urllib.error.HTTPError(url, 401, "Unauthorized", {}, io.BytesIO(b"")) + + +class StubFetcher: + """Maps URLs to canned payloads, and records the headers it was given.""" + + def __init__(self, responses): + self.responses = responses + self.calls = [] + + def __call__(self, url, headers=None, timeout=30): + self.calls.append((url, headers or {})) + for needle, payload in self.responses.items(): + if needle in url: + if isinstance(payload, Exception): + raise payload + return payload + raise urllib.error.URLError(f"unstubbed url: {url}") + + +PEPY = {"total_downloads": 1234, "recent_downloads": {"last_month": 90}} +JSDELIVR = {"type": "github", "name": "infinitode/blurjs", "rank": 42, "totalHits": 5678} + + +class TestCountExtraction(unittest.TestCase): + def test_finds_pepy_total_downloads(self): + self.assertEqual(udb.find_count(PEPY), 1234) + + def test_finds_jsdelivr_total_hits(self): + self.assertEqual(udb.find_count(JSDELIVR), 5678) + + def test_prefers_all_time_key_over_nested_period_hits(self): + payload = {"hits": {"yesterday": 5, "lastMonth": 50}, "totalHits": 999} + self.assertEqual(udb.find_count(payload), 999) + + def test_ignores_non_numeric_matches(self): + self.assertIsNone(udb.find_count({"hits": {"total": 1}, "other": "x"})) + + def test_returns_none_when_absent(self): + self.assertIsNone(udb.find_count({"name": "nothing useful"})) + + def test_does_not_treat_booleans_as_counts(self): + self.assertIsNone(udb.find_count({"downloads": True})) + + +class TestTotals(unittest.TestCase): + def test_pypi_sums_every_package(self): + fetcher = StubFetcher({pkg: {"total_downloads": 100} for pkg in udb.PYPI_PACKAGES}) + totals, failures = udb.pypi_totals(udb.PYPI_PACKAGES, fetcher=fetcher) + self.assertEqual(len(totals), len(udb.PYPI_PACKAGES)) + self.assertEqual(sum(totals.values()), 100 * len(udb.PYPI_PACKAGES)) + self.assertEqual(failures, {}) + + def test_pypi_records_failure_without_abandoning_the_run(self): + responses = {pkg: {"total_downloads": 100} for pkg in udb.PYPI_PACKAGES} + responses["qrforge"] = urllib.error.URLError("boom") + totals, failures = udb.pypi_totals(udb.PYPI_PACKAGES, fetcher=StubFetcher(responses)) + self.assertNotIn("qrforge", totals) + self.assertIn("qrforge", failures) + self.assertEqual(sum(totals.values()), 100 * (len(udb.PYPI_PACKAGES) - 1)) + + def test_pypi_falls_back_to_legacy_endpoint(self): + # with a key configured the keyed v2 endpoint is tried first, so a 401 + # there must fall through to the keyless legacy endpoint + fetcher = StubFetcher({"api/v2/projects": http_error("x"), "api/projects": PEPY}) + totals, failures = udb.pypi_totals(["qrforge"], api_key="secret-key", fetcher=fetcher) + self.assertEqual(totals, {"qrforge": 1234}) + self.assertEqual(failures, {}) + self.assertEqual(len(fetcher.calls), 2) + + def test_pypi_sends_api_key_only_to_keyed_endpoint(self): + fetcher = StubFetcher({"api/v2/projects": PEPY}) + udb.pypi_totals(["qrforge"], api_key="secret-key", fetcher=fetcher) + keyed = [h for url, h in fetcher.calls if "api/v2/projects" in url] + self.assertEqual(keyed, [{"X-Api-Key": "secret-key"}]) + + def test_pypi_skips_keyed_endpoint_when_no_key_configured(self): + fetcher = StubFetcher({"api/projects": PEPY}) + totals, _ = udb.pypi_totals(["qrforge"], api_key=None, fetcher=fetcher) + self.assertEqual(totals, {"qrforge": 1234}) + self.assertNotIn("api/v2/projects", [url for url, _ in fetcher.calls]) + + def test_jsdelivr_sums_repos(self): + fetcher = StubFetcher({"packages/gh": JSDELIVR}) + totals, failures = udb.jsdelivr_totals(["infinitode/blurjs"], fetcher=fetcher) + self.assertEqual(totals, {"infinitode/blurjs": 5678}) + self.assertEqual(failures, {}) + + def test_collect_combines_both_sources(self): + responses = {pkg: {"total_downloads": 10} for pkg in udb.PYPI_PACKAGES} + responses["packages/gh"] = JSDELIVR + result = udb.collect(fetcher=StubFetcher(responses)) + expected = 10 * len(udb.PYPI_PACKAGES) + 5678 + self.assertEqual(result["total"], expected) + self.assertEqual(result["failures"], {}) + + +class TestFormatting(unittest.TestCase): + def test_boundaries(self): + cases = [(0, "0"), (999, "999"), (1_000, "1.0k"), (45_678, "45.7k"), + (999_999, "1000.0k"), (1_234_567, "1.23M"), (12_300_000, "12.30M")] + for value, expected in cases: + self.assertEqual(udb.format_count(value), expected, f"format_count({value})") + + def test_value_is_url_safe_for_shields(self): + for value in (0, 999, 45_678, 1_234_567): + text = udb.format_count(value) + self.assertRegex(text, r"^[0-9.]+[kM]?$") + + +class TestReadmeRewrite(unittest.TestCase): + def setUp(self): + self.readme = f"# Profile\n\n{udb.ANCHOR}\n\nSome text.\n" + + def test_inserts_after_visitor_counter_on_first_run(self): + updated, changed = udb.update_readme(self.readme, 1_234_567) + self.assertTrue(changed) + self.assertIn("![Total Downloads](https://img.shields.io/badge/Total%20Downloads-1.23M-5d17eb)", updated) + self.assertIn(udb.START_MARK, updated) + self.assertIn(udb.END_MARK, updated) + self.assertLess(updated.index(udb.ANCHOR), updated.index(udb.START_MARK)) + + def test_replaces_in_place_on_later_runs(self): + once, _ = udb.update_readme(self.readme, 100) + twice, changed = udb.update_readme(once, 2_000_000) + self.assertTrue(changed) + self.assertEqual(twice.count("TOTAL-DOWNLOADS:START"), 1) + self.assertEqual(twice.count("TOTAL-DOWNLOADS:END"), 1) + self.assertIn("2.00M", twice) + self.assertNotIn("Total%20Downloads-100-", twice) + + def test_idempotent_for_same_value(self): + once, _ = udb.update_readme(self.readme, 1234) + twice, changed = udb.update_readme(once, 1234) + self.assertFalse(changed) + self.assertEqual(once, twice) + + def test_preserves_surrounding_content(self): + updated, _ = udb.update_readme(self.readme, 5_000_000) + self.assertIn("# Profile", updated) + self.assertIn("Some text.", updated) + + def test_no_anchor_and_no_marker_is_a_noop(self): + updated, changed = udb.update_readme("nothing here\n", 1) + self.assertFalse(changed) + self.assertEqual(updated, "nothing here\n") + + +class TestMainEndToEnd(unittest.TestCase): + """Drives the real main() against a throwaway copy of the README.""" + + def _write(self, text): + handle = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") + handle.write(text) + handle.close() + self.addCleanup(os.unlink, handle.name) + return handle.name + + def _run(self, argv, responses): + original = udb.fetch_json + udb.fetch_json = StubFetcher(responses) + self.addCleanup(setattr, udb, "fetch_json", original) + buffer = io.StringIO() + original_stdout, sys.stdout = sys.stdout, buffer + try: + code = udb.main(argv) + finally: + sys.stdout = original_stdout + return code, buffer.getvalue() + + def test_end_to_end_writes_the_tally(self): + readme = f"# Profile\n\n{udb.ANCHOR}\n" + path = self._write(readme) + responses = {pkg: {"total_downloads": 1_000} for pkg in udb.PYPI_PACKAGES} + responses["packages/gh"] = {"totalHits": 4_000} + + code, output = self._run(["--readme", path], responses) + + expected = 1_000 * len(udb.PYPI_PACKAGES) + 4_000 + self.assertEqual(code, 0) + self.assertIn(f"{expected:>12,}", output) + with open(path, encoding="utf-8") as handle: + written = handle.read() + self.assertIn(f"Total%20Downloads-{udb.format_count(expected)}-", written) + + def test_dry_run_leaves_the_file_alone(self): + path = self._write(f"# Profile\n\n{udb.ANCHOR}\n") + with open(path, encoding="utf-8") as handle: + before = handle.read() + responses = {pkg: {"total_downloads": 7} for pkg in udb.PYPI_PACKAGES} + responses["packages/gh"] = {"totalHits": 8} + + code, output = self._run(["--readme", path, "--dry-run"], responses) + + self.assertEqual(code, 0) + self.assertIn("--dry-run", output) + with open(path, encoding="utf-8") as handle: + self.assertEqual(handle.read(), before) + + def test_partial_failure_still_updates_and_exits_nonzero(self): + path = self._write(f"# Profile\n\n{udb.ANCHOR}\n") + responses = {pkg: {"total_downloads": 10} for pkg in udb.PYPI_PACKAGES} + responses["qrforge"] = urllib.error.URLError("boom") + responses["packages/gh"] = {"totalHits": 0} + + code, _ = self._run(["--readme", path], responses) + + self.assertEqual(code, 1) + with open(path, encoding="utf-8") as handle: + self.assertIn("TOTAL-DOWNLOADS:START", handle.read()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/update-download-badges.py b/scripts/update-download-badges.py new file mode 100644 index 0000000..f1a5957 --- /dev/null +++ b/scripts/update-download-badges.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Tally all-time PyPI and jsDelivr downloads and refresh the profile badge. + +Sums the all-time download counts for every package/repo listed below, then +rewrites the badge that sits between the ``TOTAL-DOWNLOADS`` markers in +``profile/README.md``. + +All network access goes through :func:`fetch_json`, so the parsing, summing and +README-rewriting logic can be exercised offline by passing a custom fetcher. + +Usage:: + + python3 scripts/update-download-badges.py --readme profile/README.md + python3 scripts/update-download-badges.py --dry-run # print, don't write +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request + +# --- what gets counted ------------------------------------------------------- + +PYPI_PACKAGES = [ + "qrforge", + "localsitemap", + "pyautoplot", + "valx", + "hued", + "deepdefend", + "codesafe", + "pywebscrapr", + "funcprofiler", + "duplipy", +] + +JSDELIVR_GH = [ + "infinitode/blurjs", + # "johanbeytell1/alloy", # uncomment once it is served through jsDelivr +] + +# (url template, requires an API key) +PEPY_ENDPOINTS = [ + ("https://api.pepy.tech/api/v2/projects/{pkg}", True), + ("https://api.pepy.tech/api/projects/{pkg}", False), # legacy, keyless +] + +JSDELIVR_ENDPOINTS = [ + "https://data.jsdelivr.com/v1/packages/gh/{repo}", + "https://data.jsdelivr.com/v1/package/gh/{repo}", +] + +# Keys that hold an *all-time* count, highest confidence first. +COUNT_KEYS = ( + "total_downloads", + "totaldownloads", + "totalhits", + "downloads", + "hits", +) + +START_RE = re.compile(r"", re.S) +START_MARK = "" +END_MARK = "" +BADGE = "![Total Downloads](https://img.shields.io/badge/Total%20Downloads-{value}-5d17eb)" +ANCHOR = "![](https://komarev.com/ghpvc/?username=infinitode&color=5d17eb)" + + +# --- network ----------------------------------------------------------------- + + +def fetch_json(url, headers=None, timeout=30): + """Fetch *url* and decode it as JSON. The only network call in this module.""" + request = urllib.request.Request( + url, + headers={ + "User-Agent": "Infinitode-download-badge", + "Accept": "application/json", + **(headers or {}), + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +# --- pure logic -------------------------------------------------------------- + + +def _search(node, key): + """Depth-first search for the first numeric value stored under *key*.""" + if isinstance(node, dict): + for name, value in node.items(): + if name.lower() == key and isinstance(value, (int, float)) and not isinstance(value, bool): + return value + for value in node.values(): + found = _search(value, key) + if found is not None: + return found + elif isinstance(node, list): + for item in node: + found = _search(item, key) + if found is not None: + return found + return None + + +def find_count(payload): + """Return the first all-time count found anywhere in a decoded payload.""" + for key in COUNT_KEYS: + value = _search(payload, key) + if value is not None: + return int(value) + return None + + +def first_count(urls, fetcher=None, label=""): + """Try each URL in turn and return the first count that comes back.""" + fetcher = fetcher or fetch_json # resolved here so it stays patchable + errors = [] + for url in urls: + try: + count = find_count(fetcher(url, None)) + except (urllib.error.URLError, ValueError) as error: + errors.append(f"{url} -> {error}") + continue + if count is not None: + return count, url + raise LookupError(f"no download count for {label}: " + "; ".join(errors)) + + +def pypi_totals(packages, api_key=None, fetcher=None): + """All-time download count per PyPI package.""" + fetcher = fetcher or fetch_json + totals, failures = {}, {} + for package in packages: + candidates = [] + for template, needs_key in PEPY_ENDPOINTS: + if needs_key and not api_key: + continue # skip the keyless-incompatible endpoint + candidates.append( + (template.format(pkg=package), {"X-Api-Key": api_key} if needs_key else None) + ) + errors = [] + for url, headers in candidates: + try: + count = find_count(fetcher(url, headers)) + except (urllib.error.URLError, ValueError) as error: + errors.append(f"{url} -> {error}") + continue + if count is not None: + totals[package] = count + break + else: + failures[package] = f"no download count: " + "; ".join(errors) + return totals, failures + + +def jsdelivr_totals(repos, fetcher=None): + """All-time hit count per jsDelivr-served GitHub repo.""" + fetcher = fetcher or fetch_json + totals, failures = {}, {} + for repo in repos: + urls = [template.format(repo=repo) for template in JSDELIVR_ENDPOINTS] + try: + totals[repo], _ = first_count(urls, fetcher, label=repo) + except LookupError as error: + failures[repo] = str(error) + return totals, failures + + +def format_count(total): + """Compact, URL-safe badge value: 1234567 -> '1.23M', 45678 -> '45.7k'.""" + if total >= 1_000_000: + return f"{total / 1_000_000:.2f}M" + if total >= 1_000: + return f"{total / 1_000:.1f}k" + return str(total) + + +def render_block(total): + return f"{START_MARK}\n{BADGE.format(value=format_count(total))}\n{END_MARK}" + + +def update_readme(text, total): + """Replace (or insert) the badge block. Returns ``(new_text, changed)``.""" + block = render_block(total) + if START_RE.search(text): + new_text, replacements = START_RE.subn(lambda _match: block, text, count=1) + return new_text, new_text != text + if ANCHOR in text: # first run: drop the badge under the visitor counter + return text.replace(ANCHOR, f"{ANCHOR}\n{block}", 1), True + return text, False + + +# --- entry point ------------------------------------------------------------- + + +def collect(fetcher=None, api_key=None): + pypi, pypi_failures = pypi_totals(PYPI_PACKAGES, api_key=api_key, fetcher=fetcher) + jsdelivr, jsdelivr_failures = jsdelivr_totals(JSDELIVR_GH, fetcher=fetcher) + return { + "pypi": pypi, + "jsdelivr": jsdelivr, + "failures": {**pypi_failures, **jsdelivr_failures}, + "total": sum(pypi.values()) + sum(jsdelivr.values()), + } + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--readme", default="profile/README.md") + parser.add_argument("--dry-run", action="store_true", help="print the result, change nothing") + args = parser.parse_args(argv) + + result = collect(api_key=os.environ.get("PEPY_API_KEY")) + total = result["total"] + + for name, count in sorted(result["pypi"].items()): + print(f" pypi {name:<16} {count:>12,}") + for name, count in sorted(result["jsdelivr"].items()): + print(f" jsdelivr {name:<25} {count:>12,}") + for name, reason in sorted(result["failures"].items()): + print(f" FAILED {name}: {reason}", file=sys.stderr) + print(f" {'TOTAL':<33} {total:>12,} ({format_count(total)})") + + if args.dry_run: + print("\n--dry-run: README left untouched") + return 0 if not result["failures"] else 1 + + with open(args.readme, encoding="utf-8") as handle: + original = handle.read() + + updated, changed = update_readme(original, total) + if not changed: + print(f"Badge already up to date in {args.readme}.") + return 0 if not result["failures"] else 1 + + with open(args.readme, "w", encoding="utf-8") as handle: + handle.write(updated) + print(f"Updated the badge in {args.readme}.") + return 0 if not result["failures"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main())