Reading environment variables, talking to APIs, parsing JSON, reading CLI arguments, and working with timestamps — the toolbox every IT/SecOps script actually reaches for.
Environment variables are settings passed to your script from outside — API tokens, config paths — instead of hardcoded into the file. os.environ.get() reads one safely, returning a default instead of crashing if it's missing.
import os
token = os.environ.get("OKTA_TOKEN", "not set")
print(token)
Run the example above. What prints (you haven't set this variable)?
not setTry os.environ["OKTA_TOKEN"] (direct access, no default) on a variable you haven't set. What happens?
KeyError: 'OKTA_TOKEN'Read the PATH variable (which does exist on your system) with os.environ.get("PATH"). Print just its type.
<class 'str'>Why is .get() with a default almost always better than direct [] access for env vars? One sentence.
Write a function get_token() that reads MY_API_TOKEN safely, returning None if unset. Call it, print the result.
NoneOpen problems/01_env_vars.py and do all 5 problems locally.
A request is your script asking a server for something over the network. A response is what the server sends back — a status code (did it work?) plus a body (usually JSON) with the actual data.
This sub-skill cluster (2–5) is syntax and concept only — no local graded problems, since a real network call can't be reliably tested offline. Read the syntax, run the labs if you have real credentials to test with, and recognize the shape when you see it in an interview or on the job.
In one sentence: what's the difference between a request and a response?
Name one piece of information a response always includes, regardless of what API you're calling.
Why does almost every modern API return JSON instead of plain text?
What do you think happens if the network is down when you send a request? (predict, don't run)
Name one real IT/SecOps task that requires talking to an API (Okta, Slack, or Google).
requests.get() / post() SyntaxThe requests library sends HTTP requests. get() asks for data. post() sends data. Both return a Response object.
import requests
response = requests.get(
"https://example.okta.com/api/v1/users",
headers={"Authorization": f"SSWS {token}"}
)
response = requests.post(
"https://hooks.slack.com/services/...",
json={"text": "Alert: something happened"}
)
In the get() example, what argument carries the auth token?
headersIn the post() example, what argument carries the message body?
jsonWhich verb would you use to fetch a list of users — get or post?
getWhich verb would you use to send a Slack alert message — get or post?
postWrite the syntax (don't run it) for a get() call to "https://api.example.com/status" with no headers.
requests.get("https://api.example.com/status")status_codeEvery response has a status_code — a number telling you if the request worked. 200 means success. 4xx means your request was wrong (bad auth, bad URL). 5xx means the server itself failed.
if response.status_code == 200:
print("success")
elif response.status_code == 401:
print("bad auth token")
elif response.status_code == 404:
print("not found")
else:
print(f"unexpected: {response.status_code}")
What does status code 200 mean?
What does status code 401 usually mean?
What does status code 404 mean?
Is a 500 error your script's fault, or the server's fault?
Write the if/elif/else branching above from memory (don't look back).
.json() and a Full Fetch Functionresponse.json() converts the response body into a normal Python list/dict — the same shapes you've used all course. Combine everything: request, status check, and JSON conversion, into one function.
def fetch_users(base_url, token):
response = requests.get(
f"{base_url}/api/v1/users",
headers={"Authorization": f"SSWS {token}"}
)
if response.status_code != 200:
raise Exception(f"fetch failed: {response.status_code}")
return response.json()
What Python type does .json() usually return for a "list users" endpoint?
list (of dicts)Why does fetch_users check status_code BEFORE calling .json()?
What does fetch_users do instead of returning when the status code is bad?
Sketch (in words) how you'd call this in a chain: fetch_users → what would the next function do with the returned list?
Write fetch_users exactly as shown from memory, plus a fake call_it() that would call it with a real base_url and token (don't actually run the network call).
json.loads()APIs send JSON as raw text. json.loads() converts that text into real Python lists/dicts you can work with. (Loading JSON from a file uses json.load() instead — you'll meet that in Module 10.)
import json
raw = '{"id": "00u001", "status": "ACTIVE"}'
user = json.loads(raw)
print(user["status"])
print(type(user))
Run the example above. Confirm both printed lines.
ACTIVE then <class 'dict'>Parse '[1, 2, 3]' with json.loads(). Print its type.
<class 'list'>Parse '{"groups": ["IT", "VPN"]}', then print just the second group name.
VPNTry json.loads('not valid json'). What happens?
json.JSONDecodeErrorParse '{"active": true, "count": null}'. Print both values and their Python types.
True <class 'bool'> then None <class 'NoneType'>Open problems/06_json_loads.py and do all 5 problems locally.
json.dumps()The reverse of loads() — json.dumps() turns a Python list/dict back into JSON text, for sending in a request body or writing to a file.
import json
alert = {"text": "Something happened", "level": "warning"}
text = json.dumps(alert)
print(text)
print(type(text))
Run the example above. Confirm both printed lines.
{"text": "Something happened", "level": "warning"} then <class 'str'>json.dumps([1, 2, 3]) — print the result and its type.
[1, 2, 3] then <class 'str'>Round-trip: take a dict, dumps() it, then loads() the result back. Confirm it equals the original with ==.
TrueTry json.dumps on a dict containing a datetime object (not a plain value). What happens?
TypeError: Object of type datetime is not JSON serializableAdd indent=2 to json.dumps() on a small dict. What changes about the output?
Open problems/07_json_dumps.py and do all 5 problems locally.
sys.argvsys.argv is a list of the words typed on the command line when your script ran. sys.argv[0] is always the script's own filename; the arguments start at index 1.
# saved as report.py, run as: python report.py IT 2026-07-01
import sys
print(sys.argv)
print(sys.argv[1])
Save the example as a file and run it with an argument. What does sys.argv[0] hold?
Run your script with two arguments. What's len(sys.argv)?
3 (filename + 2 args)Run your script with ZERO arguments. Try to access sys.argv[1]. What happens?
IndexErrorWhy is it risky to access sys.argv[1] without checking len(sys.argv) first?
Write a guarded version that prints sys.argv[1] if present, else prints "no argument given".
Open problems/08_sys_argv.py and do all 5 problems locally.
argparseargparse is a library that turns raw sys.argv words into named, typed, validated arguments — with free help text and error messages, instead of you hand-checking positions.
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--department", help="filter by department")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
print(args.department)
print(args.verbose)
sys.argv | argparse | |
|---|---|---|
| Access | by position ([1], [2]) | by name (args.department) |
| Missing arg | IndexError, you guard it yourself | built-in default / required check |
| Bad usage | no help text | auto-generated --help |
Save the example, run it with --department IT --verbose. What prints?
IT then TrueRun it with NO arguments. Does it crash?
None then False, since neither is requiredAdd required=True to the --department argument. Run with no arguments -- what happens?
Run your script with --help. What do you see?
In one sentence: what's the main advantage of argparse over hand-parsing sys.argv?
Open problems/09_argparse.py and do all 4 problems locally.
Combine env vars, JSON, and argparse into one small tool — the shape of a real script you'd run from a terminal.
import os, json, argparse
def build_report(department, users):
matches = [u for u in users if u["profile"].get("department") == department]
return json.dumps({"department": department, "count": len(matches)})
parser = argparse.ArgumentParser()
parser.add_argument("--department", required=True)
args = parser.parse_args()
token = os.environ.get("MY_API_TOKEN", "no-token")
print(build_report(args.department, USERS))
Write build_report exactly as shown. Call it directly (not through argparse) with "Engineering" and the real USERS fixture. Print the result.
{"department": "Engineering", "count": 3}Call build_report with a department that doesn't exist, like "Marketing". What prints?
{"department": "Marketing", "count": 0}Parse the JSON string from Lab 1 back into a dict with json.loads(). Print just the count.
3What does os.environ.get("MY_API_TOKEN", "no-token") print if you haven't set that variable?
no-tokenWrite the full script (env var read + build_report + a fake argparse-style call using a hardcoded department instead of real CLI input) and run it end-to-end.
Open problems/10_mini_cli_tool.py — 5 problems, #5 is a checkpoint combining sub-skills 1, 6-9.
datetime.fromisoformat()APIs send timestamps as text, like "2026-07-24T09:14:00Z". datetime.fromisoformat() parses that text into a real datetime object you can compare and do math with.
from datetime import datetime
text = "2026-07-24T09:14:00Z"
when = datetime.fromisoformat(text)
print(when)
print(type(when))
print(when.year)
Run the example above. Confirm all three printed lines.
2026-07-24 09:14:00+00:00, <class 'datetime.datetime'>, 2026Parse USERS[0]["lastLogin"] (Julio). Print just .month and .day.
7 then 24Parse two different users' lastLogin values and compare them with <. Which one is earlier?
Try parsing USERS[3]["lastLogin"] (Dana Morris) directly. What happens?
TypeError, it's None, not a stringWrite a loop that parses every user's lastLogin EXCEPT Dana's (skip None with an if), printing each parsed year.
2025 or 2026Open problems/11_fromisoformat.py and do all 5 problems locally.
timedelta and datetime.now()Subtracting two datetime objects gives a timedelta — a span of time. datetime.now() gives you the current moment to compare against.
from datetime import datetime
then = datetime.fromisoformat("2026-06-01T00:00:00")
now = datetime.fromisoformat("2026-07-26T00:00:00")
span = now - then
print(span)
print(type(span))
print(span.days)
Run the example above. Confirm all three printed lines.
55 days, 0:00:00, <class 'datetime.timedelta'>, 55Print type(datetime.now()).
<class 'datetime.datetime'>Compute the span between Julio's lastLogin and Tri Nguyen's lastLogin (both parsed). Print .days.
Subtract a LATER date minus an EARLIER date, then the reverse (earlier minus later). What's different about the sign?
.daysWrite a function days_since(dt) that returns (reference_now - dt).days, using a fixed reference datetime you define, not the real datetime.now() (so it's reproducible). Test it on Julio's parsed lastLogin.
Open problems/12_timedelta.py and do all 5 problems locally.
None TimestampsReal records have missing timestamps — a user who's never logged in. Guard before parsing, the same way you've guarded missing dict keys all course.
def parse_login(user):
raw = user.get("lastLogin")
if raw is None:
return None
return datetime.fromisoformat(raw)
Write parse_login exactly as shown. Call it on USERS[3] (Dana). Print the result.
NoneCall it on USERS[0] (Julio). Print the result and its type.
datetime, type <class 'datetime.datetime'>Loop all 12 USERS, calling parse_login on each -- confirm no crash, and exactly one None.
None, 11 real datetimesWrite a function that prints "never logged in" for a None result, or the date otherwise. Run it over all 12 users.
Why is guarding here the same shape as guarding .get("department", "Unknown") from Module 8? One sentence.
Open problems/13_guard_none_timestamps.py and do all 5 problems locally.
Combine parsing, guarding, and comparison into one report over the real fixture.
Using a fixed reference datetime of 2026-07-26, compute days-since-login for every user with a real lastLogin (skip Dana). Print each as "name: N days".
Count how many users logged in more than 90 days ago.
4 (Bina, Tri, Rosa, Pat)Find the single most-recently-logged-in user (smallest days-since). Print their name.
Amy or Sam (both 0 days -- print whichever your loop finds first)Build a JSON string (with json.dumps) summarizing: total users, users never logged in, users inactive 90+ days.
{"total": 12, "never_logged_in": 1, "inactive_90_plus": 4}Open problems/14_timestamp_report.py and do all 4 problems locally.
requestsOne script combining env vars, JSON, datetime, and argparse-style argument handling (hardcoded stand-in for real CLI input) over the full fixture.
Read a (probably-unset) env var for a report title, defaulting to "User Report".
User ReportBuild the inactivity summary dict from sub-skill 14's Lab 4.
Convert it to a JSON string with json.dumps(..., indent=2). Print it.
Wrap the whole thing in a function build_report(title) and call it with your env-var title from Lab 1.
One script: env var title, guarded timestamp parsing over all 12 users, the inactivity summary, and a pretty-printed JSON report -- zero crashes.
Open problems/15_final_checkpoint.py — 5 problems, #5 is the final checkpoint for all of Module 9.
Run python check.py in problems/ to grade everything.