Python Course Part 8: Escaping the Standard Library — Virtual Environments

Everything we've built so far has run on Python's standard library — the batteries that ship with the language. That library is huge and excellent. But the real gravity of Python is the other half million packages sitting on PyPI, one pip install away: HTTP clients, data frames, web frameworks, machine-learning toolkits, and the official SDKs we'll use in the finale.
Today we step outside. We'll set up an isolated virtual environment, install our first third-party package, and write a script that pulls live data from a real REST API — the GitHub API.
Here's the target:
--- GitHub Repo Inspector ---
Fetching public repos for 'octocat'...
Found 6 non-fork repositories.
Total stars across them: 18452
Top repositories by stars:
1. Hello-World ⭐ 2554 (no language)
2. Spoon-Knife ⭐ 12100 (HTML)
3. octocat.github.io ⭐ 1100 (CSS)
(The exact numbers will differ when you run it — it's live data.)
1. Why Virtual Environments Exist
Imagine two projects on your machine. Project A needs version 1.0 of some library; Project B needs version 2.0, which changed how things work. If you install packages globally, those two requirements collide — upgrading for B breaks A. This is "dependency hell," and it's miserable.
A virtual environment is an isolated, per-project Python installation. Each project gets its own private folder of packages, completely separate from your system Python and from every other project. Install whatever versions you like in one; the others never notice.
🛑 Dev Callout:
venv+pip≈ Your Package ManagerComing from .NET, Java, or Node? You already know this concept under different names.
pipis Python's package installer — the rough equivalent of NuGet, Maven, ornpm. Arequirements.txtfile is your.csproj<PackageReference>list /pom.xmldependencies /package.json. The one piece that feels different is that Python's isolation is directory-based and activated per shell session: you "enter" a project's environment, and your terminal'spythonandpippoint at that project's private packages until you leave. There's no global lockfile resolver doing it invisibly — you turn it on yourself.
2. Creating and Activating a venv
venv is built into Python — nothing to install. From inside your project folder:
# Create an environment in a folder called .venv
python -m venv .venv
That makes a .venv/ directory holding a private copy of Python and pip. Now activate it so your shell uses it:
# macOS / Linux (Bash or Zsh):
source .venv/bin/activate
# Windows (PowerShell):
.venv\Scripts\Activate.ps1
Your prompt changes to show (.venv) — that's how you know it's active. From now on, python and pip in this terminal refer to the isolated environment. When you're done working, type deactivate to step back out.
Tip: add
.venv/to your.gitignore. You commit the list of dependencies (requirements.txt), not the installed packages themselves — anyone who clones your project recreates the environment from that list.
3. Installing a Package with pip
With the environment active, let's install requests — the de-facto standard library for making HTTP calls in Python. It's friendlier than the built-in urllib, and it's what you'll see in virtually every real project.
pip install requests
Freeze your dependencies into a file so the project is reproducible:
pip freeze > requirements.txt
That writes out every installed package and its exact version. Anyone (including future you) can recreate the environment with one command:
pip install -r requirements.txt
4. Your First HTTP Request
requests makes a GET request a one-liner. Here's the shape, with the error handling you learned in Part 5 — because the network is exactly the kind of hostile, unreliable place that try/except was built for:
import requests
try:
response = requests.get("https://api.github.com/users/octocat", timeout=10)
response.raise_for_status() # turn a 4xx/5xx status into an exception
data = response.json() # parse the JSON body into a Python dict
print(data["name"]) # The Octocat
except requests.exceptions.RequestException as e:
print(f"[ERROR] Request failed: {e}")
Three things worth calling out:
timeout=10— always set a timeout. Without one, a hung server can freeze your script forever. (One of the most common bugs in production HTTP code is a missing timeout.)response.raise_for_status()— by defaultrequestsdoes not throw on a 404 or 500; it just hands you the response. This line converts a bad status into aRequestExceptionso yourexceptcan handle it.response.json()— REST APIs speak JSON. This parses the response body straight into Python dicts and lists, so a JSON object becomes adictand a JSON array becomes alist. No manual parsing.
5. Secrets and Environment Variables
Many APIs need a key or token to identify you. The GitHub API works without one, but unauthenticated requests are rate-limited to a trickle; adding a token lifts the ceiling dramatically.
Here is the rule that matters: never hardcode a secret in your source code. A key pasted into a .py file gets committed to git, pushed to GitHub, and scraped by bots within minutes. The standard place for secrets is an environment variable — a value that lives in your shell's environment, outside your code.
Read one with os.environ.get():
import os
# Returns the value if set, or None if it isn't — no crash either way.
token = os.environ.get("GITHUB_TOKEN")
if token:
print("Token found — using authenticated requests.")
else:
print("No token — using anonymous (rate-limited) requests.")
Set the variable in your shell before running the script:
# macOS / Linux:
export GITHUB_TOKEN="ghp_your_token_here"
# Windows (PowerShell):
$env:GITHUB_TOKEN = "ghp_your_token_here"
Because the secret lives in the environment, not the file, it never lands in git. (For local development, many people keep these in a .env file loaded by the python-dotenv package — also git-ignored. We'll keep it to plain export here, but the principle is the same, and it's exactly how we'll handle the AI key in Part 10.)
6. Bringing It Together: The GitHub Repo Inspector
Time to build the real thing — a script that fetches a GitHub user's public repositories and reports on them. It uses everything: requests, error handling, an optional token from the environment, and the comprehensions and key= sorting from Part 6.
Make sure your venv is active and requests is installed, then create repo_inspector.py:
import os
import requests
API_BASE = "https://api.github.com"
def build_headers():
"""Add an auth header only if a token is in the environment."""
headers = {"Accept": "application/vnd.github+json"}
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def fetch_repos(username):
"""Fetch all public repos for a user. Returns a list of repo dicts."""
url = f"{API_BASE}/users/{username}/repos"
params = {"per_page": 100, "sort": "updated"}
response = requests.get(url, headers=build_headers(), params=params, timeout=10)
response.raise_for_status()
return response.json()
def main():
username = "octocat"
print("--- GitHub Repo Inspector ---")
print(f"Fetching public repos for '{username}'...\n")
try:
repos = fetch_repos(username)
except requests.exceptions.HTTPError as e:
# raise_for_status() raised this — the request reached GitHub but got a bad status.
if e.response.status_code == 404:
print(f"[ERROR] No such user: '{username}'.")
elif e.response.status_code == 403:
print("[ERROR] Rate limited. Set a GITHUB_TOKEN to raise the limit.")
else:
print(f"[ERROR] GitHub returned {e.response.status_code}.")
return
except requests.exceptions.RequestException as e:
# Network-level problem: DNS, timeout, connection refused.
print(f"[ERROR] Could not reach GitHub: {e}")
return
# Filter out forks — we only care about original work. (Part 6 comprehension.)
own_repos = [r for r in repos if not r["fork"]]
total_stars = sum(r["stargazers_count"] for r in own_repos)
# Sort by star count, highest first, and take the top 3. (Part 6 lambda key.)
top = sorted(own_repos, key=lambda r: r["stargazers_count"], reverse=True)[:3]
print(f"Found {len(own_repos)} non-fork repositories.")
print(f"Total stars across them: {total_stars}\n")
print("Top repositories by stars:")
for rank, repo in enumerate(top, start=1):
language = repo["language"] or "no language"
print(f" {rank}. {repo['name']:<20} ⭐ {repo['stargazers_count']:<6} ({language})")
if __name__ == "__main__":
main()
Run it:
python repo_inspector.py
You're now pulling live data off the internet, parsing it, and reporting on it — with graceful handling for a missing user, a rate limit, and a dead connection. Change username to your own GitHub handle and watch your repos show up. That repo["language"] or "no language" trick leans on Part 2's truthy/falsy rule: GitHub returns None for the language of an empty repo, and None or "no language" evaluates to the fallback string.
What's Next?
You can now reach beyond the standard library and talk to the live internet. That's the foundation everything modern is built on.
But a script that hits a real network is also a script that can break in ways your eyes won't catch — an API changes a field, a refactor quietly flips a comparison. In Part 9: Quality Control, we'll bridge the gap between "script" and "software": we'll add type hints to make our intentions explicit, run mypy to catch type mistakes before they run, and write real pytest tests so we know our code works.