TRENDING
A real wooden outdoor sandbox filled with sand and toys, empty of people
September 27, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
Subway turnstiles showing a green ENTER sign and a red DO NOT ENTER sign side by side
September 27, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
Macro photo of a brass keyhole with a key partially inserted in a wooden door
September 27, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel
Akamai's glass headquarters tower in Cambridge, Massachusetts, with the company's logo visible on the facade
September 27, 2026
Anthropic’s $11.6 Billion Akamai Deal Flips the Usual AI Financing Script
A staircase of sequential canal lock chambers at Bingley Five Rise Locks, each gate validating the water level before the next stage
September 27, 2026
How to Build a Multi-Stage AI Agent Pipeline in Python to Stop Errors From Compounding
27 Sep 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A manila file folder with a paperclip clipped to its tab, against a white background
CISA Orders Federal Agencies to Patch a SharePoint RCE Flaw Microsoft First Called Spoofing
September 27, 2026
Five alphabetical thumb-index tabs cut into the edge of a dictionary, each labeled with a letter range
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
Five sample state-issued EBT benefit cards fanned out on a white background
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 209 Posts
News 211 Posts
Learning Hub 180 Posts
Home/Learning Hub/How to Build a pub.dev Package Health Checker in Python to Catch a Rolling-Window Illusion
Learning Hub

How to Build a pub.dev Package Health Checker in Python to Catch a Rolling-Window Illusion

Build a Python tool that checks a pub.dev package's real health, and see why a headline download count can make a healthy package look like it's dying.

September 24, 2026 16 Min Read
18

If you publish a package on pub.dev, the Dart and Flutter package registry, you get a single headline number on your package page: a download count. It is tempting to read that number the way you’d read a total: “my package has been downloaded 12,000,000 times.” That is not what the number means, and if you build any kind of dashboard, alert, or report on top of it without understanding why, you will eventually tell someone their package is dying when it isn’t.

Table Of Content

  • What You’ll Build
  • Prerequisites
  • Step 1: Understand pub.dev’s Real, Documented API
  • A quick note on verifying claims yourself
  • Step 2: Set Up Your Project
  • Step 3: Write (and Break) a Naive Client
  • Why this happens
  • Step 4: Build a Client That Handles This Properly
  • Step 5: Turn a Publish Date Into a Package Age
  • Step 6: Cache Responses With a Real Time-to-Live
  • Step 7: See the Rolling-Window Illusion for Yourself
  • Reading the table
  • Step 8: Write Tests So You Trust Your Own Numbers
  • Step 9: Tie It Together With a Command-Line Tool
  • Verify the Whole Thing End to End
  • Common Mistakes and Gotchas
  • Next Steps

In this tutorial you will build a small, real Python tool that talks to pub.dev’s actual public API, and along the way you will learn a lesson that applies far beyond Dart packages: any metric described as “downloads in the last N days” is a rolling window, not a running total, and a rolling window can make a perfectly healthy, growing project look like it’s in decline the moment an early spike ages out of the window. The same trap applies to npm’s download stats, Docker Hub pull counts, GitHub traffic graphs, and App Store ranking charts. Once you’ve seen the mechanism once, in code you wrote and ran yourself, you’ll recognize it everywhere.

You do not need to know Dart, install Flutter, or write a single line of Dart to follow this tutorial. pub.dev’s API is a plain, public, unauthenticated HTTP JSON API. We’ll talk to it entirely from Python.

What You’ll Build

By the end of this tutorial you’ll have a small package called pkgwatch that:

  • Fetches a package’s real, live data from three of pub.dev’s officially documented API endpoints.
  • Handles a real bug you’ll reproduce yourself: pub.dev’s error response for a missing package isn’t JSON, so a naive client crashes.
  • Caches responses locally with a time-to-live, so you don’t hammer pub.dev’s servers on repeated lookups.
  • Computes a package’s real age from verified publish timestamps, and uses that age to flag when a “30-day” figure doesn’t actually cover 30 real days.
  • Simulates, with clearly labeled synthetic data, exactly how a rolling 30-day window can show a 76 percent “drop” in a package that never lost a single user.
  • Ships with a small test suite you’ll run against both the real API and deterministic synthetic data.

Prerequisites

  • Python 3.10 or newer (this tutorial was built and tested on Python 3.13.14; any 3.10+ interpreter will work).
  • The ability to run pip install and reach the internet, since this tutorial makes real HTTP requests to pub.dev.
  • Basic familiarity with Python: functions, dictionaries, and running a script from the command line. If you’ve never used the requests library before, that’s fine, we’ll explain each call as we go.
  • No pub.dev account, API key, or Dart/Flutter installation of any kind. Every endpoint we use is public and read-only.

Step 1: Understand pub.dev’s Real, Documented API

Before writing any code, it’s worth reading pub.dev’s own rules for its API, because they’re unusually explicit about something a lot of platforms leave vague. pub.dev’s official API documentation says, in its own words:

“This document describes the officially supported API of the pub.dev site. pub.dev may expose API endpoints that are available publicly, but unless they are documented here, we don’t consider them as officially supported, and may change or remove them without notice.”

That page documents exactly five endpoints. The one we care about most is the score endpoint:

GET https://pub.dev/api/packages/<package>/score

You can call it right now with nothing more than a browser or curl. Here’s what it returns for a real, very popular package called http:

curl https://pub.dev/api/packages/http/score
{
  "grantedPoints": 160,
  "maxPoints": 160,
  "likeCount": 8473,
  "downloadCount30Days": 12033717,
  "tags": [
    "publisher:dart.dev",
    "sdk:dart",
    "sdk:flutter",
    "..."
  ]
}

downloadCount30Days is exactly what the name says: the number of downloads in the trailing 30-day window, recalculated continuously as time moves forward. It is not a lifetime total. grantedPoints and maxPoints come from pub.dev’s own automated quality analyzer (called pana), and likeCount is a simple, cumulative count of user likes, which behaves nothing like the download figure since likes are never “forgotten.”

A quick note on verifying claims yourself

If you search around, you may come across writeups claiming pub.dev has an extra, undocumented endpoint under a path like /metrics that exposes 52 weeks of historical weekly download counts. It’s worth checking claims like that yourself rather than building on them secondhand, so here’s what actually happens when you call it:

curl https://pub.dev/api/packages/http/metrics
{
  "score": { "...": "same fields as the score endpoint" },
  "scorecard": {
    "packageName": "http",
    "packageVersion": "1.6.0",
    "panaReport": { "...": "quality analysis, not download history" }
  }
}

That’s a package quality scorecard, not a download history. Whatever that path may have returned in the past, it does not expose historical download counts today, and it was never part of the five endpoints pub.dev’s own documentation lists as officially supported in the first place. This isn’t a criticism of any particular source, it’s the whole point of the lesson: undocumented behavior can change silently, and the only way to know what an API actually does right now is to call it and look.

Step 2: Set Up Your Project

Create a project folder and an isolated virtual environment, then install the two libraries this tutorial uses:

mkdir pkgwatch-tutorial
cd pkgwatch-tutorial
python -m venv venv
venv\Scripts\activate          # Windows
# source venv/bin/activate     # macOS/Linux

pip install requests pytest

This tutorial was built with requests 2.34.2 and pytest 9.1.1. Any recent version of either will work fine.

Step 3: Write (and Break) a Naive Client

Let’s start with the simplest possible client, the kind you’d write in thirty seconds without thinking about edge cases:

# step1_naive_client.py
import requests

BASE_URL = "https://pub.dev/api"


def fetch_score(package_name):
    url = f"{BASE_URL}/packages/{package_name}/score"
    response = requests.get(url, timeout=10)
    return response.json()


if __name__ == "__main__":
    print("Fetching a real package:")
    data = fetch_score("http")
    print(f"  http -> downloadCount30Days={data['downloadCount30Days']}")

    print("\nFetching a package name that does not exist:")
    data = fetch_score("this_package_does_not_exist_xyz123")
    print(data)

Run it:

python step1_naive_client.py

The first call works fine. The second one crashes, and the real traceback looks like this:

Fetching a real package:
  http -> downloadCount30Days=12033717

Fetching a package name that does not exist:
Traceback (most recent call last):
  ...
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

During handling of the above exception, another exception occurred:
Traceback (most recent call last):
  ...
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Why this happens

The bug is in the assumption that response.json() will always succeed. When you ask pub.dev for a package that doesn’t exist, you get an HTTP 404, and the response body for that 404 is not JSON at all. It’s raw XML from the underlying storage layer:

<?xml version='1.0' encoding='UTF-8'?>
<Error><Code>NoSuchKey</Code>
<Message>The specified key does not exist.</Message></Error>

Calling .json() on that body throws a decode error, which is a confusing failure for whoever’s debugging it later, since the real problem (a package that doesn’t exist) gets hidden behind an unrelated-looking parsing exception. This is a common shape of bug with any HTTP client: error responses are frequently structured completely differently from success responses, and code that only tests the happy path won’t catch it.

Step 4: Build a Client That Handles This Properly

Create a package folder for the real client:

mkdir pkgwatch

and add pkgwatch/client.py:

# pkgwatch/client.py
import requests

BASE_URL = "https://pub.dev/api"
USER_AGENT = "pkgwatch-tutorial/1.0 (+https://sxz.io)"


class PackageNotFoundError(Exception):
    """Raised when pub.dev has no package with the given name."""


def _get_json(url):
    response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=10)
    if response.status_code == 404:
        raise PackageNotFoundError(f"No package at {url}")
    response.raise_for_status()
    return response.json()


def fetch_score(package_name):
    """GET /api/packages/<package>/score - the 30-day rolling window the
    pub.dev UI itself shows, plus pana quality score and like count."""
    return _get_json(f"{BASE_URL}/packages/{package_name}/score")


def fetch_metadata(package_name):
    """GET /api/packages/<package> - full version history with publish
    timestamps."""
    return _get_json(f"{BASE_URL}/packages/{package_name}")


def fetch_publisher(package_name):
    """GET /api/packages/<package>/publisher - the verified publisher,
    or None."""
    data = _get_json(f"{BASE_URL}/packages/{package_name}/publisher")
    return data.get("publisherId")

The fix checks the status code before trying to parse the body as JSON, and raises a clear, specific exception instead of an opaque decode error. response.raise_for_status() (documented in the requests API reference) handles any other non-2xx status by raising requests.exceptions.HTTPError, so any failure mode we haven’t specifically anticipated still fails loudly instead of silently returning garbage.

Verify it against a real package, a small unclaimed one, and the missing one, all in one run:

python -c "
from pkgwatch.client import fetch_score, fetch_metadata, fetch_publisher, PackageNotFoundError

score = fetch_score('http')
print('http downloads (30d):', score['downloadCount30Days'])

meta = fetch_metadata('http')
print('http versions published:', len(meta['versions']))

pub = fetch_publisher('dart_exceptor')
print('dart_exceptor publisher:', pub)

try:
    fetch_score('this_package_does_not_exist_xyz123')
except PackageNotFoundError as e:
    print('caught cleanly:', e)
"

Real output from this exact run:

http downloads (30d): 12033717
http versions published: 130
dart_exceptor publisher: None
caught cleanly: No package at https://pub.dev/api/packages/this_package_does_not_exist_xyz123/score

The publisher for dart_exceptor comes back as None because publisher verification is optional. pub.dev’s API returns null for any package that hasn’t been claimed by a verified publisher, so treat the return value of fetch_publisher as always potentially None, never assume it’s a string.

Step 5: Turn a Publish Date Into a Package Age

The metadata endpoint (GET /api/packages/<package>) returns every published version of a package, each with a real published timestamp. The earliest one tells you, with certainty, how old the package actually is, which matters because a “30-day download count” is a meaningless comparison for a package that’s only been alive for 12 days.

Add pkgwatch/health.py:

# pkgwatch/health.py
from datetime import datetime, timezone

from pkgwatch.client import fetch_metadata, fetch_publisher, fetch_score


def _parse_pubdev_timestamp(raw):
    # pub.dev timestamps look like "2012-11-30T20:40:39.500320Z"
    return datetime.fromisoformat(raw.replace("Z", "+00:00"))


def build_health_report(package_name, cache=None):
    if cache is None:
        score = fetch_score(package_name)
        metadata = fetch_metadata(package_name)
        publisher = fetch_publisher(package_name)
    else:
        score, _ = cache.get_or_fetch(
            f"{package_name}:score", lambda: fetch_score(package_name)
        )
        metadata, _ = cache.get_or_fetch(
            f"{package_name}:metadata", lambda: fetch_metadata(package_name)
        )
        publisher, _ = cache.get_or_fetch(
            f"{package_name}:publisher", lambda: fetch_publisher(package_name)
        )

    versions = metadata["versions"]
    first_published = _parse_pubdev_timestamp(versions[0]["published"])
    latest_published = _parse_pubdev_timestamp(versions[-1]["published"])
    age_days = (datetime.now(timezone.utc) - first_published).days

    # A package's rolling 30-day window can only ever cover, at most, its
    # own lifetime. A 12-day-old package cannot have "30 days of history"
    # even though downloadCount30Days is always phrased as a 30-day figure.
    window_days_available = min(30, age_days) if age_days >= 0 else 0

    return {
        "package": package_name,
        "download_count_30_days": score["downloadCount30Days"],
        "window_days_actually_available": window_days_available,
        "like_count": score["likeCount"],
        "pub_points": f"{score['grantedPoints']}/{score['maxPoints']}",
        "publisher": publisher,
        "num_versions_published": len(versions),
        "first_published": first_published.date().isoformat(),
        "latest_published": latest_published.date().isoformat(),
        "age_days": age_days,
    }


def format_report(report):
    lines = [
        f"Package:            {report['package']}",
        f"Publisher:          {report['publisher'] or '(none - unclaimed)'}",
        f"Pub points:         {report['pub_points']}",
        f"Likes:               {report['like_count']}",
        f"Age:                 {report['age_days']} days "
        f"(first published {report['first_published']})",
        f"Versions published: {report['num_versions_published']} "
        f"(latest {report['latest_published']})",
        f"30-day download count: {report['download_count_30_days']}",
    ]
    if report["window_days_actually_available"] < 30:
        lines.append(
            f"  NOTE: package is only {report['age_days']} days old, so this "
            f"'30-day' figure only actually covers "
            f"{report['window_days_actually_available']} real days of history."
        )
    return "\n".join(lines)

Run it against two very different real packages:

python -c "
from pkgwatch.health import build_health_report, format_report

for pkg in ['http', 'dart_exceptor']:
    print(format_report(build_health_report(pkg)))
    print()
"

Real captured output:

Package:            http
Publisher:          dart.dev
Pub points:         160/160
Likes:               8473
Age:                 5045 days (first published 2012-11-30)
Versions published: 130 (latest 2025-11-10)
30-day download count: 12033717

Package:            dart_exceptor
Publisher:          (none - unclaimed)
Pub points:         150/160
Likes:               5
Age:                 108 days (first published 2026-06-08)
Versions published: 4 (latest 2026-06-08)
30-day download count: 57

http is nearly fourteen years old; a 30-day download count is a completely stable, meaningful signal for it. If you ran this against a package published eight days ago, the tool would print an explicit warning that its “30-day” figure only actually covers eight real days, which is the difference between a useful metric and a misleading one.

Step 6: Cache Responses With a Real Time-to-Live

Every lookup above makes three live HTTP requests. If you’re checking the same package repeatedly (in a script that runs every few minutes, say), that’s wasteful and inconsiderate to pub.dev’s servers. pub.dev’s own score endpoint tells you, in its response headers, exactly how long you’re expected to treat a response as fresh:

curl -I https://pub.dev/api/packages/http/score
cache-control: public, max-age=120

max-age=120 means 120 seconds, two minutes. That’s the number to build your own client-side cache around, rather than guessing. Add pkgwatch/cache.py:

# pkgwatch/cache.py
import hashlib
import json
import time
from pathlib import Path

DEFAULT_TTL_SECONDS = 120


class FileCache:
    def __init__(self, cache_dir, ttl_seconds=DEFAULT_TTL_SECONDS):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.ttl_seconds = ttl_seconds

    def _path_for(self, key):
        digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
        return self.cache_dir / f"{digest}.json"

    def get(self, key):
        """Return (value, age_seconds) for a live cache entry, or (None, None)."""
        path = self._path_for(key)
        if not path.exists():
            return None, None
        entry = json.loads(path.read_text(encoding="utf-8"))
        age = time.time() - entry["cached_at"]
        if age > self.ttl_seconds:
            return None, None
        return entry["value"], age

    def set(self, key, value):
        path = self._path_for(key)
        entry = {"cached_at": time.time(), "value": value}
        path.write_text(json.dumps(entry), encoding="utf-8")

    def get_or_fetch(self, key, fetch_fn):
        """Return (value, was_cache_hit)."""
        cached_value, age = self.get(key)
        if cached_value is not None:
            return cached_value, True
        value = fetch_fn()
        self.set(key, value)
        return value, False

Each cache entry is a small JSON file, named by the SHA-256 hash of its lookup key, holding the value plus the timestamp it was written. get_or_fetch is the only method most callers need: give it a key and a function that knows how to fetch a fresh value, and it handles the hit/miss logic for you.

Prove it actually saves real network calls:

python -c "
import time
from pkgwatch.cache import FileCache
from pkgwatch.client import fetch_score

cache = FileCache('.cache_demo', ttl_seconds=120)
call_count = 0

def do_fetch():
    global call_count
    call_count += 1
    return fetch_score('http')

t0 = time.time()
value, hit = cache.get_or_fetch('http:score', do_fetch)
print(f'first call:  cache_hit={hit}, elapsed={time.time()-t0:.3f}s, real_fetches={call_count}')

t0 = time.time()
value, hit = cache.get_or_fetch('http:score', do_fetch)
print(f'second call: cache_hit={hit}, elapsed={time.time()-t0:.4f}s, real_fetches={call_count}')
"

Real captured output:

first call:  cache_hit=False, elapsed=0.400s, real_fetches=1
second call: cache_hit=True, elapsed=0.0004s, real_fetches=1

The second call is about a thousand times faster, and real_fetches stays at 1, confirming no second network request actually happened. Now update build_health_report to accept a cache argument (it already does in the code above) and pass one in:

python -c "
from pkgwatch.cache import FileCache
from pkgwatch.health import build_health_report, format_report

cache = FileCache('.pkgwatch_cache', ttl_seconds=120)
print(format_report(build_health_report('provider', cache=cache)))
"
Package:            provider
Publisher:          dash-overflow.net
Pub points:         150/160
Likes:               11004
Age:                 2896 days (first published 2018-10-19)
Versions published: 70 (latest 2025-08-19)
30-day download count: 1130347

Step 7: See the Rolling-Window Illusion for Yourself

This is the concept the whole tutorial has been building toward. pub.dev doesn’t publish a real, verified per-day download history through any endpoint (we confirmed that in Step 1), so to actually see the mechanism at work, clearly and deterministically, we’ll build it with an honestly-labeled synthetic dataset rather than pretend we’re reading real historical data that doesn’t exist.

Add pkgwatch/window_illusion.py:

# pkgwatch/window_illusion.py
"""Demonstrate, with a clearly-labeled synthetic daily-download series, why a
rolling 30-day window can look like a package is dying even while its real,
cumulative install base is flat or still growing.
"""

WINDOW_SIZE = 30


def simulate_launch_then_trickle(spike_days=7, spike_daily=30, trickle_daily=2, total_days=70):
    """A realistic pattern: a burst of installs right after launch (from a
    blog post, a tweet, a conference talk), then a much smaller steady
    trickle of new installs from organic discovery."""
    daily_downloads = []
    for day in range(total_days):
        if day < spike_days:
            daily_downloads.append(spike_daily)
        else:
            daily_downloads.append(trickle_daily)
    return daily_downloads


def rolling_30_day_sum(daily_downloads):
    """What pub.dev's downloadCount30Days conceptually represents: the sum
    of the last 30 daily download counts, recomputed every day."""
    rolling = []
    for day in range(len(daily_downloads)):
        window_start = max(0, day - WINDOW_SIZE + 1)
        window = daily_downloads[window_start : day + 1]
        rolling.append(sum(window))
    return rolling


def cumulative_installs(daily_downloads):
    """The number that actually matters if you care about total adoption:
    every download that has ever happened, never forgotten."""
    cumulative = []
    running_total = 0
    for value in daily_downloads:
        running_total += value
        cumulative.append(running_total)
    return cumulative

Now run the simulation and inspect specific days:

python -c "
from pkgwatch.window_illusion import simulate_launch_then_trickle, rolling_30_day_sum, cumulative_installs

daily = simulate_launch_then_trickle()
rolling = rolling_30_day_sum(daily)
cumulative = cumulative_installs(daily)

print(f'{\"day\":>4} {\"daily\":>6} {\"rolling_30d\":>12} {\"cumulative\":>11}')
for day in [0, 6, 7, 20, 29, 30, 36, 37, 50, 69]:
    print(f'{day:>4} {daily[day]:>6} {rolling[day]:>12} {cumulative[day]:>11}')

peak = max(rolling)
peak_day = rolling.index(peak)
trough = min(rolling[peak_day:])
trough_day = rolling.index(trough, peak_day)
print()
print(f'Rolling 30-day count peaked at {peak} on day {peak_day}.')
print(f'By day {trough_day}, it had fallen to {trough}, a {(1 - trough/peak)*100:.1f}% apparent drop.')
print(f'Cumulative installs never went down: {cumulative[peak_day]} -> {cumulative[trough_day]}.')
"

Real captured output:

 day  daily  rolling_30d  cumulative
   0     30           30          30
   6     30          210         210
   7      2          212         212
  20      2          238         238
  29      2          256         256
  30      2          228         258
  36      2           60         270
  37      2           60         272
  50      2           60         298
  69      2           60         336

Rolling 30-day count peaked at 256 on day 29.
By day 36, it had fallen to 60, a 76.6% apparent drop.
Cumulative installs never went down: 256 -> 270.

Reading the table

For the first 7 days, every real download is still inside the 30-day window, so rolling_30d and cumulative are identical: nothing has fallen out yet. Starting around day 30, the launch-week days begin sliding out of the trailing 30-day window one at a time, so rolling_30d drops fast even though the package is still gaining a steady 2 installs a day, forever. By day 36, the entire 7-day launch spike has aged out of the window, and rolling_30d settles at a flat 60 (2 downloads a day times the 30-day window), while cumulative keeps climbing every single day.

If you were only watching the rolling number, day 29 to day 36 looks like a package cratering, a 76.6 percent collapse. It isn’t. It’s the exact same 2-downloads-a-day trickle it’s had since day 8; the window just moved. This is precisely the mechanism behind a real, live example we captured earlier in this tutorial: the real downloadCount30Days figure for dart_exceptor dropped from 174 to 57 within about a day of testing, a 67 percent apparent decline, purely because an early spike aged a day further out of its own rolling window. Nobody uninstalled anything. The number just measures something narrower than most people assume.

Step 8: Write Tests So You Trust Your Own Numbers

Create test_pkgwatch.py in your project root:

# test_pkgwatch.py
import time

import pytest
import requests

from pkgwatch.cache import FileCache
from pkgwatch.client import PackageNotFoundError, fetch_score
from pkgwatch.window_illusion import (
    cumulative_installs,
    rolling_30_day_sum,
    simulate_launch_then_trickle,
)


@pytest.fixture
def cache_dir(tmp_path):
    return tmp_path / ".cache"


def test_real_package_returns_expected_fields():
    data = fetch_score("http")
    assert "downloadCount30Days" in data
    assert data["downloadCount30Days"] > 0
    assert data["maxPoints"] == 160


def test_nonexistent_package_raises_clean_error():
    with pytest.raises(PackageNotFoundError):
        fetch_score("this_package_does_not_exist_xyz123")


def test_cache_miss_then_hit(cache_dir):
    cache = FileCache(cache_dir, ttl_seconds=60)
    calls = {"count": 0}

    def fake_fetch():
        calls["count"] += 1
        return {"value": 42}

    value1, hit1 = cache.get_or_fetch("key", fake_fetch)
    value2, hit2 = cache.get_or_fetch("key", fake_fetch)

    assert hit1 is False
    assert hit2 is True
    assert value1 == value2 == {"value": 42}
    assert calls["count"] == 1


def test_cache_expires_after_ttl(cache_dir):
    cache = FileCache(cache_dir, ttl_seconds=0.2)
    calls = {"count": 0}

    def fake_fetch():
        calls["count"] += 1
        return calls["count"]

    first, _ = cache.get_or_fetch("key", fake_fetch)
    time.sleep(0.3)
    second, hit = cache.get_or_fetch("key", fake_fetch)

    assert first == 1
    assert second == 2
    assert hit is False
    assert calls["count"] == 2


def test_rolling_window_sum_matches_hand_computation():
    daily = [10, 10, 10, 10, 10]
    rolling = rolling_30_day_sum(daily)
    assert rolling == [10, 20, 30, 40, 50]


def test_rolling_window_drops_stale_days_once_window_fills():
    daily = [1] * 35
    rolling = rolling_30_day_sum(daily)
    assert rolling[29] == 30
    assert rolling[30] == 30


def test_cumulative_never_decreases():
    daily = simulate_launch_then_trickle()
    cumulative = cumulative_installs(daily)
    for earlier, later in zip(cumulative, cumulative[1:]):
        assert later >= earlier


def test_rolling_window_can_drop_sharply_while_cumulative_rises():
    daily = simulate_launch_then_trickle(spike_days=7, spike_daily=30, trickle_daily=2, total_days=40)
    rolling = rolling_30_day_sum(daily)
    cumulative = cumulative_installs(daily)

    peak = max(rolling)
    peak_day = rolling.index(peak)
    later_day = peak_day + 8
    assert rolling[later_day] < rolling[peak_day]
    assert cumulative[later_day] >= cumulative[peak_day]


def test_score_endpoint_is_genuinely_public_no_auth_header_needed():
    response = requests.get("https://pub.dev/api/packages/http/score", timeout=10)
    assert response.status_code == 200
    assert "downloadCount30Days" in response.json()

Run the suite:

pytest test_pkgwatch.py -v

Real captured output:

test_pkgwatch.py::test_real_package_returns_expected_fields PASSED
test_pkgwatch.py::test_nonexistent_package_raises_clean_error PASSED
test_pkgwatch.py::test_cache_miss_then_hit PASSED
test_pkgwatch.py::test_cache_expires_after_ttl PASSED
test_pkgwatch.py::test_rolling_window_sum_matches_hand_computation PASSED
test_pkgwatch.py::test_rolling_window_drops_stale_days_once_window_fills PASSED
test_pkgwatch.py::test_cumulative_never_decreases PASSED
test_pkgwatch.py::test_rolling_window_can_drop_sharply_while_cumulative_rises PASSED
test_pkgwatch.py::test_score_endpoint_is_genuinely_public_no_auth_header_needed PASSED

============================== 9 passed in 1.59s ==============================

Two of these tests (test_real_package_returns_expected_fields and test_score_endpoint_is_genuinely_public_no_auth_header_needed) make real live calls to pub.dev. That’s a deliberate choice for this tutorial, since the whole point is proving these numbers are real, but in a production test suite you’d normally mark network-dependent tests separately (with a marker like @pytest.mark.integration) so your fast unit tests can run offline.

Step 9: Tie It Together With a Command-Line Tool

Add pkgwatch/cli.py:

# pkgwatch/cli.py
import sys

from pkgwatch.cache import FileCache
from pkgwatch.client import PackageNotFoundError
from pkgwatch.health import build_health_report, format_report


def main(argv=None):
    argv = argv if argv is not None else sys.argv[1:]
    if not argv:
        print("usage: python -m pkgwatch.cli <package> [<package> ...]")
        return 1

    cache = FileCache(".pkgwatch_cache", ttl_seconds=120)
    exit_code = 0
    for package_name in argv:
        try:
            report = build_health_report(package_name, cache=cache)
            print(format_report(report))
            print()
        except PackageNotFoundError:
            print(f"Package: {package_name}")
            print("  NOT FOUND on pub.dev\n")
            exit_code = 1
    return exit_code


if __name__ == "__main__":
    sys.exit(main())

Verify the Whole Thing End to End

Run it against three real packages and one that doesn’t exist, in a single invocation:

python -m pkgwatch.cli http provider dart_exceptor does_not_exist_xyz123

Real captured output:

Package:            http
Publisher:          dart.dev
Pub points:         160/160
Likes:               8473
Age:                 5045 days (first published 2012-11-30)
Versions published: 130 (latest 2025-11-10)
30-day download count: 12033717

Package:            provider
Publisher:          dash-overflow.net
Pub points:         150/160
Likes:               11004
Age:                 2896 days (first published 2018-10-19)
Versions published: 70 (latest 2025-08-19)
30-day download count: 1130347

Package:            dart_exceptor
Publisher:          (none - unclaimed)
Pub points:         150/160
Likes:               5
Age:                 108 days (first published 2026-06-08)
Versions published: 4 (latest 2026-06-08)
30-day download count: 57

Package: does_not_exist_xyz123
  NOT FOUND on pub.dev

The command exits with status 1 because one of the four lookups failed to find a package, which is exactly the behavior you want if you were wiring this into a CI check or a monitoring script: a missing package should be visibly different from a healthy one, not silently swallowed.

Run it a second time immediately afterward and it finishes almost instantly, since every value except the newly-requested one is still inside its 2-minute cache window. That’s your confirmation the caching layer from Step 6 is actually wired into the report builder, not just tested in isolation.

Common Mistakes and Gotchas

  • Treating downloadCount30Days as a lifetime total. It resets conceptually every day as old days fall out of the window. A dip does not mean a decline; check the package’s age and, if you can, the trend over multiple weeks before drawing any conclusion.
  • Parsing an error response as JSON without checking the status code first. We reproduced this exact bug in Step 3. Many APIs, not just pub.dev, return non-JSON bodies (HTML, XML, or plain text) for error conditions, so always check response.status_code or call raise_for_status() before calling .json().
  • Ignoring how young a package is when interpreting a windowed figure. A package published 5 days ago cannot, by definition, have “30 days” of real history behind its 30-day number, even though the field name never says so.
  • Hammering a public API instead of caching. pub.dev tells you its own intended freshness window right in the cache-control response header. Respect it; a 2-minute local cache costs you nothing in accuracy and saves real load on someone else’s servers.
  • Trusting an undocumented or “unofficial” endpoint you read about somewhere without testing it yourself. We found in Step 1 that a commonly cited path doesn’t do what it’s sometimes described as doing. Undocumented behavior is undocumented precisely because the provider hasn’t committed to it staying that way.

Next Steps

This tool makes plain, unauthenticated HTTP calls with no retry logic, so a single dropped connection or a transient 503 from pub.dev will currently just fail the whole lookup. If you want to make it production-ready, pairing it with exponential backoff and jitter is the natural next step: see How to Retry Failed API Calls in Python With Exponential Backoff and Jitter for a from-scratch implementation you can drop straight into pkgwatch/client.py.

The broader lesson here, that a displayed metric can badly mislead you unless you understand exactly what window or scope it covers, comes up constantly outside of package registries too. If you maintain a website and want to see the same kind of “don’t trust the headline number, go verify it against the real API” mindset applied to search traffic data, see How to Detect AI Query Fan-Out Impressions in Google Search Console With Python.

You could also extend pkgwatch itself: compare several packages’ pub points side by side, watch a package’s downloadCount30Days over repeated runs and store the history in a local SQLite database (giving you the real historical trend pub.dev itself doesn’t expose), or wrap the CLI in a scheduled job that alerts you only when a package’s pub points score drops, a signal that doesn’t suffer from the windowing problem at all.

Tags:

CachingData AnalysispytestPythonrest-api

Share

Two empty blackjack tables inside a European casino, evoking the multi-agent blackjack scenario researchers used to test AI collusion detection
Previous Post

Oxford’s NARCBench Turns Catching Colluding AI Agents Into a Mind-Reading Problem

Rows of numbered blue storage lockers in a station corridor, a literal metaphor for shared storage reused across many different occupants
Next Post

A Cloudflare Containers Bug Let Customers Recover Other Tenants’ Leftover Disk Data

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
27 Sep
CISA Orders Federal Agencies to Patch a SharePoint RCE Flaw Microsoft First Called Spoofing
26 Sep
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
Trending
September 27, 2026
CISA Orders Federal Agencies to Patch a SharePoint RCE Flaw Microsoft First Called Spoofing
September 26, 2026
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
September 26, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
September 26, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026