Pragmatic Python Bootcamp — From Zero to Automation
Build real Python tools, the right way
Welcome to the Pragmatic Python Bootcamp
From zero to automation
Whether you're writing your very first line of code or you're a seasoned C# / Java developer tired of boilerplate and curious about Python's clean, modern syntax — you're in the right place.
Most Python tutorials either treat you like a toddler or assume you already have a PhD in computer science. This course is neither. We skip the theoretical fluff and build real, modern tools — from interactive terminal scripts to an AI-powered command-line app — and we teach not just how to write Python, but how to write it well and efficiently.
How to use this course
The course is written for two kinds of reader:
- The absolute beginner. Go top to bottom. Each lesson builds on the last, and we walk you through setup and logic step by step.
- The transitioning developer. Watch for the 🛑 Dev Callout boxes. They're the TL;DR on how Python differs from strongly-typed or compiled languages — typing, scope, memory, tooling — so you can skim the basics and get straight to building.
Every lesson ends with a short quiz. Score at least 67% to unlock the next one.
What you'll build
You'll finish each chapter with something that runs: a tip calculator, a number-guessing game, an inventory system, a log analyzer, a server-fleet monitor, a live GitHub inspector, and finally a typed, tested, AI-powered CLI tool.
Grab a coffee, open your terminal, and let's get started.
Setup & the Python Mental Model
Why Python
Python treats developer time as more expensive than machine time. You stop fighting syntax and start solving the problem. It runs everywhere, ships with a huge standard library ("batteries included"), and has the largest ecosystem of third-party packages of any language — web frameworks, data tools, machine learning, automation.
This course takes you from your first line of code to a real, typed, tested, AI-powered command-line tool. We won't print "Hello World" and call it a day — in the very first chapter you'll build a working calculator that takes input and does math.
💡 Tip: You learn programming by typing code, not reading about it. Keep a terminal open and run every snippet. Break them on purpose and see what happens.
Three things to internalize
🛑 Dev Callout: The Python Mental Model
Coming from C#, .NET, or Java? Adding Python to your toolbelt feels like a cheat code, but the architecture differs in three ways worth internalizing up front:
- No compilation step. Python is interpreted line by line. You write, you run. There's no build.
- Dynamically typed. You don't declare
string nameorint age. Variables get their type at runtime, from whatever you assign.- Whitespace matters. Python uses indentation instead of
{}to define code blocks. Badly-indented Python literally won't run — which forces every codebase you read to be visually consistent.
Hold those three ideas and most of Python's "surprises" stop being surprising.
The 60-second setup
Step 1 — The interpreter.
- Linux (Arch / EndeavourOS):
sudo pacman -S python - Linux (Debian / Ubuntu):
sudo apt install python3 python3-pip - macOS (Homebrew):
brew install python - Windows: download the installer from python.org. Make absolutely sure you tick "Add Python to PATH" on the first screen — skipping that box is the single most common reason a Windows install "doesn't work."
Step 2 — The editor.
- Visual Studio Code — lightweight, fast, terminal-centric. Install the official "Python" extension by Microsoft.
- JetBrains PyCharm — if you live in the JetBrains ecosystem (Rider, IntelliJ), PyCharm will feel like home.
Verify the install:
python --version
# Output should be something like: Python 3.12.x
If you see a version number, you're ready. (On some Linux systems the command is python3.)
Two ways to run code
The REPL (Read-Eval-Print Loop) is an interactive playground. Type python with no file and you get a >>> prompt that runs code as you type it:
>>> 2 + 2
4
>>> "py" * 3
'pypypy'
Perfect for quick experiments. Type exit() to leave.
A script is a .py file you run from the terminal. This is how real programs are written and shared:
# hello.py
print("Hello from a script!")
python hello.py
# Hello from a script!
print() is the built-in that writes text to the screen. The REPL auto-shows the value of an expression; a script does not — inside a script you must print() anything you want to see.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Variables, Types & f-Strings
Naming values
Creating a variable is just naming it and assigning a value with =:
target_ip = "192.168.1.15" # a string
port = 8080 # an integer
is_secure = True # a boolean
We never said what type each variable is. Python works it out from the value. The convention for names is snake_case — lowercase words joined by underscores.
💡 Tip: Names should describe what the value means, not what type it is.
user_countbeatsn;is_securebeatsflag.
Types are attached to values, not names
🛑 Dev Callout: Dynamic vs. Static Typing
In C#/Java you write
int age = 34;— the variable has a fixed type forever. In Python the value carries the type, and a name can be rebound to a value of any type:x = 10 # x refers to an int x = "ten" # now the very same name refers to a str — totally legalFreeing, but it also means a typo in a name creates a new variable instead of a compile error. We'll claw back that safety net with type hints and
mypyin Chapter 7.
If you ever need to check a type, the built-in type() tells you:
print(type(port)) # <class 'int'>
The core built-in types you'll meet first: int, float, str, bool.
Working with text
You can use single ' or double " quotes interchangeably — pick one and be consistent. Strings support handy operators:
name = "Ada"
print(name + " Lovelace") # concatenation -> "Ada Lovelace"
print("=" * 20) # repetition -> "===================="
print(len(name)) # length -> 3
Use triple quotes for text that spans multiple lines:
banner = """
Welcome to the system.
All activity is logged.
"""
f-strings: the only way to format
Put an f before the opening quote, then drop variables straight into the text inside curly braces {}:
username = "admin"
login_attempts = 3
# The old, clunky way — manual concatenation and str() conversion:
print("User " + username + " failed to login " + str(login_attempts) + " times.")
# The f-string way:
print(f"User {username} failed to login {login_attempts} times.")
Both print the same thing. Only one is worth typing.
You can put any expression inside the braces, and add a format specifier after a colon — :.2f shows two decimal places, great for money:
price = 144.6 / 3
print(f"Each person pays: ${price:.2f}") # Each person pays: $48.20
Without :.2f, floating-point math leaks ugly digits like 48.199999999999996.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Numbers, Math & Input
Python is a capable calculator
| Operation | Operator | Note |
|---|---|---|
| Addition / subtraction | + - | |
| Multiplication | * | |
| Division | / | Always returns a float: 10 / 2 is 5.0 |
| Floor division | // | Drops the remainder: 7 // 2 is 3 |
| Modulo (remainder) | % | 7 % 2 is 1 |
| Exponent | ** | 2 ** 10 is 1024 |
The two that surprise beginners: / always gives a float, and % (modulo) gives the remainder — invaluable for "is this even?" (n % 2 == 0) or "every 10th item" (i % 10 == 0).
There's also augmented assignment — x += 1 is shorthand for x = x + 1. The same works for -=, *=, /=. You'll use += constantly.
input() and the casting trap
The input() function pauses the program and waits for the user to type something:
age = input("How old are you? ")
The trap: input() always hands you back a string. If the user types 34, Python sees "34". Try to do math — age + 5 — and the script crashes, because you can't add a number to a piece of text.
The fix is to cast (convert) the string into a number: int() for whole numbers, float() for decimals.
age = int(input("How old are you? "))
print(f"In 10 years, you will be {age + 10}.")
⚠️ Warning: If the user types
"twenty",int()raises aValueErrorand the script crashes. That's fine for now — handling it gracefully is exactly what Chapter 5 (error handling) is about.
Converting between types
Casting functions turn one type into another:
int("42") # 42 string -> int
float("3.14") # 3.14 string -> float
str(99) # "99" int -> string
int(3.99) # 3 float -> int (truncates, does NOT round)
Two things to remember:
int()on a float truncates toward zero —int(3.99)is3, not4. Useround(3.99)if you want rounding.- Casting fails loudly on nonsense:
int("hello")raisesValueError. That's a feature — it tells you the input was bad instead of silently guessing.
Bringing it together
Let's combine variables, f-strings, math, input, and casting into a real tool. Create calculator.py:
# 1. Greet the user
print("Welcome to the Terminal Tip & Split Calculator!")
# 2. Get the inputs and convert them right away.
# float() because money has decimals:
bill_amount = float(input("Enter the total bill amount: $"))
# Dividing by 100 turns 20 into 0.20 — what the math needs:
tip_percentage = float(input("Enter the tip percentage (e.g. 15, 20): ")) / 100
# People come in whole numbers, so int():
people = int(input("How many people are splitting the bill? "))
# 3. Do the math
tip_amount = bill_amount * tip_percentage
total_bill = bill_amount + tip_amount
cost_per_person = total_bill / people
# 4. Print a clean receipt. \n adds a blank line; :.2f rounds to cents.
print("\n--- Receipt ---")
print(f"Total Bill (with tip): ${total_bill:.2f}")
print(f"Each person pays: ${cost_per_person:.2f}")
python calculator.py
You didn't print "Hello World" — you built a script that handles real input and real math. Notice how fragile it still is: type "twenty" and it falls over. Next chapter we add the brains — decisions and loops — so code can react instead of blindly trusting.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Booleans & Conditionals
True and False
At the core of all logic sits the boolean: True or False (always capitalized in Python). You produce booleans with comparison operators:
| Operator | Means |
|---|---|
== | equal to |
!= | not equal to |
> < | greater / less than |
>= <= | greater-or-equal / less-or-equal |
print(5 > 3) # True
print(10 == "10") # False — an int is never equal to a str
⚠️ Warning:
=assigns,==compares. Writingif x = 5:is a syntax error; you meanif x == 5:. This trips up everyone once.
Branching
🛑 Dev Callout: Indentation Is Law
Used to
{}and;? Python throws both out. A block starts with a colon:, and everything indented under it (four spaces, by convention) belongs to that block. The moment you un-indent, the block ends.
system_status = "offline"
battery_level = 15
if system_status == "online":
print("All systems nominal.")
elif battery_level < 20:
print("Warning: low power. Initiating sleep mode.")
else:
print("System is offline, but power is stable.")
elif is Python's "else if" — chain as many as you like. Python checks each condition top to bottom and runs the first one that's True, then skips the rest.
Combining conditions
Glue booleans together with the keywords and, or, and not (Python spells them out — no && or ||):
age = 25
has_ticket = True
if age >= 18 and has_ticket:
print("Entry granted.")
if not has_ticket:
print("Please buy a ticket.")
and—Trueonly if both sides are true.or—Trueif either side is true.not— flips a boolean.
💡 Tip: Python "short-circuits" — in
a and b, ifais false it never even evaluatesb. Handy for guards likeif user and user.is_admin:.
Emptiness is False
Python is elegant about emptiness. You rarely need if name != "" or if len(items) > 0. These values are falsy — treated as False:
0and0.0""(empty string)[],{},(),set()(empty collections)None
Everything else is truthy. So you can write:
user_input = ""
if not user_input:
print("You didn't type anything!")
items = [1, 2, 3]
if items: # reads as "if there are items"
print(f"You have {len(items)} items.")
This idiom is everywhere in real Python. Embrace it — if items: is clearer than if len(items) > 0:.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Loops: while, for & range
Repeat until a condition changes
A while loop runs as long as its condition stays True. Perfect for menus, game loops, or anything that continues until a specific event happens:
attempts = 0
while attempts < 3:
attempts += 1
print(f"Scanning... (scan {attempts})")
print("Done.")
⚠️ Warning: If the condition never becomes false, you get an infinite loop. Make sure something inside the loop moves it toward the exit (here,
attempts += 1). Stuck in one? Press Ctrl+C to stop the program.
Iterating over a collection
🛑 Dev Callout: The End of
for (int i = 0; ...)Used to
for (int i = 0; i < arr.Length; i++)? Python'sforbehaves like aforeach— it iterates directly over the items in a collection, not over an index you manage by hand. Off-by-one errors mostly disappear.
# Walk over the characters in a string:
for letter in "HACK":
print(f"Decrypting: {letter}")
# Walk over a list:
for ip in ["10.0.0.1", "10.0.0.2"]:
print(f"Pinging {ip}")
The variable (letter, ip) takes each value in turn. The loop ends automatically when the collection is exhausted.
Repeating N times, and steering the loop
To repeat a fixed number of times, pair for with the built-in range():
# range(5) produces 0, 1, 2, 3, 4 — five numbers, starting at zero.
for i in range(5):
print(f"Ping {i + 1} sent.")
range(start, stop, step) is flexible: range(2, 10, 2) gives 2, 4, 6, 8.
Two keywords steer any loop:
break— bail out of the loop immediately.continue— skip the rest of this iteration and jump back to the top.
for n in range(10):
if n == 5:
break # stop entirely at 5
if n % 2 == 0:
continue # skip even numbers
print(n) # prints 1, 3
A loop can have an else
A lesser-known feature: a for or while loop can carry an else block. It runs only if the loop finished normally — i.e. it was not ended by break. It's the clean way to express "I searched the whole thing and didn't find it":
needle = 42
for n in [10, 20, 30]:
if n == needle:
print("Found it!")
break
else:
print("Not found anywhere.") # runs, because no break fired
This saves you a separate found = False flag variable. You'll see the manual-flag version in the project below — both are valid; pick whichever reads clearer.
Bringing it together: the number-guessing game
We need our first piece of the standard library: the random module. import it at the top, and random.randint(a, b) gives a random whole number in [a, b].
Create firewall_bypass.py:
import random
# 1. Set up the game
secret_passcode = random.randint(1, 100) # random int, 1..100 inclusive
max_attempts = 5
current_attempt = 0
firewall_bypassed = False
print("Welcome to the Terminal Hacker System.")
print(f"You have {max_attempts} attempts to bypass the firewall.\n")
# 2. The main game loop
while current_attempt < max_attempts:
current_attempt += 1
guess = int(input(f"Attempt {current_attempt}: Enter your guess: "))
# 3. Compare the guess to the secret
if guess == secret_passcode:
print(f"[SUCCESS] Firewall bypassed in {current_attempt} attempts!")
firewall_bypassed = True
break
elif guess < secret_passcode:
print("[!] Too low.\n")
else:
print("[!] Too high.\n")
# 4. Handle the loss
if not firewall_bypassed:
print(f"[LOCKED] The correct passcode was {secret_passcode}.")
A smart player cracks any code in this range within 5 attempts using a halving strategy — guess the middle, throw away half the range each time. That's no accident; we'll meet the O(log N) math behind it in the next chapter.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Strings in Depth
Strings are sequences of characters
Every character has an index, starting at 0. Negative indexes count from the end:
text = "PYTHON"
print(text[0]) # 'P' — first
print(text[-1]) # 'N' — last
Slicing grabs a range with [start:stop] — start is included, stop is not:
print(text[0:3]) # 'PYT' indexes 0,1,2
print(text[3:]) # 'HON' from 3 to the end
print(text[:2]) # 'PY' from the start to 2
print(text[::-1]) # 'NOHTYP' — a neat reversal trick (step of -1)
💡 Tip: The same
[start:stop:step]slicing works on lists and tuples too — learn it once, use it everywhere.
You can't change a string in place
name = "ada"
name[0] = "A" # 💥 TypeError — strings can't be modified
Instead, string methods return a new string, leaving the original untouched:
name = "ada"
proper = name.capitalize() # "Ada"
print(name) # "ada" — original is unchanged
This trips people up: name.upper() does nothing useful unless you capture the result (name = name.upper() or use it directly). The original never mutates.
The string toolbox
A handful of methods cover 90% of real text work:
| Method | Does |
|---|---|
.lower() / .upper() | change case |
.strip() | remove leading/trailing whitespace |
.replace(a, b) | swap every a for b |
.split(sep) | break into a list on sep (default: whitespace) |
.startswith(x) / .endswith(x) | boolean checks |
.find(x) / in | locate / test for a substring |
line = " 192.168.1.50 GET /index.html 200 "
clean = line.strip() # drop the surrounding spaces
parts = clean.split() # ['192.168.1.50', 'GET', '/index.html', '200']
ip = parts[0] # '192.168.1.50'
print("GET" in clean) # True
print(clean.endswith("200")) # True
.split() is the workhorse of log parsing — you'll use it constantly in this course.
join() — the right way to assemble text
To stitch a list of strings together, use .join() — not a += loop:
servers = ["web-01", "web-02", "db-01"]
# ✅ Clean and fast:
print(", ".join(servers)) # "web-01, web-02, db-01"
The separator goes on the left; the iterable of strings goes inside join().
🛑 Dev Callout: Why not
+=in a loop?Building a big string by repeated
result += piececreates a brand-new string on every iteration (strings are immutable), which isO(N²)work — the same reason you reach for aStringBuilderin C#/Java."".join(pieces)does it in one pass. Use+for a couple of strings; usejoin()for many.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Lists & Tuples
Ordered, mutable collections
A list is an ordered collection. Make one with square brackets []. Lists are mutable — you can add, remove, and change items after creation.
loot = ["Gold", "Health Potion", "Iron Sword"]
# Access by index (zero-based); negatives count from the end:
print(loot[0]) # Gold
print(loot[-1]) # Iron Sword
# Modify in place:
loot.append("Magic Ring") # add to the end
loot.remove("Iron Sword") # remove the first matching value
loot[1] = "Major Health Potion" # replace by index
loot.insert(0, "Map") # insert at a position
popped = loot.pop() # remove & return the last item
Lists are your default for ordered data: a queue of tasks, a log of actions, a sequence of results.
Everyday list tools
nums = [4, 7, 1, 9, 2]
len(nums) # 5 — how many items
sorted(nums) # [1, 2, 4, 7, 9] — returns a NEW sorted list
nums.sort() # sorts nums IN PLACE, returns None
sum(nums) # total
min(nums), max(nums) # 1, 9
7 in nums # True — membership test
nums + [100] # concatenation -> new list
[0] * 3 # [0, 0, 0]
⚠️ Warning:
sorted(nums)returns a new list;nums.sort()mutates and returnsNone. Writingnums = nums.sort()is a classic bug — you just setnumstoNone. Usesorted()when you want a copy,.sort()when you want to reorder in place.
Immutable by design
A tuple looks like a list but uses parentheses () and is immutable — once created, you cannot add, remove, or change anything:
server_coordinates = (192, 168, 1, 15)
print(server_coordinates[0]) # 192 — reading is fine
server_coordinates[0] = 10 # 💥 TypeError — tuples can't be changed
Why want something you can't change? Two reasons: immutability is slightly faster and lighter, and — more importantly — it's a guarantee. When you hand someone a tuple you're promising the data won't shift under their feet. Use them for fixed records: coordinates, RGB colors, a database row, config that shouldn't change at runtime.
💡 Tip: Tuples shine at multiple return values and unpacking:
x, y = (10, 20)assigns both at once. Functions that "return two things" actually return one tuple.
Which one, when?
| Use a list when… | Use a tuple when… |
|---|---|
| the collection will change | the data is fixed |
| order matters and you'll edit it | you want a lightweight record |
| it's a homogeneous sequence (many of one thing) | it's a fixed-size group of related fields |
A good rule of thumb from the community: lists are for "many of the same kind of thing" (a list of users), tuples are for "one thing with several parts" (a single user's (name, age, city)).
🛑 Dev Callout: Immutability as a contract
If you've used C#
recordor a read-only struct, a tuple is the lightweight, nameless version: a value you pass around with a promise that nobody will mutate it. When you reach for a named, structured version, that's a dataclass — Chapter 6.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Dictionaries & Sets
The key → value store
If you master only one data structure in Python, make it the dictionary (dict). It stores data as key → value pairs. Curly braces {}, with a colon between key and value:
player = {
"name": "Arthur",
"level": 5,
"class": "Knight",
}
print(player["class"]) # Knight — look up by key
player["level"] = 6 # key exists -> updates
player["guild"] = "The Ravens" # key is new -> creates
A value can be anything — a number, a string, a list, even another dict. That's how you model nested data without inventing a class for every little thing.
🛑 Dev Callout: This is your
Dictionary<TKey,TValue>/HashMap— same hash-table guarantees, far less ceremony.
Safe access and iteration
Looking up a missing key with [] crashes with KeyError. Use .get() to supply a fallback instead:
print(player.get("title")) # None — no crash
print(player.get("title", "Squire")) # "Squire" — default value
Iterate over keys, values, or both:
for key in player: # keys by default
print(key)
for key, value in player.items(): # both at once — the common case
print(f"{key} = {value}")
if "level" in player: # membership tests the KEYS
print("has a level")
.items() giving you the key and value together is one of the most-used patterns in all of Python.
Unordered, no duplicates
A set uses curly braces {} and has one superpower: it refuses duplicates. Add something already present and the set silently ignores you. Sets are also unordered — don't rely on item order.
visited = {"Tavern", "Forest", "Castle"}
visited.add("Tavern") # already there — ignored
visited.add("Dungeon") # added
print("Forest" in visited) # True
Sets are perfect for de-duplicating data and for fast "have I seen this before?" checks. They also do real set algebra:
a = {1, 2, 3}
b = {3, 4, 5}
print(a & b) # {3} intersection
print(a | b) # {1,2,3,4,5} union
print(a - b) # {1, 2} difference
The performance decision that actually matters
🛑 Dev Callout: Algorithmic Complexity (Big-O)
If you need to check whether an item exists in a large collection —
if item in collection:— do not use a list. A list scans every element one by one: anO(N)linear search.A
set(and adictkey lookup) is a hash table, so membership isO(1)— constant time, regardless of size. Parsing logs against a blacklist of 500,000 malicious IPs? Load them into a set, not a list, and a crawl becomes instant.
Same idea powers the halving strategy from the guessing game: cutting the search space in half each step is O(log N), which is why 5 guesses cover 100 numbers (and ~20 guesses would cover a million).
| Structure | "is x in here?" | Keeps order? | Duplicates? |
|---|---|---|---|
list | O(N) slow | yes | yes |
set | O(1) fast | no | no |
dict (by key) | O(1) fast | yes (insertion) | keys unique |
Picking the right container is the optimization. Reach for it before clever tricks.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Iterating Like a Pro
Need the index too? Use enumerate
Beginners reach for a manual counter:
# ❌ Clunky:
i = 0
for item in items:
print(i, item)
i += 1
Python gives you the index and the item together with enumerate():
# ✅ Pythonic:
for i, item in enumerate(items):
print(i, item)
Pass start=1 to count like a human (1, 2, 3…) — perfect for line numbers in a file:
for line_number, line in enumerate(open("log.txt"), start=1):
print(f"Line {line_number}: {line.strip()}")
Walking two lists in lockstep
zip() pairs up items from multiple iterables, position by position:
names = ["web-01", "web-02", "db-01"]
loads = [0.45, 0.91, 0.30]
for name, load in zip(names, loads):
print(f"{name}: {load}")
It stops at the shortest input. zip() is also the clean way to build a dict from two lists:
config = dict(zip(names, loads))
# {'web-01': 0.45, 'web-02': 0.91, 'db-01': 0.30}
Sorting by a computed value
sorted() (and .sort()) take a key= function that says what to sort by:
words = ["banana", "kiwi", "apple"]
print(sorted(words)) # alphabetical: ['apple','banana','kiwi']
print(sorted(words, key=len)) # by length: ['kiwi','apple','banana']
print(sorted(words, reverse=True)) # reverse order
For lists of dicts or objects, you point the key at the field you care about (we'll use lambda for that in Chapter 4). The same key= idea drives max() and min().
💡 Tip:
key=str.lowergives case-insensitive sorting — a tidy trick.
Bringing it together
We combine dicts, sets, and iteration into a text-adventure inventory. We use a dict for stackable items (quantity matters) and a set for one-of-a-kind items.
Create inventory.py:
inventory_counts = { # stackable items: name -> how many
"Health Potions": 3,
"Gold Coins": 150,
}
unique_items = {"Rusty Key", "Map"} # one-of-a-kind items
new_loot = ["Iron Shield", "Health Potion", "Map", "Health Potion"]
print("--- Player Inventory ---")
for item, count in inventory_counts.items():
print(f"{item}: {count}")
print(f"Unique Items: {unique_items}\n")
print("--- Processing loot ---")
for item in new_loot:
if item == "Health Potion":
inventory_counts["Health Potions"] += 1
print(f"[+] Health Potions -> {inventory_counts['Health Potions']}.")
else:
# Let the SET enforce uniqueness for us:
if item in unique_items:
print(f"[!] Already have a {item}. Discarding duplicate.")
else:
unique_items.add(item)
print(f"[+] Added {item}.")
print("\n--- Final Inventory ---")
print(f"Quantities: {inventory_counts}")
print(f"Unique Items: {unique_items}")
Notice how the set quietly handles the duplicate Map while the dict happily stacks the potions. Choosing the right structure made the logic almost write itself — exactly the lesson from the Big-O section.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Functions, Parameters & return
def — package logic for reuse
Copy-pasting the same logic everywhere is a maintenance trap: tighten the rule in one place and you have to remember the other four. A function is a named, reusable block. Define it with def, a snake_case name, parentheses, and a colon:
def boot_sequence():
print("Initializing core modules...")
print("System ready.")
# Defining it doesn't run it. CALLING it does:
boot_sequence()
When Python reads a def block it just memorizes the body — the code only runs when you call the function by name with ().
Feeding data in
The names in the definition are parameters; the values you pass are arguments:
def ban_user(username, reason):
print(f"User {username} banned. Reason: {reason}")
ban_user("hacker99", "Exploiting bugs")
ban_user(reason="Spam", username="bot42") # keyword arguments, any order
Default parameters make an argument optional:
def ping_server(ip, retries=3):
print(f"Pinging {ip}... (retries: {retries})")
ping_server("10.0.0.1") # uses the default, 3
ping_server("10.0.0.5", retries=10) # overrides it
⚠️ Warning: Parameters with defaults must come after those without. And never use a mutable default like
def f(items=[])— that one list is shared across all calls. UseNoneand create a fresh one inside (more in Chapter 6).
The distinction beginners miss
print() and return are not the same thing:
print()dumps text to the console for a human to read.returnhands a value back to the program so other code can use it.
def calculate_tax(subtotal):
return subtotal * 1.08 # hand the number back
price = calculate_tax(50.00) # capture it
print(f"Pay: ${price:.2f}") # Pay: $54.00
A function with no return implicitly returns None. The moment a function hits return, it exits.
💡 Tip: Prefer
returnover
Document as you go
A string literal right under the def line is a docstring — Python's built-in way to document what a function does. Editors show it on hover; help() reads it.
def analyze_strength(password):
"""Rate a password and return a human-readable verdict."""
if len(password) < 8:
return "WEAK - too short."
return "OK"
Get in the habit now. A one-line docstring stating what the function does and what it returns pays for itself the first time you (or a teammate) revisit the code.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Scope & the LEGB Rule
Which variables can a function see?
Python resolves a name by looking, in order:
- Local — names created inside the function itself.
- Enclosing — names in an outer function, if this one is nested.
- Global — names defined at the top level of the file.
- Built-in — Python's pre-loaded names, like
printandlen.
system_status = "ONLINE" # global
def check_system():
status_code = 200 # local
print(f"{system_status} with code {status_code}")
check_system()
# print(status_code) # 💥 NameError — locals are destroyed when the function returns
A function can freely read a global; locals vanish the moment it returns.
A trap for C#/Java developers
🛑 Dev Callout: The "Block Scope" Trap
In C#/C++/Java a variable declared inside an
iforfordies when that block ends. Python has no block scope — only function scope and global scope.if True: temp_token = "ABC" print(temp_token) # Works in Python. Would not compile in Java.A name created inside a loop or
ifleaks into the rest of the enclosing function. It's the design, not a bug — but a name you thought was temporary can quietly collide with one later.
The habit that saves you: keep variables inside functions (not floating at file top), and give them names specific enough that two never fight over the same identifier.
Reading vs reassigning globals
A function can read a global, but it can't reassign one by default — try, and Python just makes a new local of the same name:
counter = 0
def increment():
counter = counter + 1 # 💥 UnboundLocalError — Python sees a local "counter"
There's a global keyword that forces it, but don't reach for it. Functions that mutate global state are hard to test and reason about.
The clean pattern is the one functions were built for: take data in through parameters, send results out through return.
def increment(counter):
return counter + 1
counter = increment(counter) # explicit, testable, no surprises
Bringing it together
Two functions, each doing one job — generate passwords, and rate them. Notice how short the execution part at the bottom becomes once logic lives in functions.
Create security_tool.py:
import random
import string
def generate_password(length=12):
"""Generate a random password of the given length."""
characters = string.ascii_letters + string.digits + string.punctuation
password = ""
for _ in range(length): # _ = "I don't need this loop value"
password += random.choice(characters)
return password
def analyze_strength(password):
"""Rate a password and return a human-readable verdict."""
if len(password) < 8:
return "WEAK - Too short. Must be at least 8 characters."
# any() is True if at least one item passes the test:
has_number = any(char.isdigit() for char in password)
has_special = any(char in string.punctuation for char in password)
if not has_number and not has_special:
return "WEAK - Missing numbers and special characters."
elif not has_number or not has_special:
return "MODERATE - Add both numbers and specials for max security."
return "STRONG - Meets all security criteria."
# --- MAIN SCRIPT ---
print("[1] Generating secure password (length 15)...")
print(f"Result: {generate_password(length=15)}\n")
print("[2] Analyzing 'admin':")
print(f"Result: {analyze_strength('admin')}\n")
print("[3] Analyzing 'SuperSecretPass123!':")
print(f"Result: {analyze_strength('SuperSecretPass123!')}")
Each function returns its verdict instead of printing — which is exactly what makes them reusable and testable later. any(...) paired with a generator expression is a preview of the comprehensions coming up next.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Comprehensions
Say it in one line
🛑 Dev Callout: Comprehensions Are LINQ
A list comprehension is
.Where().Select()collapsed into one expression:var names = users.Where(u => u.Active).Select(u => u.Name).ToList(); // C#names = [u.name for u in users if u.active] # PythonJava devs: same idea as a
Streampipeline, minus the ceremony.
The shape is always [expression for item in iterable]:
# The long way:
squares = []
for n in range(1, 6):
squares.append(n * n)
# The comprehension:
squares = [n * n for n in range(1, 6)] # [1, 4, 9, 16, 25]
Same result, one line, no temporary empty list to manage.
Add an if to filter
A trailing if (no else) keeps only items you want — the .Where()/.filter() half:
numbers = [4, 7, 10, 13, 16]
evens = [n for n in numbers if n % 2 == 0] # [4, 10, 16]
The expression part (before for) reshapes each item — any expression: a method call, math, a slice:
lines = ["10.0.0.99 POST /login 403", "192.168.1.50 GET / 200"]
ips = [line.split()[0] for line in lines] # ['10.0.0.99', '192.168.1.50']
You can embed Python's ternary in the transform — value_if_true if condition else value_if_false:
codes = [200, 403, 500]
labels = ["OK" if c < 400 else "FAIL" for c in codes] # ['OK','FAIL','FAIL']
The rule: a trailing if filters; an if/else before the for transforms.
Same idea, curly braces
Swap [] for {} and you get the other two.
A set comprehension builds a deduplicated set — perfect for "the unique X":
lines = ["192.168.1.50 ... 200", "10.0.0.99 ... 403", "192.168.1.50 ... 404"]
unique_visitors = {line.split()[0] for line in lines}
# {'192.168.1.50', '10.0.0.99'}
A dict comprehension uses key: value syntax:
servers = ["web-01", "web-02", "db-01"]
name_lengths = {name: len(name) for name in servers}
# {'web-01': 6, 'web-02': 6, 'db-01': 5}
You can even filter an existing dict:
failures = {"a": 1, "b": 4, "c": 2}
suspicious = {ip: n for ip, n in failures.items() if n > 2} # {'b': 4}
Pythonic means readable, not shortest
Comprehensions are for building a collection. Reach for a plain loop when:
- the loop has side effects — printing, writing files, calling an API;
- the comprehension grows three clauses deep and stops fitting on one line;
- you need
break/continueor complex intermediate steps.
# ❌ Don't cram side effects into a comprehension:
[print(x) for x in items] # works, but abuses the syntax
# ✅ Just write the loop:
for x in items:
print(x)
💡 Tip: If you have to read a comprehension twice to understand it, it should have been a loop. Pythonic means clear, not clever.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Generators & Lambdas
Tiny throwaway functions
A lambda is a one-line, unnamed function: lambda arguments: expression. It returns the expression automatically (no return):
double = lambda x: x * 2
print(double(5)) # 10
You'll almost never assign one to a name like that — if it deserves a name, use def. Where lambdas shine is as the key= argument to sorted(), max(), and min():
servers = [
{"name": "web-01", "load": 0.82},
{"name": "db-01", "load": 0.31},
{"name": "web-02", "load": 0.95},
]
busiest_first = sorted(servers, key=lambda s: s["load"], reverse=True)
hottest = max(servers, key=lambda s: s["load"])
print(hottest["name"]) # web-02
The key function tells the builtin what to compare. Without it, Python wouldn't know which field of the dict you mean.
Comprehensions that don't blow up your RAM
Swap [] for () and you get a generator expression. It looks almost identical to a list comprehension but produces items one at a time, on demand — instead of building the whole list in memory at once:
# List comprehension — builds all 10 million numbers in memory immediately:
total = sum([n * n for n in range(10_000_000)])
# Generator — feeds one number at a time into sum(), holding ~one in memory:
total = sum(n * n for n in range(10_000_000))
Both give the same answer. The second uses a tiny, constant amount of memory. This is the same memory lesson as streaming a file line by line — now a language feature.
Nothing computes until you pull
🛑 Dev Callout: Generators Are Lazy
IEnumerablesA generator is Python's
yield-based lazy sequence — conceptually the same as a C#IEnumerable<T>built withyield return, or a JavaStreamyou haven't terminated. Nothing is computed until something pulls from it (aforloop,sum(),list()).
Choosing between them:
- Use a list comprehension when you need the full collection in hand — you'll index it, loop it twice, or check its length.
- Use a generator when you'll consume it exactly once and the source is large — you trade random access for near-zero memory.
You can also write generators with def + yield:
def countdown(n):
while n > 0:
yield n # pause here, hand back n, resume on the next pull
n -= 1
for x in countdown(3):
print(x) # 3, 2, 1
Bringing it together
Take raw access-log lines and produce a security report — mostly in one-liners. We use collections.Counter, a dict subclass purpose-built for tallying.
Create log_analyzer.py:
from collections import Counter
raw_lines = [
"192.168.1.50 GET /index.html 200",
"10.0.0.99 POST /login 403",
"192.168.1.50 GET /admin 404",
"10.0.0.99 POST /login 403",
"172.16.0.1 GET /index.html 200",
"10.0.0.99 POST /login 403",
]
# Split each line into (ip, method, path, status) once, up front:
records = [tuple(line.split()) for line in raw_lines]
all_ips = [ip for ip, method, path, status in records]
unique_visitors = {ip for ip, method, path, status in records}
failed_ips = [ip for ip, m, p, status in records if status.startswith("4")]
requests_per_ip = Counter(all_ips)
failures_per_ip = Counter(failed_ips)
top_ip, top_count = requests_per_ip.most_common(1)[0]
# Dict comprehension filtering an existing dict:
suspicious = {ip: n for ip, n in failures_per_ip.items() if n > 2}
print("--- Access Log Report ---")
print(f"Total requests: {len(records)}")
print(f"Unique visitors: {len(unique_visitors)}")
print(f"Failed requests (4xx): {len(failed_ips)}")
print(f"Top talker: {top_ip} ({top_count} requests)")
print("Suspicious IPs (>2 failures):")
for ip, n in suspicious.items():
print(f" - {ip} -> {n} failures")
Count how many lines of imperative for/if/append the comprehensions replaced. The comprehension version isn't just shorter — once you're fluent, each line states one transformation instead of a recipe you mentally execute.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Error Handling
The real world is hostile
Up to now we've assumed the happy path: users behave, files exist, input is clean. The real world isn't like that. Connections drop, disks fill, input arrives malformed. A script that throws a raw stack trace and dies on the first bad line is not production-ready.
Errors aren't failures — they're information. They tell you something unexpected happened, and your code gets to decide what to do about it. Python handles them with try / except, the equivalent of try / catch in C# or Java:
try:
user_id = int("admin")
except ValueError as e:
print(f"[ERROR] Cannot convert input: {e}")
The as e binds the exception object so you can inspect or log it.
No "Pokémon exception handling"
There's an anti-pattern common enough to have a name — Pokémon exception handling, because you "gotta catch 'em all":
# ❌ DANGER: a bare except catches EVERYTHING
try:
result = do_something_risky()
except:
print("Something went wrong")
A bare except: is dangerously broad. It swallows:
KeyboardInterrupt— the user pressing Ctrl+C to stop the programSystemExit— a deliberatesys.exit()- typos in your own code that should crash loudly while you develop
Always name the exception you anticipate, and let everything else propagate:
try:
config = data["timeout"]
except KeyError:
config = 30 # sensible default
A TypeError elsewhere would still crash — which, during development, is exactly what you want.
Cleanup and the success path
finally runs no matter what — success, handled error, or an uncaught error on its way out. It's where cleanup that must happen goes:
conn = open_connection()
try:
conn.query("SELECT ...")
except ConnectionError as e:
print(f"[ERROR] DB unreachable: {e}")
finally:
conn.close() # always runs
print("[INFO] Connection closed.")
The lesser-known else block runs only if the try finished with no exception — handy for code that should run on success but shouldn't be "protected" by the try:
try:
value = int(user_input)
except ValueError:
print("Not a number.")
else:
print(f"Got a clean number: {value}") # only on success
When your code is the one detecting the problem
Use raise to signal an error from your own functions — fail fast and clearly rather than returning a bogus value that blows up somewhere far away:
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("Amount must be positive.")
if amount > balance:
raise ValueError("Insufficient funds.")
return balance - amount
The caller decides how to react:
try:
balance = withdraw(100, 150)
except ValueError as e:
print(f"[DENIED] {e}")
💡 Tip: Raise the most specific built-in that fits —
ValueErrorfor a bad value,FileNotFoundErrorfor a missing file,KeyErrorfor a missing key. Specific exceptions let callers handle each case precisely.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Files & pathlib
open() and the with statement
🛑 Dev Callout:
IDisposablevs. Context ManagersYou know the pain of a leaked file handle when someone forgets
.Dispose()— which is why you wrap things inusing. Python's equivalent is the context manager viawith. It guarantees the file is closed the instant the block ends, even if an exception is thrown inside it.using (var r = new StreamReader("log.txt")) { ... } // C#with open("log.txt") as r: # Python content = r.read() # closed automatically here
| Mode | Behaviour |
|---|---|
'r' | Read (default). FileNotFoundError if missing. |
'w' | Write — overwrites the file; creates it if absent. |
'a' | Append — adds to the end; creates it if absent. |
Skip the with and you're leaving handles dangling — the same bug as forgetting Dispose().
Memory-efficient file reading
You could load an entire file into memory with file.read() — but what happens when the log is 10 GB? Your program grinds to a halt. Instead, iterate over the file object directly, one line at a time:
# ✅ Memory-efficient — one line in memory at a time:
with open("huge_log.txt", "r") as file:
for line in file:
process(line)
This reads a line, processes it, discards it, grabs the next. A 10 GB file flows through as smoothly as a 10 KB one. This is the same lesson as generators in Chapter 4 — don't hold more in memory than you need.
Writing is just as simple:
with open("audit_log.txt", "a") as file:
file.write("User 'admin' logged in\n") # \n — write() does not add newlines
Stop concatenating path strings
Hardcoding a / or \\ is a bug waiting to fire on a different OS. pathlib (Python 3.4+) is the modern standard. It overloads / to join paths and emits the right separator for the current OS:
from pathlib import Path
# Works on Linux, macOS, and Windows:
log_path = Path.home() / "server_logs" / "access.log"
if not log_path.exists():
print("Log file missing!")
A few methods you'll use constantly:
| Method / property | Purpose |
|---|---|
path.exists() / .is_file() / .is_dir() | existence checks |
path.read_text() / path.write_text(s) | one-shot read/write |
path.parent | the containing directory |
path.name / .stem / .suffix | access.log / access / .log |
Bringing it together
A robust utility: ingest a partially-corrupted log, extract valid IPs, skip broken lines without crashing, and stream clean output to a new file. The architecture is the real lesson — nested try/except at two levels.
Create log_cleanser.py:
import re
from pathlib import Path
# Rough IP pattern: four groups of 1-3 digits, dot-separated.
IP_PATTERN = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
input_path = Path("raw_traffic.log")
output_path = Path("clean_ips.txt")
valid_ips, skipped, total = [], 0, 0
print("[INFO] Processing log file...")
try:
# with manages BOTH files; both close cleanly even on error.
with open(input_path, "r") as infile, open(output_path, "w") as outfile:
for line_number, line in enumerate(infile, start=1):
total += 1
try: # INNER try: one bad line won't kill the run
match = IP_PATTERN.search(line.strip())
if match:
ip = match.group()
valid_ips.append(ip)
outfile.write(ip + "\n")
else:
skipped += 1
print(f'[WARN] Skipping line {line_number}: "{line.strip()[:40]}"')
except (ValueError, AttributeError):
skipped += 1
except FileNotFoundError: # OUTER except: catastrophic failure
print(f"[ERROR] File not found: {input_path}")
else: # runs only if the file processed fully
print(f"[SUCCESS] Extracted {len(valid_ips)} IPs from {total} lines.")
print(f"[SUCCESS] Saved to {output_path}")
Look at the architecture: the outer try catches "the file isn't there at all"; the inner try catches "this one line is broken"; the else runs only on full success. That layering is what separates production code from a toy script — it degrades gracefully and keeps working.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Modules, Packages & the Standard Library
Reusing what others (and you) wrote
A module is just a .py file. import brings its contents into your program. The standard library is the huge set of modules that ship with Python — "batteries included."
import random # whole module; use as random.randint(...)
from pathlib import Path # pull one name out; use as Path(...)
from collections import Counter, defaultdict # several names
import statistics as stats # alias a long name
Forms to know:
import module— namespaced access (module.thing). Safest; no name clashes.from module import name— pull a name in directly. Convenient.import module as alias— shorten a long name (e.g.import numpy as np).
⚠️ Warning: Avoid
from module import *. It dumps every name into your file, hiding where things came from and inviting silent clashes.
A tour of the standard library
You've already used several. A few worth knowing before you ever reach for a third-party package:
| Module | For |
|---|---|
random | random numbers and choices |
math | sqrt, pi, floor, ceil, … |
datetime | dates, times, formatting |
pathlib | filesystem paths |
json | read/write JSON |
re | regular expressions |
collections | Counter, defaultdict, deque |
itertools | iterator building blocks |
os / sys | environment and interpreter |
import json
data = {"name": "ada", "level": 5}
text = json.dumps(data) # dict -> JSON string
back = json.loads(text) # JSON string -> dict
💡 Tip: Before installing a package or hand-rolling a utility, check the standard library — the answer is often already there.
Splitting a project into files
As a program grows, split it into modules. Put helpers in utils.py, import them from main.py:
# utils.py
def greet(name):
return f"Hello, {name}!"
# main.py
from utils import greet
print(greet("Ada"))
A package is a directory of modules. Importing runs the module top to bottom once, then caches it — import the same module ten times, its top-level code runs once.
💡 Tip: Keep modules focused — one clear responsibility each. "A file of loosely related stuff" is a smell; "a file about parsing logs" is a module.
The idiom in every Python script
You'll see this at the bottom of nearly every script:
def main():
print("Running the tool...")
if __name__ == "__main__":
main()
It means "only run main() when this file is executed directly, not when it's imported by another file." When you run python tool.py, Python sets the special variable __name__ to "__main__". When another file does import tool, __name__ is "tool" instead — so the guarded code stays quiet.
Why it matters: it lets a file be both a runnable program and an importable library of functions. That distinction becomes essential the moment you write tests (Chapter 7), which import your code to exercise its functions without triggering the whole script.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Classes & Objects
Bundling data and behaviour
Every program so far kept its data loose — an IP here, a count there. A class lets you bundle data and the behaviour that operates on it into one clean type. A class is a blueprint; from it you create instances (objects).
class Server:
def __init__(self, name, ip, cpu_load):
self.name = name
self.ip = ip
self.cpu_load = cpu_load
web1 = Server("web-01", "192.168.1.10", 0.45)
print(web1.name) # web-01
print(web1.cpu_load) # 0.45
__init__is the constructor — it runs automatically when you create an instance. The double underscores mark it a "dunder" (double-underscore) method.selfis the instance being built or operated on.self.name = namestores the value on this instance.
Behaviour that lives with the data
A function defined inside a class is a method. It reads and modifies the instance through self:
class Server:
def __init__(self, name, cpu_load):
self.name = name
self.cpu_load = cpu_load
def is_overloaded(self, threshold=0.85):
"""Return True if this server's load is above the threshold."""
return self.cpu_load > threshold
web2 = Server("web-02", 0.91)
print(web2.is_overloaded()) # True
🛑 Dev Callout: Where's
this? Why isselfexplicit?In C#/Java,
thisis implicit. In Python the instance is passed explicitly as the first parameter of every method, namedselfby convention. You writedef is_overloaded(self):and callweb2.is_overloaded()— Python wiresweb2intoselffor you. (Andself.nameis the field; a barenamewould be a local that vanishes when the method returns — the scope rule from Chapter 4.)
Why plain classes feel clunky
Watch what happens when you print or compare plain class instances:
web1 = Server("web-01", 0.45)
print(web1)
# <__main__.Server object at 0x7f3c1a2b8d90> ← useless
a = Server("web-01", 0.45)
b = Server("web-01", 0.45)
print(a == b) # False ← but they're identical!
By default, printing shows a memory address, and == checks whether two variables point at the same object, not whether their contents match. To fix both you'd hand-write two more dunder methods — __repr__ for a readable printout and __eq__ for value comparison — retyping every field name. For a class with eight fields, that's a lot of repetitive, error-prone typing.
There's a better way, and it's the whole point of the next lesson: dataclasses.
Each object is independent
Every instance carries its own copy of the data. Methods can change that state over time — this is what makes objects feel alive:
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
a = Counter()
b = Counter()
a.increment()
a.increment()
print(a.count, b.count) # 2 0 — independent state
a and b are separate objects; bumping a leaves b untouched. This is the core mental shift of OOP: you stop passing loose values around and start asking objects to manage their own data.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Dataclasses
Declare fields once, get the rest free
Python's standard library fixes the boilerplate with the @dataclass decorator. Declare your fields once with type hints, and Python generates __init__, __repr__, and __eq__ for you:
from dataclasses import dataclass
@dataclass
class Server:
name: str
ip: str
cpu_load: float
def is_overloaded(self, threshold=0.85):
return self.cpu_load > threshold
That's the entire class — no __init__, no self.name = name copying. Now everything just works:
a = Server("web-01", "192.168.1.10", 0.45)
b = Server("web-01", "192.168.1.10", 0.45)
print(a) # Server(name='web-01', ip='192.168.1.10', cpu_load=0.45)
print(a == b) # True — compared by value
The @dataclass line is a decorator — a function that wraps your class and adds capabilities. For now, just know it's doing the tedious typing on your behalf.
A familiar idea, less ceremony
🛑 Dev Callout: Dataclasses ≈ Records / Structs
A
@dataclassis Python's answer to a C#recordor a Javarecord(and close to a struct for plain data). Same goal: declare fields, get value-equality and a sensibleToString()/__repr__for free, skip the constructor boilerplate.One difference: a default
@dataclassis mutable — you can reassigna.cpu_load = 0.9. Want the immutability of a record? Use@dataclass(frozen=True)and reassignment raises an error — handy for values that should never change after creation.
Dataclasses are the right default for "a bag of related data with a few methods." Reach for them constantly.
Field defaults, and one you must not write
Dataclass fields can have defaults (which must come after non-default fields, same rule as functions):
from dataclasses import dataclass, field
@dataclass
class Server:
name: str
cpu_load: float = 0.0 # defaults to idle
tags: list = field(default_factory=list) # see the note below
⚠️ Warning: Never write
tags: list = []. A default value is created once and shared by every instance — a shared[]means every server secretly points at the same list; append to one and they all change.field(default_factory=list)builds a fresh empty list per instance.
(C#/Java folks: this is the classic "shared mutable default" footgun, and Python makes you opt out of it explicitly.)
Bringing it together
Model a small fleet and produce a status report. Notice how the comprehensions from Chapter 4 come back to do the filtering — objects and comprehensions pair beautifully.
Create fleet_monitor.py:
from dataclasses import dataclass
LOAD_THRESHOLD = 0.85
@dataclass
class Server:
name: str
ip: str
cpu_load: float
def is_overloaded(self):
return self.cpu_load > LOAD_THRESHOLD
def status_line(self):
"""A single formatted row for the report."""
icon = "⚠️ " if self.is_overloaded() else "✅"
label = "overloaded" if self.is_overloaded() else "healthy"
# :<8 left-pads the name; :<14 the IP; :.2f keeps the load tidy.
return f"{self.name:<8} {self.ip:<14} load {self.cpu_load:.2f} {icon} {label}"
def main():
fleet = [
Server("web-01", "192.168.1.10", 0.45),
Server("web-02", "192.168.1.11", 0.91),
Server("db-01", "192.168.1.20", 0.30),
]
print("--- Fleet Status ---")
for server in fleet:
print(server.status_line())
overloaded = [s for s in fleet if s.is_overloaded()] # comprehension over objects
print()
if overloaded:
names = ", ".join(s.name for s in overloaded)
print(f"[ALERT] {len(overloaded)} server over threshold: {names}")
else:
print("[OK] All servers within healthy limits.")
if __name__ == "__main__":
main()
Two things to notice: ", ".join(...) stitches names into a comma-separated list, and if __name__ == "__main__": (Chapter 5) means main() runs on direct execution but not on import — which is exactly what lets us test this file next.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
venv, pip & Live APIs
Isolation per project
Two projects on one machine: A needs v1.0 of a library, B needs v2.0. Install globally and they collide — "dependency hell." A virtual environment is an isolated, per-project Python with its own private packages.
🛑 Dev Callout:
venv+pip≈ Your Package Manager
pipis Python's installer — the rough equivalent of NuGet, Maven, or npm. Arequirements.txtis your.csproj<PackageReference>list /package.json. The different bit: isolation is directory-based and activated per shell.
python -m venv .venv # create it (venv ships with Python)
source .venv/bin/activate # activate — macOS / Linux
# .venv\Scripts\Activate.ps1 # activate — Windows PowerShell
Your prompt shows (.venv). Now python and pip point at the isolated environment. Type deactivate to leave. Add .venv/ to .gitignore — you commit the list of dependencies, not the packages.
pip and reproducible installs
With the environment active, install requests — the de-facto standard HTTP library, friendlier than the built-in urllib:
pip install requests
Freeze your dependencies so the project is reproducible:
pip freeze > requirements.txt
That writes every installed package and its exact version. Anyone (including future you) recreates the environment with one command:
pip install -r requirements.txt
💡 Tip: Commit
requirements.txt, never the.venv/folder. The list rebuilds the environment anywhere.
Talking to a REST API
requests makes a GET a one-liner. Wrap it in the error handling from Chapter 5 — the network is exactly the hostile, unreliable place try/except was built for:
import requests
try:
response = requests.get("https://api.github.com/users/octocat", timeout=10)
response.raise_for_status() # turn a 4xx/5xx into an exception
data = response.json() # parse JSON body into a Python dict
print(data["name"]) # The Octocat
except requests.exceptions.RequestException as e:
print(f"[ERROR] Request failed: {e}")
Three things worth calling out:
timeout=10— always set one. Without it, a hung server freezes your script forever. A missing timeout is one of the most common production bugs.raise_for_status()— by defaultrequestsdoes not throw on a 404/500; this converts a bad status into an exception yourexceptcan handle.response.json()— parses the JSON body straight into dicts and lists.
Never hardcode a key
Many APIs need a token. The rule that matters: never hardcode a secret in source. A key pasted into a .py file gets committed to git, pushed, and scraped by bots within minutes. The standard home for secrets is an environment variable:
import os
token = os.environ.get("GITHUB_TOKEN") # value, or None if unset — no crash
if token:
print("Token found.")
Set it in your shell before running:
export GITHUB_TOKEN="ghp_your_token_here" # macOS / Linux
# $env:GITHUB_TOKEN = "ghp_..." # Windows PowerShell
Because the secret lives in the environment, not the file, it never lands in git. (For local dev, many use a git-ignored .env file with the python-dotenv package — same principle.) This is exactly how we'll handle the AI key in the capstone.
Bringing it together
A script that fetches a user's public repos and reports on them — requests, error handling, an optional token, and the comprehensions and key= sorting from earlier.
Create repo_inspector.py (venv active, requests installed):
import os
import requests
API_BASE = "https://api.github.com"
def build_headers():
headers = {"Accept": "application/vnd.github+json"}
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def fetch_repos(username):
url = f"{API_BASE}/users/{username}/repos"
params = {"per_page": 100, "sort": "updated"}
response = requests.get(url, headers=build_headers(), params=params, timeout=10)
response.raise_for_status()
return response.json()
def main():
username = "octocat"
print(f"--- GitHub Repo Inspector: {username} ---")
try:
repos = fetch_repos(username)
except requests.exceptions.HTTPError as e:
code = e.response.status_code
msg = {404: "No such user.", 403: "Rate limited — set GITHUB_TOKEN."}.get(code, f"GitHub returned {code}.")
print(f"[ERROR] {msg}")
return
except requests.exceptions.RequestException as e:
print(f"[ERROR] Could not reach GitHub: {e}")
return
own = [r for r in repos if not r["fork"]] # comprehension
total_stars = sum(r["stargazers_count"] for r in own) # generator
top = sorted(own, key=lambda r: r["stargazers_count"], reverse=True)[:3]
print(f"Found {len(own)} non-fork repos, {total_stars} total stars.\n")
for rank, repo in enumerate(top, start=1):
lang = repo["language"] or "no language" # None or fallback (truthy)
print(f" {rank}. {repo['name']:<20} ⭐ {repo['stargazers_count']:<6} ({lang})")
if __name__ == "__main__":
main()
You're now pulling live data off the internet and reporting on it — with graceful handling for a missing user, a rate limit, and a dead connection. That repo["language"] or "no language" leans on Chapter 2's truthy/falsy rule.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Type Hints & mypy
Optional, checkable documentation
Python is dynamically typed — you never have to declare types. But since 3.5 you can, optionally, annotate them. These annotations are type hints:
def greet(name: str) -> str:
return f"Hello, {name}"
attempts: int = 0
ratio: float = 0.85
Read name: str as "name is expected to be a string," and -> str as "this function returns a string." For collections and optional values, the modern syntax (3.10+) is clean:
def total(numbers: list[int]) -> int:
return sum(numbers)
# str | None means "a string, or None" — the value might be missing:
def find_language(repo: dict) -> str | None:
return repo.get("language")
Hints don't run
🛑 Dev Callout: Gradual Typing — Hints Don't Run
In C#/Java, types are enforced by the compiler; violating code won't build. Python's hints are not enforced at runtime — the interpreter ignores them entirely. This runs without complaint:
def total(numbers: list[int]) -> int: return sum(numbers) total("not a list at all") # Python runs this — the hint is just a note
This is gradual typing: add hints where they help, leave them off where they don't, mix freely. Hints become useful in two ways — your editor uses them for autocomplete and inline warnings, and a separate tool called mypy reads them to catch mismatches before you run. Think of hints as checkable documentation: comments a tool can verify never went stale.
A static type checker
mypy reads your hints and flags contradicting code — no execution required. Install it into your virtual environment:
pip install mypy
Point it at a file:
mypy repo_stats.py
If you wrote total("not a list"), mypy tells you before you ever run it:
error: Argument 1 to "total" has incompatible type "str"; expected "list[int]"
That's a whole class of bug — "I passed the wrong thing" — caught at your desk instead of in production. A clean run prints:
Success: no issues found in 1 source file
💡 Tip: You don't have to type everything at once. Add hints to your public functions first — that's where the payoff is highest.
The payoff
If hints don't run, why add them? Three concrete wins:
- Editor superpowers. Autocomplete knows what a value is, so it suggests the right methods and flags typos as you type.
- Refactoring confidence. Change a function's signature and
mypyshows every call site that no longer fits — instantly. - Living documentation.
def fetch(url: str) -> dict:tells the next reader exactly what goes in and comes out, and the checker guarantees the doc never drifts from reality.
The sweet spot: type your function boundaries (parameters and return values) and the data structures that flow between modules. Leave throwaway locals alone. That's the discipline that turns a script into software — and it sets up the testing in the next lesson, because typed, pure functions are the easiest things in the world to test.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Testing with pytest
Script vs software
There's a line every project crosses — the one separating a script (works on my machine, today, if I squint) from software (works reliably, survives refactors, can be changed without fear). Automated tests get you across it.
Picture the GitHub script from Chapter 6. How would you know if a refactor silently broke the star-counting logic? You'd run it, eyeball the output, and hope. That doesn't scale. Tests turn "I think it works" into "I can prove it works, in 0.03 seconds."
The key technique — and the thing that makes a network-touching script testable at all — is separating pure logic from side effects.
Pure functions
The problem with testing a function that calls a live API: a test that depends on the network is slow, flaky, and fails when GitHub has a bad day — that's testing GitHub, not your code.
The fix is a design principle: separate the part that talks to the world from the part that thinks. The I/O function (the HTTP call) stays thin. The functions that compute things should be pure — same input, same output, no network, no files, no surprises:
from dataclasses import dataclass
@dataclass
class Repo:
name: str
stars: int
is_fork: bool
def non_forks(repos: list[Repo]) -> list[Repo]:
return [r for r in repos if not r.is_fork]
def total_stars(repos: list[Repo]) -> int:
return sum(r.stars for r in repos)
Pure functions are trivial to test — you hand them data and check what comes back. (This is also why Chapter 4 pushed return over print.)
pytest conventions
pytest is the tool the community standardized on. Install it, then follow three refreshingly minimal conventions:
pip install pytest
- Put tests in files named
test_*.py. - Write each test as a function named
test_*. - Use a plain
assertto check expectations — no special methods to memorize.
from repo_stats import Repo, non_forks, total_stars
def test_non_forks_filters_out_forks():
repos = [Repo("a", 10, False), Repo("b", 5, True), Repo("c", 8, False)]
result = non_forks(repos)
assert len(result) == 2
assert all(not r.is_fork for r in result)
def test_total_stars_sums_correctly():
assert total_stars([Repo("a", 10, False), Repo("b", 5, False)]) == 15
def test_total_stars_of_empty_list_is_zero():
assert total_stars([]) == 0 # edge cases are where bugs hide
pytest -q
... [100%]
3 passed in 0.02s
Each green dot is a passing test. Break the code on purpose and pytest fails loudly, telling you exactly which assertion broke and what it expected.
parametrize
When you want the same test logic over several inputs, copy-pasting test functions is wasteful. pytest's parametrize decorator runs one test once per row of data:
import pytest
from repo_stats import Repo, total_stars
@pytest.mark.parametrize("stars, expected", [
([], 0),
([10], 10),
([10, 20, 30], 60),
])
def test_total_stars_cases(stars, expected):
repos = [Repo(f"repo{i}", s, False) for i, s in enumerate(stars)]
assert total_stars(repos) == expected
Three distinct cases — empty, single, several — from one function body. pytest reports each row separately, so a failure points straight at the input that broke.
The payoff: green means go
With mypy green and pytest green, you can refactor fearlessly — the tools tell you the instant something slips.
A few habits that compound:
- Test the edges. Empty input, zero, one item, the maximum — that's where bugs hide.
- Test behaviour, not implementation. Assert what a function returns, not how it computes it, so tests survive refactors.
- Keep I/O at the edges. The network call lives in one thin function; everything it feeds is pure and covered.
This is the "type safety + unit tests on your API script" the course promised: the network-touching part stays thin, the logic is pure, typed, and covered. That instant, trustworthy feedback is the entire point of testing — and the foundation under the capstone you're about to build.
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
Capstone: The AI CLI Tool
The one we've been building toward
Across the course you've collected every piece you need; now we assemble them into a tool you'd actually keep around: ai_tool — a command-line program that connects to an AI API and either summarizes text or reviews code. Point it at a file, tell it what to do, and it streams back the answer.
Look at how much of the course this pulls in:
- a virtual environment and a third-party SDK installed with
pip(Ch. 6) - an API key kept safe in an environment variable (Ch. 6)
- functions with single, clear jobs (Ch. 4)
try/exceptaround everything that can fail (Ch. 5)pathlibfor reading the input file (Ch. 5)- type hints so the code documents itself (Ch. 7)
- a
MODESdictionary driving behaviour (Ch. 3) - plus one new standard-library tool:
argparse, for command-line arguments
argparse: the shape of a CLI
A good command-line tool reads its instructions from the command line, not hardcoded values. The standard library ships argparse — it parses arguments, validates them, and generates a --help screen for free:
import argparse
parser = argparse.ArgumentParser(description="Summarize text or review code with AI.")
parser.add_argument("mode", choices=["summarize", "review"], help="What to do.")
parser.add_argument("file", help="Path to the input file, or - for stdin.")
args = parser.parse_args()
print(args.mode, args.file)
Run python ai_tool.py --help and argparse prints usage automatically. Pass an invalid mode and choices= rejects it with a clear message — validation you'd otherwise hand-roll.
File or stdin, one typed function
We accept either a file path or piped input (so cat notes.txt | python ai_tool.py summarize - works). One small, typed function handles both, using pathlib from Chapter 5:
import sys
from pathlib import Path
def read_input(source: str) -> str:
"""Read text from a file path, or from stdin if source is '-'."""
if source == "-":
return sys.stdin.read()
path = Path(source)
if not path.is_file():
raise FileNotFoundError(f"No such file: {source}")
return path.read_text(encoding="utf-8")
It returns a string or raises FileNotFoundError — and because it's a small, pure-ish function, you could test it exactly like the ones in Chapter 7.
The SDK, the key, and streaming
Install the official Anthropic SDK (venv active), then put your key in the environment — never in the code:
pip install anthropic
pip freeze > requirements.txt
export ANTHROPIC_API_KEY="sk-ant-..." # Windows: $env:ANTHROPIC_API_KEY = "..."
A request is one method call: which model, the conversation (a list of messages), and an optional system prompt that sets the model's role. For a CLI, streaming prints the reply token by token — the SDK gives you a context manager, just like file handling:
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=1024,
system="You are a precise summarizer.",
messages=[{"role": "user", "content": text}],
) as stream:
for chunk in stream.text_stream:
print(chunk, end="", flush=True) # flush forces each chunk out immediately
🛑 Dev Callout: The Model Is a (Mostly) Stateless Function
Think of the API like a function call, not a chat that remembers you. Each request is independent — the model retains nothing between calls; to give a follow-up context you resend the whole conversation. Two twists vs. the pure functions of Chapter 7: the same prompt can produce slightly different wording each time, and you pay per token both ways. The
systemprompt is your config knob — change behaviour by editing a string, not rewriting logic.
Bringing it all together
Save as ai_tool.py. You'll recognize every technique from the previous lessons:
import argparse
import os
import sys
from pathlib import Path
import anthropic
MODEL = "claude-opus-4-8"
# A dictionary maps each mode to its system prompt and reply cap.
# Adding a mode later is one new entry here — no logic to touch (Ch. 3).
MODES = {
"summarize": {
"system": ("You are a precise summarizer. Produce a one-sentence TL;DR, then "
"3-5 bullet points. Use only information present in the text."),
"max_tokens": 1024,
},
"review": {
"system": ("You are a constructive code reviewer. List concrete issues ordered "
"by severity — bugs and security first, then clarity. Reference "
"specific lines. If the code is solid, say so plainly."),
"max_tokens": 4096,
},
}
def read_input(source: str) -> str:
"""Read text from a file path, or from stdin if source is '-'."""
if source == "-":
return sys.stdin.read()
path = Path(source)
if not path.is_file():
raise FileNotFoundError(f"No such file: {source}")
return path.read_text(encoding="utf-8")
def run(mode: str, text: str) -> None:
"""Send the text to Claude in the given mode and stream the reply."""
config = MODES[mode]
client = anthropic.Anthropic()
with client.messages.stream(
model=MODEL,
max_tokens=config["max_tokens"],
system=config["system"],
messages=[{"role": "user", "content": text}],
) as stream:
for chunk in stream.text_stream:
print(chunk, end="", flush=True)
print()
def main() -> None:
parser = argparse.ArgumentParser(description="Summarize text or review code using Claude.")
parser.add_argument("mode", choices=MODES.keys(), help="What to do with the input.")
parser.add_argument("file", help="Path to the input file, or - to read stdin.")
args = parser.parse_args()
if not os.environ.get("ANTHROPIC_API_KEY"):
print("[ERROR] ANTHROPIC_API_KEY is not set.", file=sys.stderr)
sys.exit(1)
try:
text = read_input(args.file)
except (FileNotFoundError, OSError) as e:
print(f"[ERROR] {e}", file=sys.stderr)
sys.exit(1)
if not text.strip():
print("[ERROR] The input is empty — nothing to do.", file=sys.stderr)
sys.exit(1)
label = "stdin" if args.file == "-" else args.file
print(f"--- {args.mode.title()} {label} ---\n")
try:
run(args.mode, text)
except anthropic.AuthenticationError:
print("\n[ERROR] Invalid API key.", file=sys.stderr); sys.exit(1)
except anthropic.RateLimitError:
print("\n[ERROR] Rate limited. Try again shortly.", file=sys.stderr); sys.exit(1)
except anthropic.APIError as e:
print(f"\n[ERROR] The API call failed: {e}", file=sys.stderr); sys.exit(1)
if __name__ == "__main__":
main()
python ai_tool.py summarize article.txt
python ai_tool.py review calculator.py
cat notes.txt | python ai_tool.py summarize -
You just built a working AI tool. Ten lessons ago, input() returning a string was a surprise.
Extend it
The architecture is deliberately easy to grow, in rough order of effort:
- Add a mode. Want "translate" or "explain-like-I'm-five"? Add one entry to
MODESwith its own system prompt. No other code changes — the payoff of driving behaviour from data (Ch. 3) instead of branching logic. - Add a flag. Use
argparseto add--model(pick a faster, cheaper model) or--max-tokens. - Save the output. Write the result to a file with
pathlib(Ch. 5) when--out report.mdis given. - Test the pure parts.
read_inputand theMODESlookup are testable without ever calling the API — writepytestcases (Ch. 7), keeping the network call isolated inrun().
You started by installing an interpreter and printing a receipt; you're finishing with a typed, error-handled CLI that streams live AI responses from a real API. More than any single library, you've built the instincts. Now go make something. 🐍
Part of the Pragmatic Python Bootcamp — write real tools, the right way.
The End of the Beginning
You did it
You started by installing an interpreter and printing a receipt; you're finishing with a typed, tested, error-handled CLI that streams live AI responses from a real API.
More importantly, you've built the instincts:
- Cast your inputs at the boundary.
- Let the data structure do the work — the right one turns a crawl into something instant.
returnvalues instead ofprint-ing them, so other code (and your tests) can use them.- Catch only the exceptions you expect; let real bugs fail loudly.
- Isolate the messy I/O from the pure logic you can test.
- Keep your secrets out of your source.
Those habits outlast any single library.
Where to go next
The language under every Python domain is the one you now know:
- Web backends — FastAPI, Django
- Data & ML — pandas, polars, scikit-learn, PyTorch
- Automation & scripting — the everyday superpower
- AI tooling & agents — pick up where the capstone left off
Pick a project you actually care about and build it. That's how the rest of it sticks.
Thanks for coding along. Now go make something. 🐍
Frequently Asked Questions
Who is the Pragmatic Python Bootcamp for?
It's built for two audiences at once: absolute beginners with zero coding background, and developers moving over from C#, Java, or another statically-typed language. Beginners work through all 22 lessons top to bottom, while experienced developers can skim the fundamentals and lean on the Dev Callout boxes that map new Python behavior onto what they already know.
Do I need any prior programming experience to start?
No — the course assumes zero background and opens with installing Python and printing your first line of output. Its 22 lessons build directly on one another, and each ends with a quiz you must score at least 67% on before the next lesson unlocks, so gaps in understanding get caught early.
What will I actually build during the course?
Nine hands-on projects across 22 lessons: a tip calculator, a number-guessing game, an inventory system, a security analyzer, a log analyzer, a log cleanser, a server-fleet monitor, a live GitHub inspector, and a capstone: a typed, tested, AI-powered command-line tool that streams real API responses.
How long does the course take to complete?
The full curriculum runs about 9.5 hours of lesson content across 22 lessons in 7 chapters, plus 88 quiz questions to check understanding along the way. Individual lessons run 20 to 40 minutes each, so a full chapter comfortably fits into a single sitting.
How is the course structured, and how do the quizzes work?
Content is organized into 7 chapters and 22 lessons, each ending in a short multiple-choice quiz. The course settings require a score of at least 67% to unlock the next lesson — a fixed threshold, not just a suggestion — which keeps you from advancing past material you haven't actually understood.
What is a 'Dev Callout,' and do I have to be a total beginner to benefit from this course?
No — a Dev Callout is a highlighted box placed throughout the lessons specifically for developers coming from C#, Java, or similar typed languages. It reframes unfamiliar Python behavior, like dynamic typing, indentation-based blocks, and scope, as a direct comparison to what those developers already know, so they don't have to sit through beginner explanations.
What does the final capstone project involve?
The capstone is a typed, tested, AI-powered command-line tool that streams live responses from a real AI API. It draws on the whole course: type hints checked with mypy, automated tests written in pytest, proper error handling, and a clean separation between I/O and the logic that tests can actually exercise.
Does the course cover professional practices like testing and type checking, or just the basics?
Both. The first six chapters build core fundamentals — syntax, data structures, functions, error handling, files, and object-oriented programming — while the seventh chapter, 'Professional Python,' is dedicated to type hints with mypy and automated testing with pytest, before all of it comes together in the capstone AI CLI project.