Module 4: Consuming Public APIs

CLI architecture

Capsule overview

You have 5 APIs to integrate. If you start writing code straight away — a requests.get() here, a print() there — you'll end up with an unmaintainable 500-line file. Before you write the first integration, you need an architecture: where each file goes, how the subcommands are organized, how authentication is configured, and what pattern all the APIs follow.

This capsule walks you through designing the CLI before coding the integrations. You'll define the folder structure, set up argparse with subcommands, adapt the APIClient from Module 3 as a base class, implement configuration handling with environment variables, and decide how to format the output. By the end you'll have a working skeleton where each API "plugs in" as an independent module.


Project structure

rest-client-cli/
├── cli.py                  # Entry point — argparse and subcommands
├── base_client.py          # BaseAPIClient (inherited from Module 3)
├── config.py               # API keys and configuration from env vars
├── formatters.py           # Output formatting functions
├── apis/
│   ├── __init__.py
│   ├── jsonplaceholder.py  # JSONPlaceholder integration
│   ├── dogs.py             # Dog CEO integration
│   ├── countries.py        # REST Countries integration
│   ├── github_api.py       # GitHub integration
│   └── weather.py          # OpenWeather integration
├── requirements.txt        # Dependencies
└── README.md               # Documentation

Why this structure

Each file has one responsibility:

  • cli.py — Only parses arguments and calls the right function
  • base_client.py — Only handles HTTP (inherited from Module 3)
  • config.py — Only reads configuration from the environment
  • formatters.py — Only formats data for display
  • apis/*.py — Each file knows about one API only

If tomorrow you need to add a 6th API, you create apis/new_api.py and add a subcommand in cli.py. You don't touch anything else.

Creating the structure

import os

dirs = ["rest-client-cli", "rest-client-cli/apis"]
files = [
    "rest-client-cli/cli.py",
    "rest-client-cli/base_client.py",
    "rest-client-cli/config.py",
    "rest-client-cli/formatters.py",
    "rest-client-cli/apis/__init__.py",
    "rest-client-cli/apis/jsonplaceholder.py",
    "rest-client-cli/apis/dogs.py",
    "rest-client-cli/apis/countries.py",
    "rest-client-cli/apis/github_api.py",
    "rest-client-cli/apis/weather.py",
    "rest-client-cli/requirements.txt",
]

for d in dirs:
    os.makedirs(d, exist_ok=True)
    print(f"  📁 {d}/")

for f in files:
    if not os.path.exists(f):
        open(f, "w").close()
    print(f"  📄 {f}")

print(f"\n✅ Structure created: {len(dirs)} folders, {len(files)} files")

Expected output:

  📁 rest-client-cli/
  📁 rest-client-cli/apis/
  📄 rest-client-cli/cli.py
  📄 rest-client-cli/base_client.py
  📄 rest-client-cli/config.py
  📄 rest-client-cli/formatters.py
  📄 rest-client-cli/apis/__init__.py
  📄 rest-client-cli/apis/jsonplaceholder.py
  📄 rest-client-cli/apis/dogs.py
  📄 rest-client-cli/apis/countries.py
  📄 rest-client-cli/apis/github_api.py
  📄 rest-client-cli/apis/weather.py
  📄 rest-client-cli/requirements.txt

✅ Structure created: 2 folders, 11 files

argparse: subcommands per API

argparse is Python's standard module for CLIs. We pick it over the alternatives because it ships with Python (no extra dependencies) and it's enough for this project.

Command structure

python cli.py <api> <action> [options]

Examples:
  python cli.py github user octocat
  python cli.py github repos octocat --limit 5
  python cli.py weather city "Mexico City"
  python cli.py dogs random
  python cli.py dogs breed labrador
  python cli.py jsonplaceholder posts --limit 5
  python cli.py jsonplaceholder post 42
  python cli.py countries search mexico
  python cli.py countries code MX

Implementation with subparsers

import argparse
import sys


def create_parser():
    """Creates the main parser with one subcommand per API."""
    parser = argparse.ArgumentParser(
        prog="cli.py",
        description="REST Client CLI — Consume 5+ public APIs",
        epilog="Use 'cli.py <api> --help' to see each API's options"
    )

    subparsers = parser.add_subparsers(dest="api", help="API to query")

    # --- GitHub ---
    gh = subparsers.add_parser("github", help="GitHub API")
    gh_sub = gh.add_subparsers(dest="action")

    gh_user = gh_sub.add_parser("user", help="Info about a user")
    gh_user.add_argument("username", help="GitHub username")

    gh_repos = gh_sub.add_parser("repos", help="A user's repos")
    gh_repos.add_argument("username", help="GitHub username")
    gh_repos.add_argument("--limit", type=int, default=5,
                          help="Number of repos (default: 5)")
    gh_repos.add_argument("--sort", default="updated",
                          choices=["updated", "stars", "name"],
                          help="Sort by (default: updated)")

    # --- Weather ---
    wt = subparsers.add_parser("weather", help="OpenWeather API")
    wt_sub = wt.add_subparsers(dest="action")

    wt_city = wt_sub.add_parser("city", help="Weather for a city")
    wt_city.add_argument("name", help="City name")
    wt_city.add_argument("--units", default="metric",
                         choices=["metric", "imperial"],
                         help="Units (default: metric)")

    # --- Dogs ---
    dg = subparsers.add_parser("dogs", help="Dog CEO API")
    dg_sub = dg.add_subparsers(dest="action")

    dg_sub.add_parser("random", help="Random dog image")

    dg_breed = dg_sub.add_parser("breed", help="Image of a breed")
    dg_breed.add_argument("name", help="Breed name")

    dg_sub.add_parser("breeds", help="List all breeds")

    # --- JSONPlaceholder ---
    jp = subparsers.add_parser("jsonplaceholder", help="JSONPlaceholder API")
    jp_sub = jp.add_subparsers(dest="action")

    jp_posts = jp_sub.add_parser("posts", help="List posts")
    jp_posts.add_argument("--limit", type=int, default=5,
                          help="Number of posts (default: 5)")

    jp_post = jp_sub.add_parser("post", help="View a post")
    jp_post.add_argument("id", type=int, help="Post ID")

    # --- Countries ---
    ct = subparsers.add_parser("countries", help="REST Countries API")
    ct_sub = ct.add_subparsers(dest="action")

    ct_search = ct_sub.add_parser("search", help="Search a country by name")
    ct_search.add_argument("name", help="Country name")

    ct_code = ct_sub.add_parser("code", help="Country by ISO code")
    ct_code.add_argument("code", help="ISO code (MX, US, AR)")

    return parser


parser = create_parser()

test_commands = [
    ["github", "user", "octocat"],
    ["github", "repos", "octocat", "--limit", "3"],
    ["weather", "city", "London", "--units", "imperial"],
    ["dogs", "random"],
    ["dogs", "breed", "labrador"],
    ["jsonplaceholder", "posts", "--limit", "10"],
    ["jsonplaceholder", "post", "42"],
    ["countries", "search", "mexico"],
    ["countries", "code", "MX"],
]

print("=== Command parsing ===\n")
for cmd in test_commands:
    args = parser.parse_args(cmd)
    cmd_str = " ".join(cmd)
    print(f"  cli.py {cmd_str:<45}{vars(args)}")

Expected output:

=== Command parsing ===

  cli.py github user octocat                        → {'api': 'github', 'action': 'user', 'username': 'octocat'}
  cli.py github repos octocat --limit 3             → {'api': 'github', 'action': 'repos', 'username': 'octocat', 'limit': 3, 'sort': 'updated'}
  cli.py weather city London --units imperial        → {'api': 'weather', 'action': 'city', 'name': 'London', 'units': 'imperial'}
  cli.py dogs random                                 → {'api': 'dogs', 'action': 'random'}
  cli.py dogs breed labrador                         → {'api': 'dogs', 'action': 'breed', 'name': 'labrador'}
  cli.py jsonplaceholder posts --limit 10            → {'api': 'jsonplaceholder', 'action': 'posts', 'limit': 10}
  cli.py jsonplaceholder post 42                     → {'api': 'jsonplaceholder', 'action': 'post', 'id': 42}
  cli.py countries search mexico                     → {'api': 'countries', 'action': 'search', 'name': 'mexico'}
  cli.py countries code MX                           → {'api': 'countries', 'action': 'code', 'code': 'MX'}

Every subcommand produces a namespace with api, action, and its specific arguments. The CLI code only needs an if/elif to dispatch to the right module.


BaseAPIClient: adapting the Module 3 pattern

The APIClient from Module 3 handles generic HTTP. For the CLI, we rename it to BaseAPIClient and each API creates its own class that inherits from it. That lets you add API-specific methods without duplicating the HTTP logic.

The complete base class

import requests
import json
import time


class BaseAPIClient:
    """Base HTTP client with session, error handling, and timeout."""

    def __init__(self, base_url, timeout=10, token=None, api_key_param=None):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.api_key_param = api_key_param
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json",
            "User-Agent": "RESTClientCLI/1.0"
        })
        if token:
            self.session.headers["Authorization"] = f"Bearer {token}"

    def _build_url(self, path):
        if path.startswith("http"):
            return path
        return f"{self.base_url}/{path.lstrip('/')}"

    def get(self, path, params=None, **kwargs):
        """GET request with the API key attached automatically if configured."""
        if self.api_key_param and params is None:
            params = {}
        if self.api_key_param:
            params.update(self.api_key_param)
        return self._request("GET", path, params=params, **kwargs)

    def post(self, path, **kwargs):
        return self._request("POST", path, **kwargs)

    def _request(self, method, path, **kwargs):
        url = self._build_url(path)
        kwargs.setdefault("timeout", self.timeout)

        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.ConnectionError:
            return {"success": False, "status": None, "data": None,
                    "error": f"Could not connect to {self.base_url}",
                    "error_type": "CONNECTION"}
        except requests.exceptions.Timeout:
            return {"success": False, "status": None, "data": None,
                    "error": f"Timed out after {kwargs['timeout']}s",
                    "error_type": "TIMEOUT"}
        except requests.exceptions.RequestException as e:
            return {"success": False, "status": None, "data": None,
                    "error": str(e), "error_type": "NETWORK"}

        result = {
            "success": True,
            "status": response.status_code,
            "data": None,
            "error": None,
            "error_type": None,
            "headers": dict(response.headers),
        }

        if response.status_code == 204:
            return result

        content_type = response.headers.get("Content-Type", "")
        if "application/json" in content_type and response.text:
            try:
                result["data"] = response.json()
            except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
                result["success"] = False
                result["error"] = "Malformed JSON in the response"
                result["error_type"] = "JSON_PARSE"
                return result

        if not response.ok:
            error_msg = f"HTTP {response.status_code} {response.reason}"
            if isinstance(result["data"], dict) and "message" in result["data"]:
                error_msg = f"{error_msg}: {result['data']['message']}"
            result["success"] = False
            result["error"] = error_msg
            result["error_type"] = "HTTP_ERROR"

        return result

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.session.close()
        return False

    def close(self):
        self.session.close()


client = BaseAPIClient("https://jsonplaceholder.typicode.com")

result = client.get("/posts/1")
print(f"Status:  {result['status']}")
print(f"Success: {result['success']}")
print(f"Title:   {result['data']['title'][:40]}")

client.close()

Expected output:

Status:  200
Success: True
Title:   sunt aut facere repellat provident occae

Differences from the Module 3 APIClient

Aspect               │ Module 3 APIClient         │ Module 4 BaseAPIClient
─────────────────────┼────────────────────────────┼──────────────────────────
Name                 │ APIClient                  │ BaseAPIClient
Purpose              │ Direct use                 │ Inheritance — APIs extend
api_key_param        │ Header                     │ Query param (OpenWeather)
Response headers     │ Not included               │ Included in the result
Context manager      │ Basic                      │ Included
Designed for         │ Quick scripts              │ CLI with multiple APIs

Inheritance: one class per API

import requests
import json


class BaseAPIClient:
    def __init__(self, base_url, timeout=10, token=None, api_key_param=None):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.api_key_param = api_key_param
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json",
            "User-Agent": "RESTClientCLI/1.0"
        })
        if token:
            self.session.headers["Authorization"] = f"Bearer {token}"

    def _build_url(self, path):
        if path.startswith("http"):
            return path
        return f"{self.base_url}/{path.lstrip('/')}"

    def get(self, path, params=None, **kwargs):
        if self.api_key_param:
            params = params or {}
            params.update(self.api_key_param)
        return self._request("GET", path, params=params, **kwargs)

    def post(self, path, **kwargs):
        return self._request("POST", path, **kwargs)

    def _request(self, method, path, **kwargs):
        url = self._build_url(path)
        kwargs.setdefault("timeout", self.timeout)
        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "status": None, "data": None,
                    "error": str(e), "error_type": "NETWORK"}
        data = None
        ct = response.headers.get("Content-Type", "")
        if "json" in ct and response.text:
            try:
                data = response.json()
            except Exception:
                return {"success": False, "status": response.status_code,
                        "data": None, "error": "Malformed JSON",
                        "error_type": "JSON_PARSE"}
        if not response.ok:
            msg = f"HTTP {response.status_code}"
            if isinstance(data, dict) and "message" in data:
                msg += f": {data['message']}"
            return {"success": False, "status": response.status_code,
                    "data": data, "error": msg, "error_type": "HTTP_ERROR"}
        return {"success": True, "status": response.status_code,
                "data": data, "error": None, "error_type": None,
                "headers": dict(response.headers)}

    def close(self):
        self.session.close()


class DogCEOClient(BaseAPIClient):
    """Client for the Dog CEO API."""

    def __init__(self):
        super().__init__("https://dog.ceo/api")

    def random_image(self):
        result = self.get("/breeds/image/random")
        if result["success"]:
            return {"success": True, "url": result["data"]["message"]}
        return result

    def breed_image(self, breed):
        result = self.get(f"/breed/{breed}/images/random")
        if result["success"]:
            return {"success": True, "url": result["data"]["message"]}
        return result

    def list_breeds(self):
        result = self.get("/breeds/list/all")
        if result["success"]:
            return {"success": True, "breeds": result["data"]["message"]}
        return result


class JSONPlaceholderClient(BaseAPIClient):
    """Client for the JSONPlaceholder API."""

    def __init__(self):
        super().__init__("https://jsonplaceholder.typicode.com")

    def list_posts(self, limit=5):
        return self.get("/posts", params={"_limit": limit})

    def get_post(self, post_id):
        return self.get(f"/posts/{post_id}")

    def get_post_comments(self, post_id):
        return self.get(f"/posts/{post_id}/comments")


dog = DogCEOClient()
jp = JSONPlaceholderClient()

print("=== Dog CEO ===")
result = dog.random_image()
if result["success"]:
    print(f"  Image: {result['url']}")

result = dog.list_breeds()
if result["success"]:
    breeds = list(result["breeds"].keys())[:5]
    print(f"  First 5 breeds: {', '.join(breeds)}")

print("\n=== JSONPlaceholder ===")
result = jp.list_posts(limit=3)
if result["success"]:
    for post in result["data"]:
        print(f"  #{post['id']} {post['title'][:45]}")

dog.close()
jp.close()

Expected output:

=== Dog CEO ===
  Image: https://images.dog.ceo/breeds/retriever-golden/n02099601_1234.jpg
  First 5 breeds: affenpinscher, african, airedale, akita, appenzeller

=== JSONPlaceholder ===
  #1 sunt aut facere repellat provident occaecati
  #2 qui est esse
  #3 ea molestias quasi exercitationem repellat qu

Each client inherits get(), _request(), error handling, and session management from BaseAPIClient. It only defines the business methods specific to its API.


Configuration: API keys from the environment

API keys don't belong in the code. Always in environment variables or in a .env file.

config.py

import os


def get_config():
    """Reads configuration from environment variables."""
    config = {
        "github_token": os.environ.get("GITHUB_TOKEN"),
        "openweather_key": os.environ.get("OPENWEATHER_API_KEY"),
    }

    return config


def check_config(config, required_keys):
    """Checks that the required keys are configured."""
    missing = []
    for key in required_keys:
        if not config.get(key):
            missing.append(key)

    if missing:
        print("⚠️  Missing configuration:")
        for key in missing:
            env_var = key.upper().replace("_KEY", "_API_KEY")
            print(f"   export {env_var}=your_value_here")
        return False
    return True


config = get_config()

print("=== CLI configuration ===\n")
for key, value in config.items():
    if value:
        masked = value[:4] + "..." + value[-4:] if len(value) > 8 else "****"
        print(f"  ✅ {key}: {masked}")
    else:
        print(f"  ⚠️  {key}: not configured")

print("\n--- Check for Weather ---")
check_config(config, ["openweather_key"])

print("\n--- Check for GitHub (optional) ---")
if config["github_token"]:
    print("  ✅ Token configured — 5000 req/hr")
else:
    print("  ℹ️  No token — 60 req/hr (enough for development)")

Expected output (with no variables configured):

=== CLI configuration ===

  ⚠️  github_token: not configured
  ⚠️  openweather_key: not configured

--- Check for Weather ---
⚠️  Missing configuration:
   export OPENWEATHER_API_KEY=your_value_here

--- Check for GitHub (optional) ---
  ℹ️  No token — 60 req/hr (enough for development)

The .env file (optional)

If you want to use a .env file instead of system environment variables, install python-dotenv:

import os

try:
    from dotenv import load_dotenv
    load_dotenv()
    print("✅ .env loaded with python-dotenv")
except ImportError:
    print("ℹ️  python-dotenv not installed — using system environment variables")

github_token = os.environ.get("GITHUB_TOKEN")
weather_key = os.environ.get("OPENWEATHER_API_KEY")

print(f"\nGITHUB_TOKEN:        {'configured' if github_token else 'not configured'}")
print(f"OPENWEATHER_API_KEY: {'configured' if weather_key else 'not configured'}")

Expected output:

ℹ️  python-dotenv not installed — using system environment variables

GITHUB_TOKEN:        not configured
OPENWEATHER_API_KEY: not configured

Output formatting: how to present the data

The CLI needs to show data in a readable way. Three options:

Option 1: simple print

def format_user_simple(user):
    """Basic format with print."""
    print(f"@{user['login']}")
    print(f"  Name:      {user.get('name', 'N/A')}")
    print(f"  Repos:     {user['public_repos']}")
    print(f"  Followers: {user['followers']}")


user = {
    "login": "octocat", "name": "The Octocat",
    "public_repos": 8, "followers": 12345
}

print("=== Simple format ===\n")
format_user_simple(user)

Expected output:

=== Simple format ===

@octocat
  Name:      The Octocat
  Repos:     8
  Followers: 12345

Option 2: manual table with aligned formatting

def format_table(headers, rows, col_widths=None):
    """Formatted table with alignment."""
    if not col_widths:
        col_widths = []
        for i, h in enumerate(headers):
            max_w = len(h)
            for row in rows:
                max_w = max(max_w, len(str(row[i])))
            col_widths.append(max_w + 2)

    header_str = ""
    sep_str = ""
    for i, h in enumerate(headers):
        header_str += f"{h:<{col_widths[i]}}"
        sep_str += "─" * col_widths[i]
    print(f"  {header_str}")
    print(f"  {sep_str}")

    for row in rows:
        row_str = ""
        for i, val in enumerate(row):
            row_str += f"{str(val):<{col_widths[i]}}"
        print(f"  {row_str}")


repos = [
    ("boysenberry-repo-1", "N/A", 0, "Testing"),
    ("git-consortium", "N/A", 0, "This repo is for demo"),
    ("hello-worId", "N/A", 0, "My first repository"),
]

print("=== Formatted table ===\n")
format_table(
    headers=["Repo", "Lang", "Stars", "Description"],
    rows=repos,
    col_widths=[22, 8, 7, 25]
)

Expected output:

=== Formatted table ===

  Repo                  Lang    Stars  Description              
  ──────────────────────────────────────────────────────────────
  boysenberry-repo-1    N/A     0      Testing                  
  git-consortium        N/A     0      This repo is for demo    
  hello-worId           N/A     0      My first repository      

Option 3: JSON mode (for scripting)

import json


def format_json(data, indent=2):
    """JSON output for piping into other commands."""
    print(json.dumps(data, indent=indent, ensure_ascii=False))


user = {
    "login": "octocat",
    "name": "The Octocat",
    "repos": 8,
    "followers": 12345
}

print("=== JSON format ===\n")
format_json(user)

Expected output:

=== JSON format ===

{
  "login": "octocat",
  "name": "The Octocat",
  "repos": 8,
  "followers": 12345
}

The --json flag in argparse

import argparse
import json


def add_output_options(parser):
    """Adds output options to any subparser."""
    parser.add_argument("--json", action="store_true",
                        help="Output in JSON format")


parser = argparse.ArgumentParser()
add_output_options(parser)

args_normal = parser.parse_args([])
args_json = parser.parse_args(["--json"])

data = {"name": "The Octocat", "repos": 8}

print("=== Without --json ===")
if args_normal.json:
    print(json.dumps(data, indent=2))
else:
    print(f"  Name:  {data['name']}")
    print(f"  Repos: {data['repos']}")

print("\n=== With --json ===")
if args_json.json:
    print(json.dumps(data, indent=2, ensure_ascii=False))
else:
    print(f"  Name:  {data['name']}")
    print(f"  Repos: {data['repos']}")

Expected output:

=== Without --json ===
  Name:  The Octocat
  Repos: 8

=== With --json ===
{
  "name": "The Octocat",
  "repos": 8
}

The complete CLI skeleton

Putting it all together — this is the working cli.py that the next capsules will fill with real integrations:

import argparse
import sys
import os
import json
import requests


class BaseAPIClient:
    def __init__(self, base_url, timeout=10, token=None, api_key_param=None):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.api_key_param = api_key_param
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json",
            "User-Agent": "RESTClientCLI/1.0"
        })
        if token:
            self.session.headers["Authorization"] = f"Bearer {token}"

    def _build_url(self, path):
        if path.startswith("http"):
            return path
        return f"{self.base_url}/{path.lstrip('/')}"

    def get(self, path, params=None, **kwargs):
        if self.api_key_param:
            params = params or {}
            params.update(self.api_key_param)
        return self._request("GET", path, params=params, **kwargs)

    def post(self, path, **kwargs):
        return self._request("POST", path, **kwargs)

    def _request(self, method, path, **kwargs):
        url = self._build_url(path)
        kwargs.setdefault("timeout", self.timeout)
        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.ConnectionError:
            return {"success": False, "status": None, "data": None,
                    "error": f"Could not connect to {self.base_url}",
                    "error_type": "CONNECTION"}
        except requests.exceptions.Timeout:
            return {"success": False, "status": None, "data": None,
                    "error": f"Timed out after {kwargs['timeout']}s",
                    "error_type": "TIMEOUT"}
        except requests.exceptions.RequestException as e:
            return {"success": False, "status": None, "data": None,
                    "error": str(e), "error_type": "NETWORK"}
        data = None
        ct = response.headers.get("Content-Type", "")
        if "json" in ct and response.text:
            try:
                data = response.json()
            except Exception:
                return {"success": False, "status": response.status_code,
                        "data": None, "error": "Malformed JSON",
                        "error_type": "JSON_PARSE"}
        if not response.ok:
            msg = f"HTTP {response.status_code} {response.reason}"
            if isinstance(data, dict) and "message" in data:
                msg += f": {data['message']}"
            return {"success": False, "status": response.status_code,
                    "data": data, "error": msg, "error_type": "HTTP_ERROR"}
        return {"success": True, "status": response.status_code,
                "data": data, "error": None, "error_type": None,
                "headers": dict(response.headers)}

    def close(self):
        self.session.close()


def handle_error(result):
    """Shows the error and exits with code 1."""
    print(f"\n❌ Error: {result['error']}")
    if result.get("error_type") == "CONNECTION":
        print("   Check your internet connection")
    elif result.get("error_type") == "TIMEOUT":
        print("   The API took too long to respond")
    elif result.get("status") == 401:
        print("   Check your API key or token")
    elif result.get("status") == 404:
        print("   The resource does not exist")
    elif result.get("status") == 429:
        print("   Rate limit reached — wait a few minutes")
    sys.exit(1)


def cmd_placeholder(args):
    """Placeholder for APIs not implemented yet."""
    print(f"🚧 API '{args.api}' action '{args.action}' — coming soon")
    print(f"   Arguments: {vars(args)}")


def create_parser():
    parser = argparse.ArgumentParser(
        prog="cli.py",
        description="REST Client CLI — Consume 5+ public APIs"
    )

    subparsers = parser.add_subparsers(dest="api", help="API to query")

    gh = subparsers.add_parser("github", help="GitHub API")
    gh_sub = gh.add_subparsers(dest="action")
    gh_user = gh_sub.add_parser("user", help="Info about a user")
    gh_user.add_argument("username")
    gh_repos = gh_sub.add_parser("repos", help="A user's repos")
    gh_repos.add_argument("username")
    gh_repos.add_argument("--limit", type=int, default=5)

    dg = subparsers.add_parser("dogs", help="Dog CEO API")
    dg_sub = dg.add_subparsers(dest="action")
    dg_sub.add_parser("random", help="Random image")
    dg_breed = dg_sub.add_parser("breed", help="Image of a breed")
    dg_breed.add_argument("name")
    dg_sub.add_parser("breeds", help="List breeds")

    jp = subparsers.add_parser("jsonplaceholder", help="JSONPlaceholder")
    jp_sub = jp.add_subparsers(dest="action")
    jp_posts = jp_sub.add_parser("posts", help="List posts")
    jp_posts.add_argument("--limit", type=int, default=5)
    jp_post = jp_sub.add_parser("post", help="View a post")
    jp_post.add_argument("id", type=int)

    wt = subparsers.add_parser("weather", help="OpenWeather API")
    wt_sub = wt.add_subparsers(dest="action")
    wt_city = wt_sub.add_parser("city", help="Weather for a city")
    wt_city.add_argument("name")

    ct = subparsers.add_parser("countries", help="REST Countries API")
    ct_sub = ct.add_subparsers(dest="action")
    ct_search = ct_sub.add_parser("search", help="Search by name")
    ct_search.add_argument("name")
    ct_code = ct_sub.add_parser("code", help="By ISO code")
    ct_code.add_argument("code")

    return parser


DISPATCH = {
    "github":           cmd_placeholder,
    "dogs":             cmd_placeholder,
    "jsonplaceholder":  cmd_placeholder,
    "weather":          cmd_placeholder,
    "countries":        cmd_placeholder,
}


def main(argv=None):
    parser = create_parser()
    args = parser.parse_args(argv)

    if not args.api:
        parser.print_help()
        return

    if not args.action:
        print(f"Usage: cli.py {args.api} <action>")
        print(f"Run: cli.py {args.api} --help")
        return

    handler = DISPATCH.get(args.api, cmd_placeholder)
    handler(args)


print("=== CLI Skeleton Demo ===\n")
main(["github", "user", "octocat"])
print()
main(["dogs", "random"])
print()
main(["jsonplaceholder", "posts", "--limit", "3"])

Expected output:

=== CLI Skeleton Demo ===

🚧 API 'github' action 'user' — coming soon
   Arguments: {'api': 'github', 'action': 'user', 'username': 'octocat'}

🚧 API 'dogs' action 'random' — coming soon
   Arguments: {'api': 'dogs', 'action': 'random'}

🚧 API 'jsonplaceholder' action 'posts' — coming soon
   Arguments: {'api': 'jsonplaceholder', 'action': 'posts', 'limit': 3}

The skeleton works. In the next capsules, you replace cmd_placeholder with real functions that use the DogCEOClient, JSONPlaceholderClient, etc. classes.


Comparison: argparse vs click

Aspect               │ argparse                       │ click
─────────────────────┼────────────────────────────────┼────────────────────────────
Installation         │ Built-in (stdlib)              │ pip install click
Subcommands          │ add_subparsers() — verbose     │ @click.group() — decorators
Type validation      │ type=int in add_argument()     │ Built-in types + custom
Colors in output     │ Manual (or with colorama)      │ built-in click.style()
Interactive prompts  │ manual input()                 │ built-in click.prompt()
Auto-completion      │ With argcomplete (extra)       │ With click-completion (extra)
Learning curve       │ More verbose but explicit      │ More concise but magical
Dependencies         │ Zero                           │ One (click)

For this project we use argparse because:

  • ✅ It adds no dependencies — your CLI runs with nothing but Python installed
  • ✅ It's the industry standard for Python CLIs
  • ✅ For 5 subcommands with simple options, it's enough

click is better when the CLI grows to 20+ commands, you need interactive prompts, or you want colors with no effort. For this project, argparse is the right choice.


Connection to the project

This capsule defined the CLI's full architecture. The next capsules fill in the blanks:

Capsule 03 (this)    →  Structure, argparse, BaseAPIClient, config
Capsule 04           →  apis/jsonplaceholder.py + apis/dogs.py (no auth)
Capsule 05           →  apis/github_api.py (token, pagination)
Capsule 06           →  apis/weather.py + apis/countries.py (API key)
Capsule 07           →  Robust error handling + formatters.py
Capsule 08           →  Full integration + README + testing

Each capsule "plugs" new modules into the skeleton you built here.


Troubleshooting

Problem 1: argparse doesn't recognize nested subcommands

Cause: You didn't use add_subparsers() on the API's subparser, or the dest is repeated.

Solution: Every subcommand level needs its own add_subparsers() with a unique dest:

# Level 1: API
subparsers = parser.add_subparsers(dest="api")

# Level 2: action within the API
gh = subparsers.add_parser("github")
gh_sub = gh.add_subparsers(dest="action")  # different dest

Problem 2: args.action is None even though I passed a subcommand

Cause: add_subparsers() without a dest doesn't store the chosen subcommand in the namespace.

Solution: Always specify dest:

# ❌ Doesn't store which action the user chose
gh.add_subparsers()

# ✅ args.action will hold the subcommand's value
gh.add_subparsers(dest="action")

Problem 3: ImportError when importing from apis/

Cause: The __init__.py file is missing from the apis/ folder, or you're running from a different directory.

Solution:

# Make sure apis/__init__.py exists (it can be empty)
# Always run from the project root:
# python cli.py github user octocat     ← correct
# cd apis && python github_api.py       ← incorrect

Problem 4: Environment variables aren't being read

Cause: You set the variable in another terminal, or you used .env without python-dotenv.

Solution:

# Set them in the same terminal where you run the CLI:
export GITHUB_TOKEN=ghp_your_token
export OPENWEATHER_API_KEY=your_key
python cli.py weather city London

# Or with python-dotenv, create a .env file:
# GITHUB_TOKEN=ghp_your_token
# OPENWEATHER_API_KEY=your_key

Exercises

Exercise 1: Parser with validation (Easy)

Create an argparse parser with a search subcommand that takes a required search term and an optional --limit (default 10, minimum 1, maximum 100). If the limit is out of range, show an error.

See solution
import argparse


def validate_limit(value):
    """Validates that limit is between 1 and 100."""
    ivalue = int(value)
    if ivalue < 1 or ivalue > 100:
        raise argparse.ArgumentTypeError(
            f"Limit must be between 1 and 100, got: {ivalue}"
        )
    return ivalue


parser = argparse.ArgumentParser(prog="search-cli")
sub = parser.add_subparsers(dest="command")

search = sub.add_parser("search", help="Search")
search.add_argument("term", help="Search term")
search.add_argument("--limit", type=validate_limit, default=10,
                    help="Results (1-100, default: 10)")

test_cases = [
    ["search", "python"],
    ["search", "python", "--limit", "50"],
    ["search", "python", "--limit", "5"],
]

for cmd in test_cases:
    args = parser.parse_args(cmd)
    print(f"  search '{args.term}' limit={args.limit}")

print("\n--- Range validation ---")
try:
    parser.parse_args(["search", "test", "--limit", "200"])
except SystemExit:
    print("  ❌ --limit 200 rejected (maximum 100)")

Explanation: validate_limit() is used as type= in argparse. If the value fails validation, argparse shows the error and exits automatically. That centralizes validation in the parser, not in the business logic.

Exercise 2: Complete dispatch table (Medium)

Create a dispatch table that maps (api, action) combinations to handler functions. Each handler takes args and prints what it would do. Include a default handler for combinations that aren't implemented.

See solution
import argparse


def github_user(args):
    print(f"  → Fetching profile of @{args.username}")

def github_repos(args):
    print(f"  → Listing {args.limit} repos of @{args.username}")

def dogs_random(args):
    print(f"  → Fetching a random dog image")

def dogs_breed(args):
    print(f"  → Fetching an image of breed '{args.name}'")

def not_implemented(args):
    print(f"  🚧 {args.api}/{args.action} not implemented yet")


DISPATCH = {
    ("github", "user"):    github_user,
    ("github", "repos"):   github_repos,
    ("dogs", "random"):    dogs_random,
    ("dogs", "breed"):     dogs_breed,
}


def dispatch(args):
    key = (args.api, args.action)
    handler = DISPATCH.get(key, not_implemented)
    handler(args)


parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="api")

gh = sub.add_parser("github")
gh_sub = gh.add_subparsers(dest="action")
gh_u = gh_sub.add_parser("user")
gh_u.add_argument("username")
gh_r = gh_sub.add_parser("repos")
gh_r.add_argument("username")
gh_r.add_argument("--limit", type=int, default=5)

dg = sub.add_parser("dogs")
dg_sub = dg.add_subparsers(dest="action")
dg_sub.add_parser("random")
dg_b = dg_sub.add_parser("breed")
dg_b.add_argument("name")

wt = sub.add_parser("weather")
wt_sub = wt.add_subparsers(dest="action")
wt_c = wt_sub.add_parser("city")
wt_c.add_argument("name")

commands = [
    ["github", "user", "octocat"],
    ["github", "repos", "octocat", "--limit", "3"],
    ["dogs", "random"],
    ["dogs", "breed", "labrador"],
    ["weather", "city", "London"],
]

print("=== Dispatch Table ===\n")
for cmd in commands:
    args = parser.parse_args(cmd)
    cmd_str = " ".join(cmd)
    print(f"cli.py {cmd_str}")
    dispatch(args)
    print()

Explanation: The dispatch table uses (api, action) tuples as keys. That's cleaner than a nested if/elif and makes it easy to add new handlers. The default handler deals with unimplemented combinations without crashing.

Exercise 3: BaseAPIClient with retry (Medium)

Extend BaseAPIClient so it retries failed requests up to N times with a delay between attempts. If every retry fails, return the last error. Test it with a URL that doesn't exist.

See solution
import requests
import time


class BaseAPIClient:
    def __init__(self, base_url, timeout=10, retries=3, retry_delay=1.0):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.retries = retries
        self.retry_delay = retry_delay
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json"})

    def _request(self, method, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)

        last_error = None
        for attempt in range(1, self.retries + 1):
            try:
                response = self.session.request(method, url, **kwargs)
            except requests.exceptions.RequestException as e:
                last_error = {"success": False, "status": None,
                              "data": None, "error": str(e),
                              "error_type": "NETWORK", "attempts": attempt}
                if attempt < self.retries:
                    print(f"  ⚠️  Attempt {attempt}/{self.retries} failed, "
                          f"retrying in {self.retry_delay}s...")
                    time.sleep(self.retry_delay)
                continue

            data = None
            ct = response.headers.get("Content-Type", "")
            if "json" in ct and response.text:
                try:
                    data = response.json()
                except Exception:
                    pass

            if response.status_code >= 500 and attempt < self.retries:
                print(f"  ⚠️  Server error {response.status_code}, "
                      f"retrying in {self.retry_delay}s...")
                time.sleep(self.retry_delay)
                continue

            if not response.ok:
                return {"success": False, "status": response.status_code,
                        "data": data, "error": f"HTTP {response.status_code}",
                        "error_type": "HTTP_ERROR", "attempts": attempt}

            return {"success": True, "status": response.status_code,
                    "data": data, "error": None, "error_type": None,
                    "attempts": attempt}

        return last_error

    def get(self, path, **kw):
        return self._request("GET", path, **kw)

    def close(self):
        self.session.close()


print("=== Test with a valid API ===")
client = BaseAPIClient("https://jsonplaceholder.typicode.com",
                        retries=3, retry_delay=0.5)
result = client.get("/posts/1")
print(f"  Result: {'✅' if result['success'] else '❌'} "
      f"(attempts: {result['attempts']})")
client.close()

print("\n=== Test with an invalid host ===")
client = BaseAPIClient("https://does-not-exist-xyz.invalid",
                        timeout=2, retries=2, retry_delay=0.5)
result = client.get("/test")
print(f"  Result: {'✅' if result['success'] else '❌'} "
      f"(attempts: {result['attempts']})")
print(f"  Error: {result['error'][:50]}")
client.close()

Explanation: The retry loop tries up to N times. It retries on connection errors and on server errors (5xx), but not on client errors (4xx) because those don't get fixed by retrying. The attempts field in the result tells you how many tries it took.

Exercise 4: Config with validation and defaults (Hard)

Write a complete config.py module that: reads from environment variables, has default values, validates types (numbers must be ints), and prints a summary of the configuration. Include: timeout, retries, github_token, openweather_key, output_format (simple/json).

See solution
import os


class Config:
    """CLI configuration with defaults and validation."""

    DEFAULTS = {
        "timeout": 10,
        "retries": 3,
        "output_format": "simple",
        "github_token": None,
        "openweather_key": None,
    }

    ENV_MAP = {
        "timeout": "CLI_TIMEOUT",
        "retries": "CLI_RETRIES",
        "output_format": "CLI_OUTPUT_FORMAT",
        "github_token": "GITHUB_TOKEN",
        "openweather_key": "OPENWEATHER_API_KEY",
    }

    VALID_FORMATS = ("simple", "json")

    def __init__(self):
        self._config = {}
        self._load()

    def _load(self):
        for key, default in self.DEFAULTS.items():
            env_var = self.ENV_MAP[key]
            value = os.environ.get(env_var)

            if value is None:
                self._config[key] = default
            elif isinstance(default, int):
                try:
                    self._config[key] = int(value)
                except ValueError:
                    print(f"  ⚠️  {env_var}='{value}' is not a number, "
                          f"using default: {default}")
                    self._config[key] = default
            else:
                self._config[key] = value

        if self._config["output_format"] not in self.VALID_FORMATS:
            print(f"  ⚠️  Format '{self._config['output_format']}' "
                  f"is not valid, using 'simple'")
            self._config["output_format"] = "simple"

    def get(self, key):
        return self._config.get(key)

    def has_github_token(self):
        return bool(self._config.get("github_token"))

    def has_weather_key(self):
        return bool(self._config.get("openweather_key"))

    def summary(self):
        print(f"\n{'=' * 45}")
        print(f"  REST Client CLI — Configuration")
        print(f"{'=' * 45}")
        for key, value in self._config.items():
            env_var = self.ENV_MAP[key]
            source = "env" if os.environ.get(env_var) else "default"

            if value and key in ("github_token", "openweather_key"):
                display = value[:4] + "..." if len(str(value)) > 4 else "****"
            elif value is None:
                display = "not configured"
            else:
                display = str(value)

            print(f"  {key:<18} = {display:<20} ({source})")
        print(f"{'=' * 45}")


config = Config()
config.summary()

print(f"\nTimeout:    {config.get('timeout')}")
print(f"GitHub:     {'✅ token' if config.has_github_token() else '❌ no token'}")
print(f"Weather:    {'✅ key' if config.has_weather_key() else '❌ no key'}")

Explanation: The Config class centralizes all configuration reading. Every setting has a default, gets read from its corresponding environment variable, and gets validated (numeric types, valid formats). summary() shows where each value comes from (env vs default) and masks secrets.

Exercise 5: Mini working CLI (Hard)

Write a minimal working CLI with a single test subcommand that makes a GET to a URL passed as an argument, shows the status, the main headers, and a preview of the body (first 200 characters). Include --timeout and --json as options.

See solution
import argparse
import requests
import json
import sys


def cmd_test(args):
    """Makes a GET to a URL and shows the response."""
    try:
        response = requests.get(args.url, timeout=args.timeout)
    except requests.exceptions.ConnectionError:
        print(f"❌ Could not connect to: {args.url}")
        sys.exit(1)
    except requests.exceptions.Timeout:
        print(f"❌ Timed out after {args.timeout}s")
        sys.exit(1)

    result = {
        "url": args.url,
        "status": response.status_code,
        "content_type": response.headers.get("Content-Type", "N/A"),
        "size_bytes": len(response.content),
        "body_preview": response.text[:200],
    }

    if args.json:
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        print(f"\n  URL:          {result['url']}")
        print(f"  Status:       {result['status']}")
        print(f"  Content-Type: {result['content_type']}")
        print(f"  Size:         {result['size_bytes']:,} bytes")
        print(f"\n  Preview:")
        print(f"  {result['body_preview'][:100]}")
        if len(result['body_preview']) > 100:
            print(f"  ...")


parser = argparse.ArgumentParser(prog="mini-cli")
sub = parser.add_subparsers(dest="command")

test = sub.add_parser("test", help="GET a URL")
test.add_argument("url", help="URL to query")
test.add_argument("--timeout", type=int, default=10)
test.add_argument("--json", action="store_true")

print("=== Mini CLI: simple test ===")
args = parser.parse_args(["test", "https://api.github.com/users/octocat"])
cmd_test(args)

print("\n=== Mini CLI: test with --json ===")
args = parser.parse_args(["test", "https://dog.ceo/api/breeds/image/random",
                          "--json"])
cmd_test(args)

Explanation: This mini CLI shows the full flow: argparse parses, the handler makes the request, and the output changes depending on --json. That same pattern scales to 5 APIs with 20 subcommands — you just add more parsers and handlers.


Summary

  • Project structure: Files split by responsibility — cli.py, base_client.py, config.py, apis/*.py
  • argparse with subparsers: Two levels of subcommands — cli.py <api> <action> — for a clear, extensible interface
  • BaseAPIClient: The Module 3 APIClient adapted for inheritance — each API creates its child class
  • Configuration: API keys from environment variables, never hardcoded in the code
  • Output formatting: Three options (simple print, table, JSON) with a --json flag for scripting
  • Dispatch table: A dict that maps (api, action) to handler functions — cleaner than if/elif
  • argparse over click: For this project, argparse is enough and adds no dependencies

Additional resources

  1. Python argparse Tutorial — Official tutorial with subcommands and options
  2. argparse: Sub-commands — Reference for add_subparsers()
  3. Python os.environ — Handling environment variables
  4. python-dotenv — Reading .env files
  5. Click Documentation — An alternative to argparse for complex CLIs
  6. 12-Factor App: Config — Principles for configuration from the environment

Next capsule: Integrating JSONPlaceholder and Dog CEO — the first 2 APIs (no auth) plugged into the CLI with complete, working code.