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
APIClientadapted 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
--jsonflag for scripting - Dispatch table: A dict that maps
(api, action)to handler functions — cleaner thanif/elif - argparse over click: For this project, argparse is enough and adds no dependencies
Additional resources
- Python argparse Tutorial — Official tutorial with subcommands and options
- argparse: Sub-commands — Reference for
add_subparsers() - Python os.environ — Handling environment variables
- python-dotenv — Reading
.envfiles - Click Documentation — An alternative to argparse for complex CLIs
- 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.