PyDA Course
Intermediate Half-scaffolded ⚡ +100 XP +15 XP per step

Build a Dependency-Freshness Checker

CLI ToolsAutomation

Every real Python project accumulates dependencies, and every dependency eventually falls behind, a security fix ships, a bug gets patched, a new feature lands, and your pyproject.toml just… doesn’t know. This project builds the tool that tells you: a real CLI that reads a pyproject.toml, asks PyPI’s public API what the current version of each dependency actually is, and reports which ones you’re behind on, the same category of tool as pip list --outdated, but one you understand completely because you built it yourself.

This is optional and ungraded, a good fit once you’ve finished Python 101 (no Data Analysis or API-key experience needed, this project uses no paid or gated service at all). See Real-World Projects for the full, growing list.

🎯 What you’ll do

  1. Parse a real pyproject.toml file and extract its dependency list.
  2. Query PyPI’s public JSON API to find each dependency’s current published version.
  3. Compare your pinned/installed version against the latest, using real semantic-version parsing, not naive string comparison.
  4. Print a clean, categorized freshness report (up to date / outdated / unable to check).

Where to run this

Locally with uv is the path this lesson’s steps follow, and the recommended one, you’ll point it at a real pyproject.toml (this course’s own repo has several, or use any project of yours). The Setup section below walks through installing it.

GitHub Codespaces is a zero-setup alternative if you’d rather not install anything locally yet: open the whole course repo in a free Codespace (Node, Python, and uv are already installed) and run the exact same uv commands from a terminal in your browser tab, plus you’ll have plenty of real pyproject.toml files nearby to point the tool at.

Google Colab, Kaggle Notebooks, or Binder also work, since this project needs no API key or GPU, a real, runnable notebook version lives at examples/dependency-freshness-checker/notebook.ipynb. Click a badge to launch it directly, no local install at all:

Open In Colab Open In Kaggle Binder

Be honest with yourself about the tradeoff, though: a notebook can only check whatever sample pyproject.toml content you paste into it, not point at a real project folder on disk the way the local CLI can.

Setup

uv is a single tool that replaces the usual “install Python, then install pip, then install a virtual environment tool, then install packages” chain, it can install and manage Python versions itself, alongside your project’s dependencies.

macOS / Linux (terminal):

curl -LsSf https://astral.sh/uv/install.sh | sh

Windows (PowerShell):

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Close and reopen your terminal, then confirm it installed:

uv --version

Then set up the project:

uv init dependency-checker
cd dependency-checker
uv add requests packaging

No API key is needed anywhere in this project, PyPI’s JSON API (https://pypi.org/pypi/<package>/json) is public, free, and requires no signup or authentication. requests handles the HTTP calls; packaging gives you real, correct semantic-version parsing (packaging.version.Version) instead of comparing version strings character by character, which breaks the moment you compare "2.9" against "2.10" as plain text.

Why not just string-compare versions?

"2.10.0" > "2.9.0" is True mathematically, but as plain strings, "2.10.0" < "2.9.0", because "1" < "9" character-by-character, Python never gets far enough to notice 10 > 9. Real version comparison has to parse each part as a number first. The packaging library (the same one pip itself uses internally) does this correctly, including pre-release versions like 2.0.0rc1.

Step 1: Parse a real pyproject.toml

Python 3.11+ ships tomllib in the standard library, no install needed to read TOML (only uv add-ing a package if you needed to write TOML, which this project doesn’t).

1.1 Write and run the parser

👟 Starter hint: Open the file in binary mode, hand it to tomllib.load, and dig into the parsed dict to reach project.dependencies, that key holds the raw specifier strings, one per dependency:

# parse_deps.py
import tomllib
from pathlib import Path

def load_dependencies(pyproject_path: str) -> list[str]:
    """Read a pyproject.toml and return its raw dependency specifier strings,
    e.g. ["requests>=2.31", "packaging"]."""
    with Path(pyproject_path).open("rb") as f:
        data = tomllib.load(f)
    return data.get("project", {}).get("dependencies", [])

if __name__ == "__main__":
    deps = load_dependencies("pyproject.toml")
    for dep in deps:
        print(dep)
uv run python parse_deps.py

🎯 Expected output: One line per dependency, printed as the raw specifier exactly as it appears in pyproject.toml (e.g. requests>=2.31), no parsing of the name or version yet, that’s the next step.

🩹 If it’s off: An empty result usually means the TOML’s dependencies live somewhere other than project.dependencies, some tools (Poetry, for one) put them under tool.poetry.dependencies instead; .get(..., {}) chains return {}/[] rather than crashing, so a wrong path fails silently instead of loudly. If you get a TOMLDecodeError, the file has a real syntax error, open it and check for a stray comma or unmatched bracket.

1.2 Verify

✅ Checklist

  • ✅ Running this against your own project’s pyproject.toml prints each dependency’s raw specifier string.
  • ✅ You can explain why tomllib needs the file opened in binary mode ("rb"), not text mode.

🤔 Socratic Question(s)

A pyproject.toml’s dependencies list holds strings like "requests>=2.31", not just package names. What’s the name on its own, separate from any version constraint attached to it? You’ll need to split those apart cleanly in the next step, and a real dependency string can be sloppier than it looks (extra spaces, extras like "requests[socks]>=2.31", exact-pin == instead of >=), which of those would break a naive .split(">=")?

Step 2: Look up each package’s current version on PyPI

2.1 Write the name-parser and the PyPI lookup

👟 Starter hint: Two small functions: one that strips the version constraint off a specifier with a regex to get just the name, and one that GETs https://pypi.org/pypi/<name>/json and pulls info.version out of the response, treating a 404 as “not found,” not an error:

# check_pypi.py
import re

import requests

def parse_package_name(specifier: str) -> str:
    """Extract just the package name from a specifier like 'requests>=2.31'
    or 'requests[socks]==2.31.0'."""
    match = re.match(r"^[A-Za-z0-9_.-]+", specifier.strip())
    if not match:
        raise ValueError(f"Could not parse a package name from {specifier!r}")
    return match.group(0)

def get_latest_version(package_name: str) -> str | None:
    """Query PyPI's public JSON API for a package's current published
    version. Returns None if the package isn't found (a typo, or a private
    package not on PyPI)."""
    url = f"https://pypi.org/pypi/{package_name}/json"
    response = requests.get(url, timeout=10)
    if response.status_code == 404:
        return None
    response.raise_for_status()
    return response.json()["info"]["version"]

if __name__ == "__main__":
    for specifier in ["requests>=2.31", "packaging", "not-a-real-package-xyz"]:
        name = parse_package_name(specifier)
        latest = get_latest_version(name)
        print(f"{name}: latest is {latest!r}")
uv run python check_pypi.py

Notice the deliberately-broken "not-a-real-package-xyz" in the test list, it should print latest is None, not crash. A real tool has to handle a typo’d or private package name gracefully, not assume every name in a pyproject.toml resolves.

🎯 Expected output: requests: latest is '2.x.x' and packaging: latest is '2x.x' with real current version numbers, then not-a-real-package-xyz: latest is None, no traceback.

🩹 If it’s off: A traceback on the fake package name means the 404 check isn’t catching it, confirm response.status_code == 404 runs before raise_for_status(), not after. If a real package’s version looks stale, you might be hitting a cached response from an earlier run of something else, rerun to confirm it’s not a network fluke.

2.2 Verify

✅ Checklist

  • ✅ Real packages print their real, current PyPI version, you can cross-check one against pypi.org in your browser.
  • ✅ The fake package name prints None instead of crashing the script.

🤔 Socratic Question(s)

response.raise_for_status() runs after the explicit 404 check above it, why single out 404 specially instead of letting raise_for_status() handle every non-2xx status the same way? What would happen to this script’s control flow if that 404 check weren’t there?

Step 3: Compare versions correctly

3.1 Wrap the comparison so it never lies

A single comparison looks one-line simple, but the honest part is handling a version string packaging can’t parse at all, that’s a different, real case you must answer, not a crash.

👟 Starter hint: Wrap Version(current) < Version(latest) in a try/except InvalidVersion, the comparison itself is one line, the honesty is in what you return when a string isn’t a real version at all:

# compare.py
from packaging.version import InvalidVersion, Version

def is_outdated(current: str, latest: str) -> bool | None:
    """Compare two version strings properly. Returns None (not True/False)
    if either string isn't a version packaging can parse — e.g. a git URL
    or a local path used as a 'version', which pyproject.toml permits."""
    try:
        return Version(current) < Version(latest)
    except InvalidVersion:
        return None

if __name__ == "__main__":
    print(is_outdated("2.9.0", "2.10.0"))  # True — real semantic comparison
    print(is_outdated("2.10.0", "2.9.0"))  # False
    print(is_outdated("2.10.0", "2.10.0"))  # False — equal, not outdated
    print(is_outdated("not-a-version", "2.10.0"))  # None — can't compare
uv run python compare.py

🎯 Expected output: True, False, False, None, one per line, in that exact order matching the four calls above.

🩹 If it’s off: If the first line prints False instead of True, you likely compared the raw strings instead of wrapping them in Version(...) first, that’s the exact string-comparison bug this whole step exists to avoid. If the last line raises instead of printing None, the except is catching the wrong exception name or nothing at all.

3.2 Verify

✅ Checklist

  • is_outdated("2.9.0", "2.10.0") prints True, proving this isn’t naive string comparison.
  • ✅ An unparseable version string returns None, not a crash or a silently-wrong True/False.

🤔 Socratic Question(s)

Why does is_outdated return three possible outcomes (True, False, None) instead of just two? What real, non-hypothetical situation in a pyproject.toml would make None the only honest answer?

Step 4: Put it together into a real freshness report

4.1 Build and print the report

👟 Starter hint: Wire the three previous steps together in build_report: load specifiers, resolve each to a name and a latest version, compare, and collect the results into a small DependencyStatus dataclass per dependency, then bucket and print them in print_report:

# freshness_report.py
from dataclasses import dataclass

from check_pypi import get_latest_version, parse_package_name
from compare import is_outdated
from parse_deps import load_dependencies

@dataclass
class DependencyStatus:
    name: str
    current_specifier: str
    latest: str | None
    outdated: bool | None

def build_report(pyproject_path: str) -> list[DependencyStatus]:
    report = []
    for specifier in load_dependencies(pyproject_path):
        name = parse_package_name(specifier)
        latest = get_latest_version(name)
        # A specifier with no pinned version (just "requests") has nothing
        # concrete to compare against — treat that case as "unknown" too.
        pinned = specifier[len(name) :].lstrip(">=<~! ")
        outdated = is_outdated(pinned, latest) if pinned and latest else None
        report.append(DependencyStatus(name, specifier, latest, outdated))
    return report

def print_report(report: list[DependencyStatus]) -> None:
    outdated = [d for d in report if d.outdated is True]
    fresh = [d for d in report if d.outdated is False]
    unknown = [d for d in report if d.outdated is None]

    if outdated:
        print(f"⚠️  {len(outdated)} outdated:")
        for d in outdated:
            print(f"   {d.name}: pinned {d.current_specifier!r}, latest is {d.latest}")
    if fresh:
        print(f"✅ {len(fresh)} up to date: {', '.join(d.name for d in fresh)}")
    if unknown:
        print(f"❓ {len(unknown)} could not be checked: {', '.join(d.name for d in unknown)}")

if __name__ == "__main__":
    import sys

    path = sys.argv[1] if len(sys.argv) > 1 else "pyproject.toml"
    print_report(build_report(path))
uv run python freshness_report.py pyproject.toml

Try pointing it at a pyproject.toml from a real, older project you have lying around (or this course repo’s own examples/*/pyproject.toml files), that’s where you’ll actually see the “outdated” bucket populate with real results, not just up-to-date dependencies you added five minutes ago.

🎯 Expected output: A three-part summary, ⚠️ N outdated: with a line per package showing pinned vs. latest, ✅ N up to date: as a comma-joined list, and ❓ N could not be checked: for anything unresolved. Sections with zero entries don’t print at all.

🩹 If it’s off: If every dependency lands in “up to date” even on an old pyproject.toml, check that pinned in build_report actually stripped the comparison operator (lstrip(">=<~! ")), a leftover >= prefix makes Version(...) raise, which silently routes everything into “unknown” via the if pinned and latest guard, not “outdated”. A ModuleNotFoundError on check_pypi/compare/parse_deps means you’re running this script from a different folder than the other three files, uv run needs them side by side.

4.2 Verify

✅ Checklist

  • ✅ Running the report against your project’s own pyproject.toml prints a categorized ✅/⚠️/❓ summary.
  • ✅ Pointing it at an intentionally older pyproject.toml shows at least one real outdated dependency.

🤔 Socratic Question(s)

This script makes one HTTP request per dependency, one after another. For a pyproject.toml with 40 dependencies, what’s the user-experienced cost of that, and what’s a concrete way you’d speed it up (hint: these requests don’t depend on each other’s results at all)?

⚠️ Common pitfalls

  • Naive string version comparison. "2.9" > "2.10" as plain strings, this is the single most common bug in a hand-rolled version checker. Always parse with packaging.version.Version, never compare version strings directly.
  • Assuming every dependency name resolves on PyPI. Private/internal packages, typos, and git-URL “dependencies” are all real things pyproject.toml permits, your script has to degrade gracefully (a None/“unknown” bucket), not crash the whole report over one unusual entry.
  • Treating an unpinned dependency ("requests" with no version at all) as “outdated”. There’s nothing to compare against, that’s a different, honest “unknown” case, not a false positive.
  • Hammering PyPI without a timeout. Always pass timeout=... to requests.get(), a single hung request without one can freeze the whole tool indefinitely.

What you just built

A real freshness-checking CLI, the same core idea behind pip list --outdated, GitHub’s Dependabot, and Renovate, built from first principles: parse a manifest, query a real public API, compare versions correctly, and report the result clearly. Nothing here was hidden behind a library that does version comparison for you, you now know exactly why naive string comparison breaks and how to avoid it, a detail that trips up plenty of hand-rolled tools in the wild.

Where to go from here

  • Speed it up with concurrent requests (concurrent.futures.ThreadPoolExecutor or asyncio + httpx), the Socratic question above is your starting point.
  • Add a --fix mode that rewrites the pyproject.toml’s version constraints to the latest versions automatically (careful: always show a diff or require confirmation before writing to a real file, the same safety principle used elsewhere in this course’s projects).
  • Check PyPI’s release date, not just version number, and flag anything untouched for over a year as possibly abandoned, a genuinely different, complementary signal from “is this outdated”.
  • Compare against uv.lock’s actually-installed versions too, not just the pyproject.toml specifiers, the two can legitimately disagree.

Share your project with the class

Built something you’re proud of? examples/student-projects/ is a gallery of projects other students have submitted, and its README has a full, beginner-friendly walkthrough for adding yours via a pull request, even if you’ve never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.

Welcome to writing Python outside the browser. 🎓

Finish every step, then mark this project complete to claim its XP.