You built 5 AI products without knowing how to code. That's remarkable. Now it's time to understand *what's actually happening* under the hood β so you can debug faster, ask AI better questions, and eventually write your own logic from scratch.
This curriculum is written for you specifically: a smart person with zero programming background who happens to already work with conditional logic every day (SpO2 below 90 β increase FiO2 β that's an if/else statement).
---
Time per lesson: ~30 minutes (15 min reading + 15 min exercises)
Tool you need: A terminal with Python 3 installed
To open Python's interactive REPL (Read-Eval-Print Loop β basically a Python scratch pad you type into):
python3
You'll see >>> β that's Python waiting for you. Type code, hit Enter, see the result instantly. To exit: exit() and Enter.
Rule: Type every example yourself. Don't copy-paste. Your fingers need to learn the syntax.
---
| # | File | Key concepts |
| --- | ------ | ------------- |
| 01 | [01-your-first-program.md](01-your-first-program.md) | `print`, variables, strings, `input`, f-strings |
| 02 | [02-numbers-and-math.md](02-numbers-and-math.md) | `int`, `float`, arithmetic, type conversion |
| 03 | [03-making-decisions.md](03-making-decisions.md) | `if/elif/else`, comparisons, booleans, `and/or/not` |
| 04 | [04-loops.md](04-loops.md) | `for`, `while`, `break`, `continue`, `range()` |
| 05 | [05-organizing-data.md](05-organizing-data.md) | Lists, dictionaries, tuples, indexing |
| 06 | [06-functions.md](06-functions.md) | Defining functions, parameters, `return`, scope |
| 07 | [07-working-with-text.md](07-working-with-text.md) | String methods, `.split()`, `.join()`, formatting |
| 08 | [08-reading-and-writing-files.md](08-reading-and-writing-files.md) | `open()`, `with`, CSV, JSON |
| 09 | [09-real-scripts.md](09-real-scripts.md) | `os`, `shutil`, `glob`, `subprocess`, `datetime` |
| 10 | [10-your-first-real-script.md](10-your-first-real-script.md) | Full project: read β process β write β report |
---
Python errors are not personal attacks. They look scary at first:
TypeError: can only concatenate str (not "int") to str
But they're helpful. Python is telling you: "You tried to combine text and a number." Once you can read errors, they become your fastest debugging tool.
---
When you finish all 10 lessons, the intermediate series covers error handling, classes, APIs, async, and deployment β the patterns behind the AI scripts you've been running.
Go to Lesson 01 β
Python is a language you use to give instructions to your computer. That's it.
Your computer is incredibly fast and literally does exactly what you tell it β but it doesn't understand English. Python is a middle ground: it looks almost like English, and there's a program (called the interpreter) that translates your Python into instructions the computer can run.
When you've been asking AI to write code and copy-pasting it into scripts β you've been writing Python. Now you're going to understand what it's actually saying.
---
Open a terminal and type:
python3
You'll see something like:
Python 3.11.4 (...)
>>>
That >>> means Python is waiting for you to type something. This is called the REPL (Read-Eval-Print Loop). It reads what you type, runs it, and prints the result. Perfect for experimenting.
To run a file instead, you do:
python3 my_file.py
---
Type this in the REPL and press Enter:
print("Hello, world!")
You'll see:
Hello, world!
What just happened?
---
A variable is just a name you give to a piece of information so you can use it later.
name = "Ron"
print(name)
Output:
Ron
The = sign doesn't mean "equals" like in math. It means "store this on the right side and call it by the name on the left."
Think of it like a labeled box:
You can change what's in the box:
name = "Ron"
print(name)
name = "Ron Sublett"
print(name)
Output:
Ron
Ron Sublett
The box just got updated. The old value is gone.
---
A string is any text between quotes. Single quotes or double quotes both work:
first = 'Ron'
last = "Sublett"
You can combine strings with +:
first = "Ron"
last = "Sublett"
full_name = first + " " + last
print(full_name)
Output:
Ron Sublett
The " " in the middle adds a space. Computers don't add spaces automatically β you have to be explicit.
---
Gluing strings with + gets messy fast. Python has a cleaner way called an f-string (the f stands for "formatted"):
first = "Ron"
job = "respiratory therapist"
print(f"My name is {first} and I am a {job}.")
Output:
My name is Ron and I am a respiratory therapist.
How it works: Put an f before the opening quote. Then anywhere you write {variable_name}, Python replaces it with the actual value. Much more readable than +.
---
You can write notes in your code that Python completely ignores. Start the line with #:
# This prints a greeting
print("Hello!")
# The next line stores a name
name = "Ron"
Comments are for humans reading the code β not for the computer. Use them to explain *why* you're doing something.
---
Create a file called hello.py and put this in it:
# Ask for the user's name
name = input("What is your name? ")
# Build a greeting
greeting = f"Hello, {name}! Welcome to Python."
# Print it
print(greeting)
Run it with: python3 hello.py
New thing: input()
input() is a function that pauses and waits for the user to type something. Whatever they type gets stored in the variable you assign it to. The text in quotes ("What is your name? ") is the prompt shown to them.
---
1. Make a script that stores your name, your job title, and your city in three variables, then prints a sentence using all three.
2. Change your script to ask the user for their name with input() instead of hardcoding it.
3. Try this: what happens if you forget the quotes around text? Type print(hello) in the REPL and see the error. Read the error message β Python tells you what went wrong.
---
| Concept | What it does |
| --------- | ------------- |
| `print()` | Displays something on screen |
| `"text"` | A string β Python's word for text |
| `variable = value` | Store a value and give it a name |
| `+` | Combine strings |
| `f"...{var}..."` | Insert a variable into text (f-string) |
| `input()` | Ask the user to type something |
| `#` | A comment β Python ignores this line |
---
Next lesson: Numbers and math β how Python handles arithmetic, and why 10 / 3 gives you 3.3333... instead of 3.
Python separates numbers into two types. This matters, and here's why:
Integers (int) β whole numbers. No decimal point.
patients = 12
age = 47
beds = 0
Floats (float) β numbers with a decimal point.
temperature = 98.6
oxygen_saturation = 94.5
price = 19.99
Why does Python care? Because computers store them differently in memory, and some operations behave differently depending on which type you're using.
---
print(10 + 3) # 13 (addition)
print(10 - 3) # 7 (subtraction)
print(10 * 3) # 30 (multiplication)
print(10 / 3) # 3.3333... (division)
print(10 ** 2) # 100 (exponent β "10 to the power of 2")
print(10 % 3) # 1 (remainder after division β called "modulo")
Notice: * is multiply (not x), and ** is "to the power of" (not ^). This trips everyone up at first.
---
In Python 3, division always gives you a float, even if you're dividing two integers.
print(10 / 2) # 5.0 (not 5 β it's a float)
print(7 / 2) # 3.5
This is actually the correct, sensible behavior. Earlier versions of Python would give you 3 for 7 / 2, which caused real bugs in real programs.
If you specifically want whole-number division (drop the remainder):
print(7 // 2) # 3 (floor division β rounds down)
---
When you do math with an int and a float together, Python automatically converts the result to a float:
print(5 + 1.0) # 6.0 (float)
print(3 * 2) # 6 (int)
print(3 * 2.0) # 6.0 (float)
---
heart_rate = 72
resting_rate = 60
difference = heart_rate - resting_rate
print(f"Difference from resting: {difference} bpm")
You can do the math right inside the variable assignment. The right side gets evaluated first, then stored.
---
A pattern you'll see constantly:
count = 0
count = count + 1
print(count) # 1
Read the second line as: "Take the current value of count, add 1 to it, and store the result back in count."
This is so common that Python has a shortcut:
count = 0
count += 1 # same as: count = count + 1
count += 1
print(count) # 2
Other shortcuts: -=, *=, /=
---
Sometimes you get a number as text (like from input()), and you need to do math with it. You have to convert it first.
age_text = input("How old are you? ")
# age_text is a string like "47" β not the number 47
age_number = int(age_text) # convert string to integer
years_to_100 = 100 - age_number
print(f"You have {years_to_100} years until 100.")
If you skip the conversion, Python will complain:
TypeError: unsupported operand type(s) for -: 'int' and 'str'
That error means "you tried to do math on text." You have to convert it first.
Conversion functions:
---
Python follows the same order you learned in school (PEMDAS):
print(2 + 3 * 4) # 14 (multiplication first)
print((2 + 3) * 4) # 20 (parentheses first)
When in doubt, use parentheses. They make your intent clear and prevent bugs.
---
pi = 3.14159265
print(round(pi)) # 3 (round to nearest integer)
print(round(pi, 2)) # 3.14 (round to 2 decimal places)
---
weight_lbs = float(input("Weight in pounds: "))
height_inches = float(input("Height in inches: "))
# Convert to metric
weight_kg = weight_lbs * 0.453592
height_m = height_inches * 0.0254
# Calculate BMI
bmi = weight_kg / (height_m ** 2)
print(f"Your BMI is {round(bmi, 1)}")
This is real code doing real math. Every line is something you now understand.
---
1. Write a script that converts Fahrenheit to Celsius. Formula: C = (F - 32) * 5 / 9
2. Write a script that asks for a patient's oxygen saturation and tells them the difference from the target of 95%.
3. What does 17 % 5 give you? Why? (Hint: how many times does 5 go into 17, and what's left over?)
---
| Concept | Example |
| --------- | --------- |
| Integer | `42` |
| Float | `3.14` |
| Division always returns float | `6 / 2` β `3.0` |
| Floor division | `7 // 2` β `3` |
| Modulo (remainder) | `7 % 3` β `1` |
| Increment shortcut | `count += 1` |
| Type conversion | `int("42")`, `float("3.14")` |
| Rounding | `round(3.14159, 2)` β `3.14` |
---
Next lesson: Making decisions β how to write code that does different things depending on what's true.
Most useful programs don't do the same thing every time. They look at a situation and respond differently based on what's true.
In English: "If the patient's oxygen saturation is below 90%, flag it as critical."
In Python, that's almost exactly what you write.
---
spo2 = 88
if spo2 < 90:
print("CRITICAL: Low oxygen saturation")
Breaking it down:
Indentation is not optional. Python uses indentation to know which code belongs to the if block. This is different from most languages and it surprises people. If the spacing is wrong, you'll get an error.
---
spo2 = 95
if spo2 < 90:
print("CRITICAL: Low oxygen saturation")
else:
print("Oxygen saturation is acceptable")
else is "otherwise" β if the condition was NOT true, do this instead. Exactly one of the two blocks will run.
---
spo2 = 91
if spo2 < 88:
print("CRITICAL")
elif spo2 < 92:
print("LOW β monitor closely")
elif spo2 < 95:
print("BORDERLINE")
else:
print("Normal")
elif is short for "else if." Python checks each condition from top to bottom and runs the first one that's true. Once it finds a match, it skips the rest.
Think of it like a triage checklist: is it this? No. Is it that? Yes β do this, and stop checking.
---
A boolean is a value that is either True or False. That's it. Two possible values.
is_critical = True
is_stable = False
print(is_critical) # True
print(is_stable) # False
When you write spo2 < 90, Python evaluates that and gets either True or False. You can store that result:
spo2 = 88
is_low = spo2 < 90
print(is_low) # True
---
These all return True or False:
10 > 5 # True β greater than
10 < 5 # False β less than
10 >= 10 # True β greater than OR equal to
10 <= 9 # False β less than OR equal to
10 == 10 # True β equal to (NOTE: two equals signs)
10 != 5 # True β NOT equal to
Critical point: = assigns a value. == checks if two things are equal. This is one of the most common beginner mistakes:
name = "Ron" # stores "Ron" in name
name == "Ron" # True β checks if name equals "Ron"
---
and β both conditions must be true:
age = 65
spo2 = 87
if age > 60 and spo2 < 90:
print("High-risk patient with low oxygen")
or β at least one condition must be true:
heart_rate = 110
if heart_rate < 60 or heart_rate > 100:
print("Abnormal heart rate")
not β flips True to False and vice versa:
is_intubated = False
if not is_intubated:
print("Patient is breathing independently")
---
critical_conditions = ["pneumonia", "ARDS", "respiratory failure"]
diagnosis = "ARDS"
if diagnosis in critical_conditions:
print("This is a critical diagnosis")
in checks whether a value exists in a collection. Very readable, very useful.
---
name = input("Patient name: ")
spo2 = int(input("SpO2 (%): "))
respiratory_rate = int(input("Respiratory rate (breaths/min): "))
if spo2 < 88 or respiratory_rate > 30:
status = "CRITICAL β immediate intervention needed"
elif spo2 < 92 or respiratory_rate > 24:
status = "HIGH CONCERN β close monitoring required"
elif spo2 < 95 or respiratory_rate > 20:
status = "BORDERLINE β continue monitoring"
else:
status = "Stable"
print(f"\nPatient: {name}")
print(f"Status: {status}")
This is real decision logic. It's exactly what a more complex triage tool would use.
---
Python considers some values as automatically false even if they're not the boolean False:
So this works:
name = ""
if name:
print(f"Hello, {name}")
else:
print("No name provided")
if name: checks whether name has any content. An empty string is "falsy" β it counts as False.
You'll see this pattern constantly in real code.
---
1. Write a script that asks for a temperature in Fahrenheit and prints:
2. Ask for someone's age and print whether they qualify for a senior discount (65+).
3. Ask for two numbers and print which one is larger, or "They're equal" if they're the same.
---
| Concept | What it does |
| --------- | ------------- |
| `if condition:` | Run this block only if true |
| `else:` | Run this block if the if was false |
| `elif condition:` | Check another condition if the previous was false |
| `True` / `False` | Boolean values |
| `==` | Check equality (not assignment) |
| `and`, `or`, `not` | Combine or flip conditions |
| `in` | Check if something is in a collection |
---
Next lesson: Loops β how to make Python repeat an action without copy-pasting the same code over and over.
Imagine you had to manually check the SpO2 of 50 patients. One by one. Same steps, every time. That's exactly the kind of thing computers were invented to eliminate.
A loop tells Python: "Do this action repeatedly β for each item in a list, or until a condition changes."
This is where programming gets genuinely powerful.
---
Before loops, you need a list β a collection of values stored in one variable.
>>> spo2_readings = [88, 92, 95, 84, 91]
>>> patients = ["Johnson", "Martinez", "Williams", "Brown"]
>>> fio2_levels = [0.21, 0.40, 0.60, 0.80]
Square brackets [] create the list. Items are separated by commas. A list can hold numbers, text, or anything else.
You'll learn more about lists in Lesson 05. For now, just know they exist and hold multiple values.
---
>>> patients = ["Johnson", "Martinez", "Williams"]
>>> for patient in patients:
... print("Checking patient: " + patient)
...
Checking patient: Johnson
Checking patient: Martinez
Checking patient: Williams
How to read this: "For each patient in the patients list, print a line."
Python grabs each item from the list one at a time, stores it in patient (the loop variable), runs the indented code, then moves to the next item.
The loop variable name (patient) is something you choose. It could be name, p, x β anything. But use something readable.
---
range() generates a sequence of numbers without making you type them all:
>>> for i in range(5):
... print(i)
...
0
1
2
3
4
range(5) gives you 0, 1, 2, 3, 4. (Starts at 0, goes up to but not including 5.)
Starting from a different number:
>>> for i in range(1, 6):
... print(i)
...
1
2
3
4
5
Counting by 5s (or any step):
>>> for i in range(0, 20, 5):
... print(i)
...
0
5
10
15
Think of range(start, stop, step).
---
>>> weights_kg = [0.85, 1.2, 3.4, 2.1]
>>> for weight in weights_kg:
... weight_lbs = weight * 2.205
... print(str(weight) + " kg = " + str(round(weight_lbs, 2)) + " lbs")
...
0.85 kg = 1.87 lbs
1.2 kg = 2.65 lbs
3.4 kg = 7.5 lbs
2.1 kg = 4.63 lbs
You just did 4 weight conversions in 3 lines. Imagine 400. Still 3 lines.
---
This is where things click:
>>> spo2_readings = [96, 84, 91, 79, 93]
>>> for reading in spo2_readings:
... if reading < 88:
... print("ALARM: SpO2 " + str(reading) + "% β too low!")
... else:
... print("OK: SpO2 " + str(reading) + "%")
...
OK: SpO2 96%
ALARM: SpO2 84% β too low!
OK: SpO2 91%
ALARM: SpO2 79% β too low!
OK: SpO2 93%
You just wrote a protocol scanner. For every reading, apply the decision rule.
---
A common pattern: start with zero, add to it inside the loop.
>>> readings = [88, 92, 95, 84, 91]
>>> total = 0
>>> for reading in readings:
... total = total + reading
...
>>> average = total / len(readings)
>>> print("Average SpO2: " + str(round(average, 1)))
Average SpO2: 90.0
len(readings) gives you 5 (there are 5 items in the list).
---
A for loop runs a set number of times. A while loop keeps going as long as something is True:
>>> fio2 = 0.21
>>> spo2 = 84
>>> while spo2 < 88:
... fio2 += 0.05
... spo2 += 3 # pretend adding FiO2 improves SpO2
... print("FiO2 now " + str(round(fio2, 2)) + ", SpO2 now " + str(spo2))
...
FiO2 now 0.26, SpO2 now 87
FiO2 now 0.31, SpO2 now 90
It ran until SpO2 reached 88 or above.
Warning: Infinite Loops. If the condition never becomes False, the loop runs forever:
while True:
print("Stuck!") # This never stops β press Ctrl+C to escape
When using while, always make sure something inside the loop will eventually make the condition False.
---
>>> for reading in [96, 92, 84, 91, 80]:
... print("Checking " + str(reading))
... if reading < 85:
... print("Critical value β stopping scan")
... break
...
Checking 96
Checking 92
Checking 84
Critical value β stopping scan
break exits the loop immediately, even if there are items left.
---
>>> for reading in [96, 92, 0, 91, 80]:
... if reading == 0:
... continue # skip this one β bad data
... print("Valid reading: " + str(reading))
...
Valid reading: 96
Valid reading: 92
Valid reading: 91
Valid reading: 80
continue skips the rest of the current iteration and jumps to the next one.
---
| Concept | What It Is | Example |
| --------- | ----------- | --------- |
| List | Collection of values | `[88, 92, 95]` |
| `for` loop | Repeat for each item | `for x in list:` |
| `range()` | Generate number sequences | `range(1, 11)` |
| `while` loop | Repeat until condition changes | `while spo2 < 88:` |
| `len()` | Count items in a list | `len(readings)` |
| `break` | Exit the loop early | `break` |
| `continue` | Skip this iteration | `continue` |
---
The moment you understand loops, you understand why AI agents are powerful. An agent that processes 1,000 products is just a for loop: for product in products: check_price(product). An agent that keeps retrying until it gets a response is a while loop. This is all the same logic.
---
Exercise 1: Print every patient name
patients = ["Johnson", "Martinez", "Williams", "Brown", "Chen"]
for patient in patients:
print("Patient: " + patient)
Exercise 2: Count down from 10
for i in range(10, 0, -1):
print(i)
print("Launch!")
Exercise 3: Triage all SpO2 readings
readings = [96, 84, 91, 79, 93, 88, 72, 95]
for reading in readings:
if reading < 80:
print("CRITICAL: " + str(reading) + "% β immediate action")
elif reading < 88:
print("LOW: " + str(reading) + "% β increase support")
else:
print("OK: " + str(reading) + "%")
Exercise 4: Average a list of weights
weights = [0.85, 1.2, 3.4, 2.1, 1.8]
total = 0
for w in weights:
total += w
average = total / len(weights)
print("Average weight: " + str(round(average, 2)) + " kg")
Exercise 5: While loop β wean FiO2
fio2 = 1.0
print("Starting wean from FiO2 " + str(fio2))
while fio2 > 0.40:
fio2 = round(fio2 - 0.10, 2)
print("Weaned to FiO2: " + str(fio2))
print("Target reached: FiO2 is now " + str(fio2))
---
Forgetting the colon:
for patient in patients # Error
for patient in patients: # Correct
Wrong indentation β code runs outside the loop:
for i in range(5):
total += i
print(i) # outside the loop if at this indentation level
Infinite while loop β forgot to change the condition:
count = 0
while count < 5:
print(count)
# forgot: count += 1 β this never stops!
---
So far you've stored one thing in one variable:
patient1_name = "Alice"
patient1_spo2 = 97
patient2_name = "Bob"
patient2_spo2 = 85
This falls apart instantly when you have 50 patients. Python has built-in ways to group related data together. You've seen these in the code you've copied β now you'll understand what they actually are.
---
A list holds multiple values in a specific order. Use square brackets [].
patients = ["Alice", "Bob", "Carlos", "Dana"]
readings = [97, 85, 93, 87]
Lists can hold any type β strings, numbers, even other lists.
patients = ["Alice", "Bob", "Carlos"]
print(patients[0]) # Alice (first item β index 0)
print(patients[1]) # Bob (second item β index 1)
print(patients[2]) # Carlos (third item β index 2)
print(patients[-1]) # Carlos (last item β negative index counts from end)
Zero-indexing again: The first item is at position 0. This is universal in programming, and you just have to burn it into your brain.
patients = ["Alice", "Bob", "Carlos"]
patients[1] = "Robert"
print(patients) # ["Alice", "Robert", "Carlos"]
patients = ["Alice", "Bob"]
patients.append("Carlos") # add to end
patients.insert(0, "Zara") # add at specific position
patients.remove("Bob") # remove by value
last = patients.pop() # remove and return last item
length = len(patients) # how many items?
patients.sort() # sort in place
sorted_list = sorted(patients) # return a new sorted list
print("Alice" in patients) # True/False β is this in the list?
readings = [97, 85, 93, 87, 91, 96]
print(readings[1:4]) # [85, 93, 87] β index 1 up to (not including) 4
print(readings[:3]) # [97, 85, 93] β from start up to index 3
print(readings[3:]) # [87, 91, 96] β from index 3 to end
print(readings[-2:]) # [91, 96] β last 2 items
---
A dictionary maps keys to values. Instead of remembering position numbers, you use names. Use curly braces {}.
patient = {
"name": "Alice",
"age": 45,
"spo2": 97,
"diagnosis": "Pneumonia"
}
Think of it like a real dictionary: you look up a word (key) to get its definition (value). Or like a form with labeled fields.
print(patient["name"]) # Alice
print(patient["spo2"]) # 97
patient["heart_rate"] = 82 # add new key
patient["spo2"] = 95 # update existing key
if "heart_rate" in patient:
print(f"Heart rate: {patient['heart_rate']}")
If you access a key that doesn't exist, Python throws an error. Use .get() to get a default value instead:
temp = patient.get("temperature", "not recorded")
print(temp) # "not recorded" β no error
patient = {"name": "Alice", "age": 45, "spo2": 97}
for key, value in patient.items():
print(f"{key}: {value}")
Output:
name: Alice
age: 45
spo2: 97
---
This is how real data is usually structured. A list where each item is a dictionary:
patients = [
{"name": "Alice", "spo2": 97, "rr": 16},
{"name": "Bob", "spo2": 85, "rr": 28},
{"name": "Carlos", "spo2": 93, "rr": 18},
]
for patient in patients:
name = patient["name"]
spo2 = patient["spo2"]
rr = patient["rr"]
print(f"{name}: SpO2={spo2}%, RR={rr}")
When you've been copying code that does things like data["patients"][0]["name"] β you're navigating this exact structure: a dictionary containing a list of dictionaries.
---
A tuple is an ordered collection that cannot be changed after creation. Use parentheses ().
coordinates = (40.7128, -74.0060) # latitude, longitude
status_codes = (200, 404, 500)
Why use a tuple instead of a list? To say "this data shouldn't change." A coordinate pair, a fixed set of options, a record you're treating as read-only.
point = (3, 7)
print(point[0]) # 3
print(point[1]) # 7
# This will error:
point[0] = 5 # TypeError: 'tuple' object does not support item assignment
You'll see tuples used in the code you work with, but you'll mostly use lists and dictionaries.
---
Real-world data can nest as deep as you need:
hospital = {
"name": "City General",
"wards": {
"ICU": {
"beds": 10,
"patients": ["Alice", "Bob"]
},
"General": {
"beds": 40,
"patients": ["Carlos", "Dana", "Eve"]
}
}
}
# Access nested data by chaining keys
icu_patients = hospital["wards"]["ICU"]["patients"]
print(icu_patients) # ["Alice", "Bob"]
print(icu_patients[0]) # "Alice"
Read left to right: get the hospital, get its wards, get the ICU ward, get its patients.
---
Check length:
if len(patients) == 0:
print("No patients to process")
Build a list in a loop (from lesson 4):
critical = []
for p in patients:
if p["spo2"] < 90:
critical.append(p["name"])
List comprehension β a compact way to build a list (you'll see this in code everywhere):
# Verbose version
critical = []
for p in patients:
if p["spo2"] < 90:
critical.append(p["name"])
# Compact version (same result)
critical = [p["name"] for p in patients if p["spo2"] < 90]
Both do the same thing. The compact version is "Pythonic" β the way experienced Python programmers tend to write it. Don't stress about writing it this way yet, but recognize it when you see it.
---
1. Create a list of 5 patient names. Print the first, the last, and the total count.
2. Create a dictionary for a single patient with keys: name, age, spo2, respiratory_rate. Print each field with a label.
3. Create a list of 3 patient dictionaries. Loop through and print a summary line for each one.
---
| Structure | Syntax | Use when |
| ----------- | -------- | ---------- |
| List | `[a, b, c]` | Ordered collection you can change |
| Dictionary | `{"key": value}` | Named fields, lookup by name |
| Tuple | `(a, b, c)` | Ordered, unchangeable collection |
Key operations:
---
Next lesson: Functions β how to give a block of code a name so you can reuse it anywhere without copy-pasting.
Imagine you need to convert a baby's weight from grams to pounds in 5 different places in your code. You write the same calculation 5 times. Then you realize you need to round to 3 decimal places instead of 2. Now you have to find all 5 copies and change them.
Functions solve this. Write the logic once, give it a name, call it from anywhere.
Think of a function like a clinical protocol: you write it once ("If SpO2 < 88%, increase FiO2 by 5%"), and anyone can follow it anytime. The protocol is the function. Following it is calling it.
---
>>> def greet():
... print("Good morning, NICU team")
...
>>> greet()
Good morning, NICU team
>>> greet()
Good morning, NICU team
Anatomy:
---
A function becomes much more useful when you can pass information to it:
>>> def check_spo2(spo2):
... if spo2 < 88:
... print("ALARM: SpO2 " + str(spo2) + "% is too low")
... else:
... print("OK: SpO2 " + str(spo2) + "%")
...
>>> check_spo2(84)
ALARM: SpO2 84% is too low
>>> check_spo2(95)
OK: SpO2 95%
>>> check_spo2(79)
ALARM: SpO2 79% is too low
spo2 is a parameter β a variable that receives whatever you pass in when you call the function. Inside the function, spo2 holds the value you gave it.
Multiple parameters:
>>> def assess_patient(name, spo2, fio2):
... print("Patient: " + name)
... print("SpO2: " + str(spo2) + "%")
... print("FiO2: " + str(fio2))
... if spo2 < 88:
... print(">>> ACTION NEEDED")
...
>>> assess_patient("Johnson", 84, 0.50)
Patient: Johnson
SpO2: 84%
FiO2: 0.5
>>> ACTION NEEDED
---
So far, functions have *printed* results. But usually you want a function to *calculate* something and *give it back* to you β so you can use the result in more code.
That's what return does:
>>> def grams_to_lbs(grams):
... kg = grams / 1000
... lbs = kg * 2.205
... return round(lbs, 2)
...
>>> weight_lbs = grams_to_lbs(875)
>>> print("Weight: " + str(weight_lbs) + " lbs")
Weight: 1.93 lbs
The function calculates and returns the answer. You store that answer in weight_lbs and use it elsewhere.
Without return, the function does work but gives nothing back:
>>> result = greet() # greet() prints but doesn't return anything
>>> print(result)
None # None means "nothing was returned"
---
You can give parameters default values, making them optional:
>>> def check_spo2(spo2, low_threshold=88, high_threshold=95):
... if spo2 < low_threshold:
... return "LOW"
... elif spo2 > high_threshold:
... return "HIGH"
... else:
... return "OK"
...
>>> check_spo2(84) # uses defaults: low=88, high=95
'LOW'
>>> check_spo2(84, low_threshold=80) # custom low threshold
'OK'
>>> check_spo2(97, high_threshold=96) # custom high threshold
'HIGH'
---
Functions can call other functions. This is how you build complexity from simple pieces:
>>> def kg_to_lbs(kg):
... return round(kg * 2.205, 2)
...
>>> def print_patient_weight(name, weight_grams):
... weight_kg = weight_grams / 1000
... weight_lbs = kg_to_lbs(weight_kg) # calling the other function
... print(name + ": " + str(weight_kg) + " kg / " + str(weight_lbs) + " lbs")
...
>>> print_patient_weight("Baby Johnson", 875)
Baby Johnson: 0.875 kg / 1.93 lbs
---
>>> def triage(spo2):
... if spo2 < 80:
... return "CRITICAL"
... elif spo2 < 88:
... return "LOW"
... else:
... return "OK"
...
>>> readings = [96, 84, 91, 78, 93]
>>> for reading in readings:
... status = triage(reading)
... print(str(reading) + "% β " + status)
...
96% β OK
84% β LOW
91% β OK
78% β CRITICAL
93% β OK
Now your decision logic is in one place (triage), cleanly separated from your loop logic.
---
Variables created inside a function are local β they only exist inside that function:
>>> def calculate_dose(weight_kg):
... dose = weight_kg * 0.1 # 'dose' only exists inside here
... return dose
...
>>> calculate_dose(3.2)
0.32000000000000006
>>> print(dose) # Error! 'dose' doesn't exist out here
NameError: name 'dose' is not defined
This is actually good β functions don't accidentally mess with variables in the rest of your code.
---
| Concept | What It Is | Example |
| --------- | ----------- | --------- |
| `def` | Define a function | `def check_spo2(x):` |
| Parameter | Input variable | `spo2` in `def f(spo2):` |
| `return` | Send a value back | `return result` |
| Calling | Running the function | `check_spo2(91)` |
| Default value | Optional parameter | `def f(x, threshold=88):` |
| Scope | Where a variable lives | Local vs global |
---
Every AI agent, API call, and script you've copy-pasted is full of functions. def process_product(item): β that's a function. def send_email(to, subject, body): β function. When you understand functions, you can read your agents' code and understand exactly what each piece does. You can also write your own small helper functions to customize behavior without breaking everything.
---
Exercise 1: Simple function
def greet_patient(name):
print("Good morning, " + name + ". Let's check your vitals.")
greet_patient("Johnson")
greet_patient("Martinez")
greet_patient("Williams")
Exercise 2: Function that returns a value
def grams_to_kg(grams):
return grams / 1000
weight = grams_to_kg(1250)
print("Weight: " + str(weight) + " kg")
Exercise 3: Function with a decision
def assess_respiratory_status(spo2, respiratory_rate):
issues = []
if spo2 < 88:
issues.append("Low SpO2")
if respiratory_rate > 60:
issues.append("Tachypnea")
if len(issues) == 0:
return "Stable"
else:
return "Issues: " + ", ".join(issues)
print(assess_respiratory_status(92, 45))
print(assess_respiratory_status(84, 65))
print(assess_respiratory_status(91, 72))
Exercise 4: Function used in a loop
def classify_weight(weight_kg):
if weight_kg < 1.0:
return "Extremely low birth weight"
elif weight_kg < 1.5:
return "Very low birth weight"
elif weight_kg < 2.5:
return "Low birth weight"
else:
return "Normal birth weight"
weights = [0.75, 1.2, 2.1, 3.4, 0.95]
for w in weights:
category = classify_weight(w)
print(str(w) + " kg: " + category)
Exercise 5: Functions calling functions
def celsius_to_fahrenheit(c):
return round(c * 9/5 + 32, 1)
def check_temp(temp_celsius):
temp_f = celsius_to_fahrenheit(temp_celsius)
if temp_celsius > 38.0:
status = "FEVER"
elif temp_celsius < 36.5:
status = "HYPOTHERMIA"
else:
status = "Normal"
print(str(temp_celsius) + "Β°C / " + str(temp_f) + "Β°F β " + status)
check_temp(37.2)
check_temp(38.5)
check_temp(36.0)
---
Forgetting return when you need the value:
def add(a, b):
result = a + b # calculated but never returned
x = add(3, 4)
print(x) # prints None β forgot return!
Calling before defining:
greet("Ron") # Error β function not defined yet
def greet(name):
print("Hello " + name)
Indentation β code outside the function accidentally:
def check(x):
result = x * 2
return result # Error β return is outside the function!
---
Every real script you'll ever write deals with text: reading CSV files, processing API responses, building messages, parsing patient names, cleaning up messy data. Python's string tools are excellent and you'll use them constantly.
A string is just a sequence of characters. Python treats it like a list of individual characters, which gives you a lot of power.
---
name = "Alice"
print(name[0]) # A (first character)
print(name[-1]) # e (last character)
print(name[1:3]) # li (slice from index 1 to 3)
print(len(name)) # 5 (number of characters)
You can loop over characters:
for char in "Hello":
print(char)
# H
# e
# l
# l
# o
---
Methods are functions attached to a value. You call them with a dot: value.method().
name = "alice SUBLETT"
print(name.upper()) # ALICE SUBLETT
print(name.lower()) # alice sublett
print(name.title()) # Alice Sublett
print(name.capitalize()) # Alice sublett (only first char)
This is critical for real data, which is often messy:
raw = " Ron Sublett "
print(raw.strip()) # "Ron Sublett" (removes both ends)
print(raw.lstrip()) # "Ron Sublett " (removes left only)
print(raw.rstrip()) # " Ron Sublett" (removes right only)
text = "Patient has pneumonia and requires oxygen therapy"
print(text.find("pneumonia")) # 12 (index where it starts, or -1 if not found)
print(text.count("a")) # 6 (how many times "a" appears)
print("oxygen" in text) # True
print(text.startswith("Patient")) # True
print(text.endswith("therapy")) # True
note = "The patient is stable. The patient needs follow-up."
updated = note.replace("The patient", "Alice")
print(updated)
# "Alice is stable. Alice needs follow-up."
replace() replaces ALL occurrences by default.
---
split() β break a string into a list of pieces:
csv_line = "Alice,45,97,Pneumonia"
parts = csv_line.split(",")
print(parts) # ["Alice", "45", "97", "Pneumonia"]
print(parts[0]) # Alice
print(parts[2]) # 97
Without an argument, split() splits on any whitespace:
words = "check on patient this morning".split()
print(words) # ["check", "on", "patient", "this", "morning"]
join() β glue a list back into a string. Note: you call it on the separator:
items = ["Alice", "45", "97", "Pneumonia"]
line = ",".join(items)
print(line) # "Alice,45,97,Pneumonia"
words = ["check", "on", "patient"]
sentence = " ".join(words)
print(sentence) # "check on patient"
split() and join() are inverses of each other. You'll use them constantly when reading files.
---
You already know the basics. Here are the formatting tricks:
bmi = 24.6789
name = "Bob"
count = 1234567
# Basic
print(f"BMI: {bmi}") # BMI: 24.6789
# Decimal places
print(f"BMI: {bmi:.2f}") # BMI: 24.68
# Integer (no decimal)
print(f"Count: {count:,}") # Count: 1,234,567
# Padding/alignment
print(f"Name: {name:<10}|") # Name: Bob | (left-align, 10 wide)
print(f"Name: {name:>10}|") # Name: Bob| (right-align, 10 wide)
print(f"Name: {name:^10}|") # Name: Bob | (center, 10 wide)
# Expressions inside braces
print(f"Result: {2 + 2}") # Result: 4
print(f"Upper: {name.upper()}") # Upper: BOB
---
Use triple quotes for strings that span multiple lines:
report = """
Patient: Alice
SpO2: 97%
Status: Stable
Notes: No immediate concerns.
"""
print(report)
Also useful for messages with lots of text.
---
"42".isdigit() # True β all characters are digits
"hello".isalpha() # True β all characters are letters
"Ron123".isalnum() # True β all letters and numbers (no spaces or punctuation)
" ".isspace() # True β only whitespace
# Check if empty
name = ""
if not name:
print("Name is empty") # prints this
---
Parse a full name:
full_name = "Ron Sublett"
parts = full_name.split(" ", 1) # split at most once
first = parts[0]
last = parts[1] if len(parts) > 1 else ""
print(f"First: {first}, Last: {last}")
Clean up messy data from a file or API:
raw_input = " ALICE SMITH , 45 , 97 , PNEUMONIA "
parts = [part.strip().title() for part in raw_input.split(",")]
print(parts) # ["Alice Smith", "45", "97", "Pneumonia"]
Check if a response contains a keyword:
api_response = "The patient shows signs of respiratory distress"
keywords = ["distress", "critical", "emergency", "failing"]
for keyword in keywords:
if keyword in api_response.lower():
print(f"Alert keyword found: {keyword}")
break
---
Log files are often one record per line, with fields separated by a delimiter:
2024-01-15,Alice,97,16,Stable
2024-01-15,Bob,84,30,Critical
2024-01-15,Carlos,94,18,Stable
log_line = "2024-01-15,Bob,84,30,Critical"
parts = log_line.split(",")
date = parts[0]
name = parts[1]
spo2 = int(parts[2])
rr = int(parts[3])
status = parts[4]
print(f"{date} | {name}: SpO2={spo2}%, RR={rr}, Status={status}")
Output:
2024-01-15 | Bob: SpO2=84%, RR=30, Status=Critical
This exact pattern β split a line, convert types, use the values β is what parsing CSV files looks like before you learn the csv module.
---
1. Take the string " hello, world! " and produce "Hello, World!" (stripped and title-cased).
2. Write a function parse_patient_line(line) that takes a comma-separated string like "Alice,45,97" and returns a dictionary with keys name, age, and spo2.
3. Given a list of patient names that may have inconsistent capitalization (["alice smith", "BOB JONES", "Carlos Ruiz"]), normalize them all to Title Case.
---
| Method | What it does |
| -------- | ------------- |
| `.upper()`, `.lower()`, `.title()` | Change case |
| `.strip()` | Remove whitespace from ends |
| `.replace(old, new)` | Replace occurrences |
| `.split(delimiter)` | Break into list |
| `delimiter.join(list)` | Join list into string |
| `.find(text)` | Get index of substring |
| `"x" in string` | Check if substring exists |
| `.startswith()`, `.endswith()` | Check string ends |
| `f"{value:.2f}"` | Format floats, alignment |
---
Next lesson: Reading and writing files β how to get data in and out of your scripts from disk.
Scripts that only use input() are limited β you can only pass them one thing at a time. Real scripts read data from files (CSVs, logs, config files, JSON exports) and write results to files. This is how automation works: drop a file in, get a processed file out.
---
# Open a file for reading
file = open("patients.txt", "r")
content = file.read()
file.close()
print(content)
Arguments to open():
| Mode | Meaning |
| ------ | --------- |
| `"r"` | Read (default) β file must exist |
| `"w"` | Write β creates file if needed, **overwrites** if it exists |
| `"a"` | Append β adds to the end of an existing file |
| `"x"` | Create β fails if file already exists |
---
Always use with when working with files. It automatically closes the file when you're done, even if something goes wrong:
with open("patients.txt", "r") as file:
content = file.read()
# file is automatically closed here
print(content)
The as file part gives the opened file a name to use inside the with block. After the block, the file is closed automatically. You don't have to call .close().
Always use with. No exceptions. If you forget to close a file manually, you can corrupt it or lock it.
---
Read the entire file at once:
with open("patients.txt", "r") as f:
content = f.read() # one big string
print(content)
Read line by line (best for large files):
with open("patients.txt", "r") as f:
for line in f:
print(line.strip()) # .strip() removes the newline at end of each line
Read all lines into a list:
with open("patients.txt", "r") as f:
lines = f.readlines() # list of strings, one per line
print(lines[0]) # first line
print(len(lines)) # total number of lines
---
Write text to a file (overwrites existing content):
with open("output.txt", "w") as f:
f.write("Patient report generated.\n")
f.write("Total patients: 5\n")
\n is the newline character β it's how you tell Python "go to the next line." Without it, everything runs together on one line.
Append to an existing file:
with open("log.txt", "a") as f:
f.write("New entry added.\n")
Write a list of lines:
lines = ["Alice,97,Stable\n", "Bob,85,Critical\n", "Carlos,93,OK\n"]
with open("patients.csv", "w") as f:
f.writelines(lines)
Or use a loop:
patients = [
{"name": "Alice", "spo2": 97, "status": "Stable"},
{"name": "Bob", "spo2": 85, "status": "Critical"},
]
with open("report.csv", "w") as f:
f.write("name,spo2,status\n") # header row
for p in patients:
f.write(f"{p['name']},{p['spo2']},{p['status']}\n")
---
You can use relative paths (relative to where you run the script) or absolute paths:
# Relative β looks in same directory as the script
open("data.txt", "r")
# Relative β look in a subfolder
open("data/patients.txt", "r")
# Absolute β full path from root
open("/home/ron/projects/data.txt", "r")
---
If you try to open a file that doesn't exist, Python throws a FileNotFoundError. You'll want to handle this gracefully:
try:
with open("patients.txt", "r") as f:
content = f.read()
except FileNotFoundError:
print("Error: patients.txt not found.")
content = ""
try/except is Python's way of saying "try this, but if it fails with this specific error, do this instead." You'll learn more about error handling in the intermediate lessons β for now, this pattern is enough.
---
CSV (Comma-Separated Values) is the most common data format you'll encounter. You can parse it manually (as you saw in lesson 7), but Python's built-in csv module is easier:
Reading a CSV:
import csv
with open("patients.csv", "r") as f:
reader = csv.DictReader(f) # reads header row automatically
for row in reader:
print(row["name"], row["spo2"])
DictReader treats the first row as column names and gives you each subsequent row as a dictionary. This is the pattern you'll use for 90% of CSV work.
Writing a CSV:
import csv
patients = [
{"name": "Alice", "spo2": 97, "status": "Stable"},
{"name": "Bob", "spo2": 85, "status": "Critical"},
]
with open("output.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["name", "spo2", "status"])
writer.writeheader()
writer.writerows(patients)
newline="" is a quirk of how Python handles line endings on Windows β always include it when writing CSV files.
---
JSON is the other format you'll see constantly β it's what APIs return, what config files use, what many of your AI scripts read and write.
Reading JSON:
import json
with open("config.json", "r") as f:
config = json.load(f) # parses JSON into Python dict/list
print(config["api_key"])
Writing JSON:
import json
data = {
"patients": [
{"name": "Alice", "spo2": 97},
{"name": "Bob", "spo2": 85},
]
}
with open("output.json", "w") as f:
json.dump(data, f, indent=2) # indent=2 makes it human-readable
The indent=2 is optional but makes the file readable. Without it, everything is on one line.
---
import csv
input_file = "patients.csv"
output_file = "report.txt"
critical_patients = []
all_patients = []
with open(input_file, "r") as f:
reader = csv.DictReader(f)
for row in reader:
name = row["name"].strip()
spo2 = int(row["spo2"])
all_patients.append({"name": name, "spo2": spo2})
if spo2 < 90:
critical_patients.append(name)
with open(output_file, "w") as f:
f.write("=== PATIENT STATUS REPORT ===\n\n")
f.write(f"Total patients: {len(all_patients)}\n")
f.write(f"Critical (SpO2 < 90%): {len(critical_patients)}\n\n")
if critical_patients:
f.write("CRITICAL PATIENTS:\n")
for name in critical_patients:
f.write(f" - {name}\n")
print(f"Report written to {output_file}")
This is a real, complete script: read input, process it, write output.
---
1. Create a text file called names.txt with 5 names, one per line. Write a script that reads it and prints each name in uppercase.
2. Write a script that reads the names file, then writes a new file greetings.txt with a personalized greeting for each person.
3. Create a CSV with columns product and price. Write a script that reads it and prints the total cost of all items.
---
| Concept | Example |
| --------- | --------- |
| Open for reading | `open("file.txt", "r")` |
| Open for writing | `open("file.txt", "w")` |
| Open for appending | `open("file.txt", "a")` |
| Always use `with` | `with open(...) as f:` |
| Read entire file | `f.read()` |
| Read line by line | `for line in f:` |
| Write text | `f.write("text\n")` |
| CSV reading | `csv.DictReader(f)` |
| CSV writing | `csv.DictWriter(f, fieldnames=...)` |
| JSON reading | `json.load(f)` |
| JSON writing | `json.dump(data, f, indent=2)` |
---
Next lesson: Scripts that do real things β using the os module, running shell commands, and making your scripts interact with the system around them.
You've been running scripts that do everything in Python's own world β variables, lists, loops, file reading. But Python can also reach out and talk to the operating system: list files in a directory, move or delete files, run shell commands, check the time, work with paths.
This is where Python stops feeling like a toy and starts feeling like a real tool.
---
import is how you load extra capabilities into your script. Python comes with a huge standard library of modules you can import for free.
import os
# What directory are we in right now?
print(os.getcwd()) # /home/ron/projects/scripts
# List files in a directory
files = os.listdir(".") # "." means current directory
print(files)
# Does a file exist?
print(os.path.exists("patients.csv")) # True or False
# Is it a file or a directory?
print(os.path.isfile("patients.csv")) # True
print(os.path.isdir("data")) # True if "data" is a folder
# Get just the filename from a full path
path = "/home/ron/data/patients.csv"
print(os.path.basename(path)) # patients.csv
print(os.path.dirname(path)) # /home/ron/data
# Join paths safely (handles slashes on Windows vs Mac/Linux)
full_path = os.path.join("data", "patients.csv")
print(full_path) # data/patients.csv
Why os.path.join() instead of "data/" + "patients.csv"?
Because on Windows, the separator is \ not /. os.path.join() uses the right one automatically. Always use it for paths.
---
import os
import shutil # "shell utilities" β higher-level file operations
# Create a directory
os.makedirs("output", exist_ok=True) # exist_ok=True won't error if it already exists
# Rename or move a file
os.rename("old_name.txt", "new_name.txt")
# Move to a different directory
shutil.move("file.txt", "archive/file.txt")
# Copy a file
shutil.copy("source.txt", "backup.txt")
# Delete a file (permanent β no recycle bin)
os.remove("temp.txt")
# Delete an empty directory
os.rmdir("empty_folder")
# Delete a directory and everything in it (careful!)
shutil.rmtree("old_data")
shutil.rmtree() is permanent and instant. Be careful.
---
import os
# Walk through a directory and all its subdirectories
for folder, subfolders, files in os.walk("data"):
print(f"In {folder}:")
for file in files:
full_path = os.path.join(folder, file)
print(f" {full_path}")
This recursively visits every folder and lists every file. Extremely useful for processing batches of files.
---
import glob
# Find all CSV files in current directory
csv_files = glob.glob("*.csv")
print(csv_files) # ["patients.csv", "staff.csv", "schedule.csv"]
# Find CSVs in any subdirectory (recursive)
all_csvs = glob.glob("**/*.csv", recursive=True)
* matches any characters. ** matches any number of directories.
---
from datetime import datetime
now = datetime.now()
print(now) # 2024-01-15 14:32:05.123456
print(now.strftime("%Y-%m-%d")) # 2024-01-15
print(now.strftime("%B %d, %Y")) # January 15, 2024
print(now.strftime("%H:%M")) # 14:32
# Use in a filename (common pattern)
timestamp = now.strftime("%Y%m%d_%H%M%S")
filename = f"report_{timestamp}.txt"
print(filename) # report_20240115_143205.txt
---
Sometimes you need to run a terminal command from inside your Python script.
import subprocess
# Run a command and capture its output
result = subprocess.run(["ls", "-la"], capture_output=True, text=True)
print(result.stdout) # the command's output as a string
print(result.returncode) # 0 means success, anything else means error
On Windows: use ["dir"] instead of ["ls", "-la"]
# Check if a command succeeded
result = subprocess.run(["python3", "my_script.py"], capture_output=True, text=True)
if result.returncode == 0:
print("Script ran successfully")
else:
print(f"Script failed: {result.stderr}")
Why not just os.system()? You'll see os.system("command") in old code. subprocess.run() is better because you can capture output and check for errors.
---
Environment variables are settings stored in the operating system that scripts can read. This is how API keys and configuration are typically stored in the code you've been working with:
import os
# Read an environment variable
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OPENAI_API_KEY not set")
else:
print("API key found")
# Read with a default value
debug_mode = os.environ.get("DEBUG", "false")
This is why your scripts have .env files and commands like export OPENAI_API_KEY=sk-... β those set environment variables, and your Python code reads them with os.environ.get().
---
When you run python3 script.py input.csv output.csv, the filenames are arguments passed to the script. You can read them:
import sys
# sys.argv is a list of arguments
# sys.argv[0] is always the script name itself
# sys.argv[1], [2], etc. are what you pass in
print(sys.argv) # ["script.py", "input.csv", "output.csv"]
if len(sys.argv) < 3:
print("Usage: python3 script.py input.csv output.csv")
sys.exit(1)
input_file = sys.argv[1]
output_file = sys.argv[2]
print(f"Reading from: {input_file}")
print(f"Writing to: {output_file}")
This makes scripts reusable β you don't hardcode the filename, you pass it in.
---
import os
import csv
import glob
from datetime import datetime
def process_patient_file(filepath):
"""Read a CSV and return summary stats."""
patients = []
with open(filepath, "r") as f:
reader = csv.DictReader(f)
for row in reader:
patients.append({
"name": row["name"].strip(),
"spo2": int(row["spo2"])
})
critical = [p for p in patients if p["spo2"] < 90]
return {
"file": os.path.basename(filepath),
"total": len(patients),
"critical_count": len(critical),
"critical_names": [p["name"] for p in critical]
}
# Find all CSV files in the "data" directory
csv_files = glob.glob("data/*.csv")
if not csv_files:
print("No CSV files found in data/")
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_path = f"reports/summary_{timestamp}.txt"
os.makedirs("reports", exist_ok=True)
with open(report_path, "w") as report:
report.write(f"Batch Report β {datetime.now().strftime('%B %d, %Y %H:%M')}\n")
report.write("=" * 50 + "\n\n")
for filepath in sorted(csv_files):
summary = process_patient_file(filepath)
report.write(f"File: {summary['file']}\n")
report.write(f" Total patients: {summary['total']}\n")
report.write(f" Critical: {summary['critical_count']}\n")
if summary["critical_names"]:
report.write(f" Names: {', '.join(summary['critical_names'])}\n")
report.write("\n")
print(f"Report written to {report_path}")
This script:
1. Finds all CSV files in a directory
2. Processes each one
3. Writes a timestamped summary report
4. Creates the output directory if it doesn't exist
This is the structure of most real automation scripts.
---
1. Write a script that lists all .txt files in the current directory and prints each filename and its size in bytes. (Hint: os.path.getsize("filename"))
2. Write a script that creates a folder called archive, then moves all .csv files from the current directory into it.
3. Write a script that accepts a directory path as a command-line argument and prints how many files are in it.
---
| Module | What it provides |
| -------- | ----------------- |
| `os` | File system operations, paths, environment variables |
| `os.path` | Path manipulation (join, exists, isfile, etc.) |
| `shutil` | Copy, move, delete files and directories |
| `glob` | Find files matching a pattern |
| `subprocess` | Run shell commands from Python |
| `sys` | Command-line arguments, exit |
| `datetime` | Dates, times, timestamps |
---
Next lesson: Your first real complete script β putting everything together into a script that reads files, processes data, makes decisions, and writes output. This is what you've been building toward.
This lesson doesn't teach new concepts. It proves you've learned the ones before it.
You're going to build a complete, working script from scratch. It will:
1. Read a CSV file of products (like from your Shopify/dropshipping work)
2. Filter and score them based on rules
3. Write a processed output file
4. Print a summary to the terminal
Every line uses something from lessons 1β9. By the end you'll have written a script that does something real β not a toy example, but the actual shape of the scripts you've been using.
---
You have a file of products exported from somewhere. Each row has: name, category, price, cost, and stock count. You want a script that:
---
First, create the input file we'll work with. Save this as products.csv:
name,category,price,cost,stock
Wireless Earbuds,Electronics,49.99,18.00,120
Phone Stand,Accessories,12.99,3.50,0
LED Desk Lamp,Home,34.99,11.00,45
Bamboo Keyboard,Electronics,89.99,42.00,28
Notebook Set,Stationery,14.99,4.25,200
USB Hub,Electronics,24.99,8.50,0
Posture Cushion,Health,39.99,14.00,67
Blue Light Glasses,Health,29.99,6.50,15
Reusable Water Bottle,Kitchen,19.99,7.00,88
Laptop Sleeve,Accessories,22.99,9.00,0
---
Create product_processor.py:
import csv
import os
from datetime import datetime
# ---------- FUNCTIONS ----------
def calculate_margin(price, cost):
"""Return profit margin as a percentage."""
if price <= 0:
return 0
profit = price - cost
return round((profit / price) * 100, 1)
def classify_margin(margin):
"""Return a label based on margin percentage."""
if margin >= 40:
return "HIGH"
elif margin >= 25:
return "OK"
else:
return "LOW"
def load_products(filepath):
"""Read products CSV and return a list of dicts."""
if not os.path.exists(filepath):
print(f"Error: File not found β {filepath}")
return []
products = []
with open(filepath, "r") as f:
reader = csv.DictReader(f)
for row in reader:
try:
product = {
"name": row["name"].strip(),
"category": row["category"].strip(),
"price": float(row["price"]),
"cost": float(row["cost"]),
"stock": int(row["stock"]),
}
products.append(product)
except (ValueError, KeyError) as e:
print(f"Skipping bad row: {row} β {e}")
return products
def process_products(products):
"""Filter and enrich the product list. Returns (processed, skipped)."""
processed = []
skipped = []
for product in products:
if product["stock"] == 0:
skipped.append(product["name"])
continue
margin = calculate_margin(product["price"], product["cost"])
margin_label = classify_margin(margin)
enriched = {
**product, # copy all existing fields
"margin_pct": margin,
"margin_label": margin_label,
}
processed.append(enriched)
return processed, skipped
def write_output(products, filepath):
"""Write processed products to a new CSV."""
if not products:
print("Nothing to write.")
return
fieldnames = ["name", "category", "price", "cost", "stock", "margin_pct", "margin_label"]
with open(filepath, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(products)
print(f"Output written to: {filepath}")
def print_summary(processed, skipped):
"""Print a summary report to the terminal."""
high_margin = [p for p in processed if p["margin_label"] == "HIGH"]
print("\n" + "=" * 50)
print("PRODUCT PROCESSING SUMMARY")
print("=" * 50)
print(f"Total processed: {len(processed)}")
print(f"Skipped (no stock): {len(skipped)}")
if skipped:
print(f" Skipped: {', '.join(skipped)}")
print(f"\nHigh-margin products ({len(high_margin)}):")
if high_margin:
for p in sorted(high_margin, key=lambda x: x["margin_pct"], reverse=True):
print(f" {p['name']:<30} {p['margin_pct']}% ${p['price']}")
else:
print(" None found")
if processed:
avg_margin = sum(p["margin_pct"] for p in processed) / len(processed)
print(f"\nAverage margin (in-stock products): {round(avg_margin, 1)}%")
print("=" * 50)
# ---------- MAIN ----------
def main():
input_file = "products.csv"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = f"products_processed_{timestamp}.csv"
print(f"Reading: {input_file}")
# Load
products = load_products(input_file)
if not products:
print("No products loaded. Exiting.")
return
print(f"Loaded {len(products)} products.")
# Process
processed, skipped = process_products(products)
# Write output
write_output(processed, output_file)
# Report
print_summary(processed, skipped)
if __name__ == "__main__":
main()
---
python3 product_processor.py
You should see output like:
Reading: products.csv
Loaded 10 products.
Output written to: products_processed_20240115_143022.csv
==================================================
PRODUCT PROCESSING SUMMARY
==================================================
Total processed: 7
Skipped (no stock): 3
Skipped: Phone Stand, USB Hub, Laptop Sleeve
High-margin products (4):
Blue Light Glasses 78.3% $29.99
Reusable Water Bottle 65.0% $19.99
LED Desk Lamp 68.6% $34.99
Notebook Set 71.6% $14.99
Average margin (in-stock products): 62.4%
==================================================
---
Let's trace through the important parts:
{product, "margin_pct": margin}**
The ** unpacks the dictionary β it copies all existing key/value pairs into a new dictionary, then adds the new keys. This is how you add fields to a dict without typing each one out.
sorted(high_margin, key=lambda x: x["margin_pct"], reverse=True)
This sorts the list by margin, highest first. The lambda x: x["margin_pct"] is an inline function that says "sort by this field." Don't stress about lambda yet β just recognize it when you see it.
if __name__ == "__main__": main()
This is a Python convention. When you run a file directly (python3 script.py), Python sets __name__ to "__main__". This check means the main() function only runs when the script is executed directly β not if someone imports your functions from another script. You'll see this in virtually every Python script you read.
---
Try making these changes to prove you understand the code:
1. Change the margin threshold. Edit classify_margin() so "HIGH" requires 50% instead of 40%. Run it again and see how the output changes.
2. Add a new column. In process_products(), add a "profit" field that contains price - cost. Make sure it shows up in the CSV output (add it to fieldnames in write_output()).
3. Filter by category. Add a function that takes a category name and returns only products in that category. Print the high-margin electronics separately in the summary.
4. Add command-line arguments. Use sys.argv to let the user specify the input file: python3 product_processor.py my_products.csv
---
The script you just wrote has the structure of production Python code:
The AI-generated scripts you've been working with follow this same shape. Now you can read them, modify them, and reason about what they're doing.
---
You now have a real foundation. The next layer (the intermediate series already in this repo) covers:
But before you go there: take one of the scripts you've already been using and read it. Don't run it β read it. See how much you now understand that you didn't before. That gap closing is the whole point of these lessons.
---
| Lesson | Concepts |
| -------- | --------- |
| 01 | `print`, variables, strings, `input`, f-strings |
| 02 | `int`, `float`, arithmetic, type conversion |
| 03 | `if/elif/else`, comparisons, booleans, `and/or/not` |
| 04 | `for`, `while`, `break`, `continue`, `range()` |
| 05 | Lists, dictionaries, tuples, indexing, nesting |
| 06 | Defining functions, parameters, `return`, scope |
| 07 | String methods, `.split()`, `.join()`, f-string formatting |
| 08 | `open()`, `with`, reading/writing files, CSV, JSON |
| 09 | `os`, `shutil`, `glob`, `subprocess`, `sys.argv`, `datetime` |
| 10 | Putting it all together in a real, structured script |
You built this. The code you copied last month β you can read it now.
You already write Python that ships to production. This curriculum teaches you
the fundamentals you're missing by using your own code as the textbook.
No toy examples. Every lesson opens with a bug or awkward pattern from one of
your real scripts, explains *why* it's a problem, then shows you how to fix it.
---
| Script | What it does | Skill level |
| -------- | ------------- | ------------- |
| `scripts/daily-content-agent.py` | Writes 14 SEO articles/day via Qwen API | Good procedural Python |
| `scripts/fb-reel-upload.py` | Uploads videos to two FB pages | Has a real crash bug |
| `scripts/trendpilot-loop.py` | Full autonomous e-commerce loop | Best error handling of the four |
| `amazon-price-tracker/main.py` | Price monitoring with DB + alerts | Best OOP of the four |
---
| # | Title | Your code | The problem |
| --- | ------- | ----------- | ------------- |
| [01](01-functions.md) | Functions Done Right | `qwen_call()` in both scripts | Returns a string OR a dict β inconsistent |
| [02](02-error-handling.md) | Error Handling | `fb-reel-upload.py:202` | Crash bug from undefined variable |
| [03](03-data-structures.md) | Data Structures | KEYWORDS dict, product catalog | Right tool for the right shape |
| [04](04-oop.md) | Classes & OOP | Procedural vs `TrendPilotLoop` vs `AmazonPriceMonitor` | When classes actually help |
| [05](05-cli-argparse.md) | CLI & argparse | `fb-reel-upload.py` sys.argv vs trendpilot argparse | Manual arg parsing breaks silently |
| [06](06-file-io.md) | File I/O & Context Managers | Token files opened without `with` | File handles that never close |
| [07](07-working-with-apis.md) | Working with APIs | Qwen + Shopify calls in both scripts | Retry, rate limits, response parsing |
| [08](08-decorators-and-context-managers.md) | Decorators & Context Managers | Copy-pasted retry logic | Write it once, use it everywhere |
| [09](09-async-and-concurrency.md) | Async & Concurrency | 14 sequential Qwen calls | 7 minutes β under 1 minute |
| [10](10-testing-and-deployment.md) | Testing & Deployment | `amazon-price-tracker/main.py` + cron | The bug that slipped through |
---
Each lesson is ~20 minutes. Read the problem section first β if you already
know the answer, skim to the exercises. If the problem surprises you, read
every line.
Do the exercises in a scratch file alongside the real script. When an exercise
says "fix this in your actual script," make the change β this is your code.
Recommended order: 01 β 02 β 04 β 07 β 08 β 03 β 05 β 06 β 09 β 10
Start with 01 and 02 because they fix active bugs. Do 04 before 07 because
understanding classes helps the API patterns click. Do 09 last β async requires
everything else to be solid first.
Source files: scripts/daily-content-agent.py and scripts/trendpilot-loop.py
---
You have qwen_call() defined in two separate scripts. Both versions have the
same flaw: they return different types depending on what happens.
From scripts/daily-content-agent.py, lines 212β238:
def qwen_call(prompt, model="qwen-plus", temperature=0.7, max_tokens=4000):
if not DASHSCOPE_API_KEY:
return {"error": "No DASHSCOPE_API_KEY set"} # returns a DICT
# ...
try:
# ...
return data["choices"][0]["message"]["content"] # returns a STRING
except Exception as e:
return {"error": str(e)} # returns a DICT
One function, three possible return types. Now look at every place that calls it:
# daily-content-agent.py line 424
article = qwen_call(prompt, ...)
if isinstance(article, dict) and "error" in article:
print(f" ERROR Qwen API error: {article['error']}")
# ... handle error
# trendpilot-loop.py line 1013
result = qwen_call(description_prompt, ...)
if isinstance(result, str) and len(result) > 50:
qwen_description = result
You're writing isinstance() checks everywhere because you can't trust what
comes back. That's a red flag β it means the function's contract is unclear.
---
A function's signature is its contract: "give me these inputs, I'll give you
this output." When the output type varies, the caller has to guess. That leads to:
1. Defensive isinstance() checks cluttering your code
2. Bugs when you forget to check (silently use a dict as a string)
3. No way to use type hints to catch it before running
---
The Python way to signal failure is to raise an exception, not return a
special error value. Callers that don't care about errors don't have to do
anything β they let the exception bubble up. Callers that do care use try/except.
class QwenError(Exception):
"""Raised when the Qwen API call fails."""
pass
def qwen_call(prompt, model="qwen-plus", temperature=0.7, max_tokens=4000) -> str:
"""Call Qwen Cloud API. Always returns a string. Raises QwenError on failure."""
if not DASHSCOPE_API_KEY:
raise QwenError("No DASHSCOPE_API_KEY set")
headers = {
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
try:
req = urllib.request.Request(
QWEN_API_URL,
data=json.dumps(payload).encode(),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
except urllib.error.HTTPError as e:
raise QwenError(f"HTTP {e.code}: {e.read().decode()[:200]}") from e
except Exception as e:
raise QwenError(str(e)) from e
Notice: the return type annotation -> str tells Python (and you) exactly what
comes back. No dicts, no isinstance() checks at the call site.
Now the callers become much cleaner:
# Before: 5 lines of isinstance checking
article = qwen_call(prompt)
if isinstance(article, dict) and "error" in article:
print(f" ERROR: {article['error']}")
file_path.write_text(f"# {keyword}\n\n*Writing...*\n")
return str(file_path)
file_path.write_text(article)
# After: clear intent, less code
try:
article = qwen_call(prompt)
file_path.write_text(article)
except QwenError as e:
print(f" ERROR: {e}")
file_path.write_text(f"# {keyword}\n\n*Writing...*\n")
return str(file_path)
---
A function should return the same type every time. If it sometimes fails, it
should raise an exception β not return a different type.
The -> str in def qwen_call(...) -> str: is a return type hint. Python
doesn't enforce it at runtime, but it documents the contract and tools like
mypy can catch violations.
class QwenError(Exception): pass creates a new exception type. This lets
callers catch *your specific error* without catching everything:
try:
result = qwen_call(prompt)
except QwenError:
# Only catches Qwen failures, not your own bugs
handle_qwen_failure()
Compare to catching Exception β that catches everything including your typos,
which hides bugs.
This chains exceptions. If someone prints the traceback, they'll see *both* the
original error and your QwenError. You don't lose the original context.
---
Your functions use default arguments well:
def qwen_call(prompt, model="qwen-plus", temperature=0.7, max_tokens=4000):
Caller only has to pass prompt. The rest have sensible defaults. This is good.
One gotcha: never use a mutable default argument.
# BAD β this list is shared across ALL calls
def pick_keywords(product, day_index, keywords=[]):
keywords.append(product) # modifies the shared list!
# GOOD β use None as sentinel, create fresh list inside
def pick_keywords(product, day_index, keywords=None):
if keywords is None:
keywords = []
You don't have this bug currently, but it's worth knowing because it's a common
Python trap.
---
Exercise 1 β Read the code:
Open scripts/trendpilot-loop.py. Find the qwen_call function (around line
166). Count how many places in the same file check isinstance(result, dict).
Answer: ___
Exercise 2 β Trace a call:
In scripts/daily-content-agent.py, the function expand_keywords() (line 477)
calls qwen_call and then does if isinstance(result, dict) and "error" in result.
If you changed qwen_call to raise QwenError instead, what would the expand_keywords
function look like? Rewrite just that section.
Exercise 3 β Write the fix:
Create a file scripts/qwen_client.py with a single qwen_call() function that:
Then update one caller in daily-content-agent.py to use it.
Exercise 4 β Think about it:
The function write_article() in daily-content-agent.py currently returns a
str (the file path). But what should it return if the Qwen API is down and it
wrote a placeholder? Should it raise? Return None? Return the path anyway?
There's no single right answer β write down your reasoning.
Source files: scripts/fb-reel-upload.py, scripts/trendpilot-loop.py
---
This script has a real bug that crashes if you use --page epic. Look at
lines 184β220:
# Upload to Epic Trends Store
if PAGE_TARGET in ["epic", "both"]:
print("π Uploading to Epic Trends Store...")
epic_video_id, epic_status, epic_details = upload_video_to_fb(...)
# ...
# Upload to BrandBoost Studio
if PAGE_TARGET in ["brandboost", "both"]:
print("π Uploading to BrandBoost Studio...")
brandboost_video_id, brandboost_status, brandboost_details = upload_video_to_fb(...)
if brandboost_status == "success": # LINE 202 β CRASH if PAGE_TARGET == "epic"
print(f"β
BrandBoost Studio Reel uploaded!")
Line 202 is outside the if PAGE_TARGET in ["brandboost", "both"] block.
If you run python3 fb-reel-upload.py video.mp4 --page epic, the script reaches
line 202 where brandboost_status has never been defined.
Python throws: UnboundLocalError: local variable 'brandboost_status' referenced before assignment
This is an indentation bug, but a good error-handling habit would have caught it
earlier: initialize variables before the conditional block:
brandboost_video_id, brandboost_status, brandboost_details = None, None, None
if PAGE_TARGET in ["brandboost", "both"]:
brandboost_video_id, brandboost_status, brandboost_details = upload_video_to_fb(...)
if brandboost_status == "success": # Now safe β worst case it's None
print(f"β
BrandBoost Studio Reel uploaded!")
---
Python exceptions form a tree. When you write except Exception, you catch
almost everything. When you write except ValueError, you catch only value
errors. Specificity matters.
BaseException
βββ KeyboardInterrupt # Ctrl+C β almost never catch this
βββ SystemExit # sys.exit() β almost never catch this
βββ Exception
βββ ArithmeticError
β βββ ZeroDivisionError
βββ LookupError
β βββ KeyError # dict["missing_key"]
β βββ IndexError # list[999]
βββ TypeError # wrong type passed to function
βββ ValueError # right type, wrong value (int("hello"))
βββ OSError # file system errors
β βββ FileNotFoundError
βββ requests.exceptions.RequestException
βββ ConnectionError
βββ Timeout
βββ HTTPError
Catching Exception catches all of these. The problem: if you have a typo that
causes a NameError, you'll catch that too β hiding your bug.
The rule: catch the most specific exception you can handle.
---
Here's the pattern in fb-reel-upload.py (line 92):
# BAD β catches everything, converts to string, loses traceback
except Exception as e:
print(f"β οΈ Upload error: {e}")
return 'error', 'exception', str(e)
Now compare to trendpilot-loop.py's shopify_api_call (lines 143β161):
# GOOD β catches specific exceptions, handles each case differently
except urllib.error.HTTPError as e:
if e.code == 429:
retry_after = int(e.headers.get("Retry-After", "2"))
log("API", f" β³ Rate limited, retrying in {retry_after}s")
time.sleep(retry_after)
continue
elif e.code == 401:
return {"error": "Shopify token expired", "status": "token_expired"}
else:
err_body = e.read().decode()[:500] if e.fp else str(e)
return {"error": f"Shopify API {e.code}: {err_body}", "status": "http_error"}
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
log("API", f" β οΈ Network error (attempt {attempt+1}): {e}")
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
return {"error": f"Network error after {max_retries} retries: {e}"}
The trendpilot version:
---
When an API is overloaded, retrying immediately makes it worse. Exponential
backoff waits longer after each failure:
for attempt in range(max_retries):
try:
result = call_api()
return result
except TemporaryError:
if attempt < max_retries - 1:
wait = 2 ** attempt # 1s, 2s, 4s, 8s...
time.sleep(wait)
else:
raise # Re-raise after final attempt
trendpilot-loop.py does this correctly. daily-content-agent.py's qwen_call
has no retry at all β if Qwen is momentarily overloaded, you lose that article.
---
You probably only use try/except. Python has two more clauses:
try:
result = risky_operation()
except SomeError as e:
print(f"Failed: {e}")
else:
# Only runs if NO exception was raised
print(f"Success: {result}")
save_result(result)
finally:
# ALWAYS runs, even if there's an exception
cleanup()
The else clause is useful because it keeps "success" code separate from
"attempt" code. The finally clause is useful for cleanup (closing files,
releasing locks) that must happen regardless of outcome.
In amazon-price-tracker/main.py, add_product uses finally:
try:
cursor.execute(...)
conn.commit()
return True
except sqlite3.Error as e:
logger.error(f"Error adding product: {e}")
return False
finally:
conn.close() # Always close the connection
---
Three forms:
# 1. Raise a new exception
raise ValueError("price cannot be negative")
# 2. Re-raise the current exception (inside except block)
except Exception:
log_it()
raise # Same exception continues up the stack
# 3. Chain exceptions (preserve original context)
except urllib.error.HTTPError as e:
raise QwenError("API call failed") from e
The raise with no arguments (form 2) is underused. It lets you log or clean
up, then let the exception continue. You don't have to handle everything.
---
Exercise 1 β Fix the crash bug:
Open scripts/fb-reel-upload.py. Add the two-line fix at line 202 that
initializes brandboost_video_id, brandboost_status, brandboost_details = None, None, None
before the if PAGE_TARGET in ["brandboost", "both"] block.
Then fix the indentation so the if brandboost_status == "success": check is
properly inside the block (or guard against None). Run it with --page epic to
verify it no longer crashes.
Exercise 2 β Add retry to qwen_call:
The qwen_call in daily-content-agent.py makes one attempt and gives up.
Add a retry loop (max 3 attempts) with exponential backoff for network errors.
Keep the no-retry behavior for DASHSCOPE_API_KEY not being set β that's not
a transient error.
Exercise 3 β Specific exceptions:
In daily-content-agent.py, load_extra_keywords() (line 457) has:
except Exception as e:
print(f" WARNING Could not load extra keywords: {e}")
What specific exceptions could happen here? (Hint: it opens a JSON file.)
Replace Exception with a tuple of the specific ones. Look up json.JSONDecodeError
and OSError.
Exercise 4 β finally for cleanup:
The token file reading in fb-reel-upload.py (line 16) opens a file without
closing it. Rewrite it using try/except/finally that closes the file even
if .strip() somehow fails. (Then note that with open(...) is even better β
that's the topic of Lesson 06.)
Source files: scripts/daily-content-agent.py, scripts/trendpilot-loop.py
---
Before covering what to learn, acknowledge what you're already doing well:
The goal here is knowing *when to choose what*, and learning two structures
you're not using yet: dataclasses and Counter/defaultdict.
---
daily-content-agent.py lines 43β188 define a large nested dict:
KEYWORDS = {
"resumeforge": [
"how to write a resume for warehouse workers",
"resume summary examples for healthcare workers",
# ... 30 more
],
"clara": [...],
"clozr": [...],
}
This is the right choice here. Why? Because you look things up by name
(KEYWORDS["resumeforge"]) and the order within each list matters (for rotation).
A list of tuples or a flat list would make the rotation logic harder.
The rotation logic at line 198β204 is elegant:
def pick_keywords(product, day_index, count=2):
keywords = KEYWORDS.get(product, [])
if not keywords:
return []
start = (day_index * count) % len(keywords)
return [keywords[(start + i) % len(keywords)] for i in range(count)]
The % len(keywords) wraps around when you reach the end. Day 15 picks where
day 0 left off. This is a circular buffer β a useful pattern.
One improvement: KEYWORDS.get(product, []) is already defensive (returns []
if the product key doesn't exist). That's good.
---
Inside write_article() (lines 360β397), you define a dict-of-dicts:
product_info = {
"resumeforge": {
"name": "ResumeForge",
"url": "resumeforge.brandbooststudio.co",
"angle": "AI resume builder...",
},
"clara": {
"name": "Clara",
"url": "clara.brandbooststudio.co",
"phone": "(361) 734-4096", # Only clara has this
"angle": "AI phone receptionist...",
},
...
}
info = product_info.get(product, {})
product_desc = info.get("angle", "") # Empty string if missing β silently wrong
The problem: info.get("angle", "") silently returns empty string if you
typo the key. If you add a new field later, nothing tells you to add it to
all products. "clara" has a phone key that others don't β is that intentional?
A dataclass makes this self-documenting:
from dataclasses import dataclass
from typing import Optional
@dataclass
class ProductInfo:
name: str
url: str
angle: str
phone: Optional[str] = None # Explicitly optional
PRODUCTS = {
"resumeforge": ProductInfo(
name="ResumeForge",
url="resumeforge.brandbooststudio.co",
angle="AI resume builder that refuses to fabricate experience.",
),
"clara": ProductInfo(
name="Clara",
url="clara.brandbooststudio.co",
angle="AI phone receptionist for small businesses.",
phone="(361) 734-4096",
),
# ...
}
# Usage
info = PRODUCTS.get(product)
if info is None:
raise ValueError(f"Unknown product: {product}")
product_desc = info.angle # Typo here β AttributeError immediately, not silent ""
Now if you typo info.naem, Python raises AttributeError immediately instead
of silently returning empty string.
---
You use sets correctly in load_extra_keywords() (line 467):
existing = set(KEYWORDS[product])
for kw in extra_kw:
if kw not in existing:
KEYWORDS[product].append(kw)
existing.add(kw)
set() makes the kw not in existing check O(1) β instant, regardless of
list size. If you used if kw not in KEYWORDS[product] directly, that's O(n)
per check β fine for 30 keywords, slow for 30,000.
The pattern you're using (convert to set for lookups, write back to list to
preserve order) is exactly right.
---
You don't use Counter yet, but you will. If you ever want to know which
categories appear most in your trends:
from collections import Counter
categories = [t.get("category_en", "unknown") for t in trends]
counts = Counter(categories)
# Counter({'tech': 8, 'beauty': 5, 'outdoor': 3, 'pet': 2})
print(counts.most_common(3)) # Top 3 categories
In trendpilot-loop.py's _search_supplier_catalog, you manually score
each product with a loop. If you needed to count how often each category
appears in the catalog, Counter would do it in one line.
---
You write this pattern in _record_post() (line 1427):
posted = {}
if POST_LOG.exists():
try:
with open(POST_LOG) as f:
posted = json.load(f)
except (json.JSONDecodeError, OSError):
posted = {}
That's correct. But sometimes you need to accumulate into nested dicts and
you keep checking "does this key exist first?":
# Without defaultdict
category_counts = {}
for trend in trends:
cat = trend.get("category_en", "unknown")
if cat not in category_counts:
category_counts[cat] = 0
category_counts[cat] += 1
# With defaultdict
from collections import defaultdict
category_counts = defaultdict(int) # Missing keys default to 0
for trend in trends:
cat = trend.get("category_en", "unknown")
category_counts[cat] += 1 # No key check needed
defaultdict(list) is also common when accumulating lists:
products_by_category = defaultdict(list)
for product in catalog:
products_by_category[product["category"]].append(product)
# products_by_category["tech"] is automatically [] if you access it
---
| Structure | Use when | Example in your code |
| ----------- | ---------- | --------------------- |
| `list` | Ordered, may have duplicates | keyword lists, trend list |
| `tuple` | Ordered, immutable, fixed size | return multiple values |
| `dict` | Key-value lookups, flexible keys | KEYWORDS, product_info |
| `set` | Membership testing, deduplication | `existing` in load_extra_keywords |
| `dataclass` | Structured records with named fields | Should replace product_info |
| `Counter` | Frequency counts | Category analysis |
| `defaultdict` | Dict where missing keys have a default | Grouping by category |
| `Enum` | Fixed set of named constants | ProductCategory in amazon-price-tracker |
---
Exercise 1 β Trace the rotation:
With len(KEYWORDS["resumeforge"]) == 30 and count=2:
Write out the math, then verify by calling pick_keywords("resumeforge", 0),
pick_keywords("resumeforge", 15), pick_keywords("resumeforge", 16).
Exercise 2 β Convert product_info to a dataclass:
Copy daily-content-agent.py's product_info dict (lines 360β397) and
convert it to a @dataclass called ProductInfo with fields: name, url,
angle, phone. Make phone optional with a default of None.
Create a module-level PRODUCTS dict that maps product keys to ProductInfo
instances. Update write_article() to use PRODUCTS instead of the local dict.
Exercise 3 β Use Counter:
In trendpilot-loop.py's analyze() function, after building the viable
list, add 3 lines that use Counter to print a summary: "Category breakdown:
tech: 4, beauty: 2, outdoor: 1". Import Counter from collections.
Exercise 4 β Set operations:
Python sets support union (|), intersection (&), and difference (-).
The word-overlap check in trendpilot-loop.py line 426β430:
name_words = set(w.lower() for w in name.split() if len(w) > 2 and w.lower() not in skip_words)
title_words = set(w.lower() for w in title_lower.split() if len(w) > 2 and w.lower() not in skip_words)
overlap = len(name_words & title_words) / max(len(name_words), 1)
name_words & title_words is a set intersection. len(...) gives the count of
shared words. Can you extend this to also compute the words that are in the
title but NOT in the name? That's title_words - name_words. Try it in a Python
interpreter.
Source files: All four scripts
---
Your four scripts show three different approaches to organizing code:
Level 1 β Pure functions (daily-content-agent.py)
Everything is a standalone function. write_article() takes arguments, does
work, returns a value. No shared state except module-level globals. This is
actually fine for scripts that do one job.
Level 2 β Thin class wrapper (trendpilot-loop.py)
TrendPilotLoop is a class, but all the interesting code is in module-level
functions (observe, analyze, source, etc.). The class just holds dry_run
and results and delegates. This is a transitional pattern.
Level 3 β Full OOP (amazon-price-tracker/main.py)
AmazonPriceMonitor holds all state (db_path, api_key, config, running)
and all behavior (add_product, fetch_amazon_price, run_monitor). The class
is the unit of organization.
Neither level is objectively better. The question is: *what problem does the
class solve?*
---
A class helps when you have:
1. State that persists between function calls β the monitor needs to remember
its running flag, its config, its db_path
2. Multiple related functions that share the same data β every method in
AmazonPriceMonitor needs self.db_path, self.api_key, self.config
3. Multiple instances β you could create two monitors watching different
databases: us_monitor = AmazonPriceMonitor("us.db") and
eu_monitor = AmazonPriceMonitor("eu.db")
Compare to daily-content-agent.py: there's no state to share. KEYWORDS is
a constant. Each function call is independent. A class would add complexity
without benefit.
---
class AmazonPriceMonitor:
# __init__ is called when you do AmazonPriceMonitor()
# "self" refers to this specific instance
def __init__(self, config_path: str = "config.json"):
self.db_path = "amazon_price_monitor.db" # instance attribute
self.api_key = os.getenv('RAPID_API_KEY') # instance attribute
self.running = True # instance attribute
self.setup_database() # call another method
# A method β just a function that gets "self" as first argument
def add_product(self, product: Dict) -> bool:
conn = sqlite3.connect(self.db_path) # uses self.db_path
# ...
def run_monitor(self):
while self.running: # checks self.running
# ...
def stop_monitor(self):
self.running = False # modifies self.running β run_monitor sees this
self is just the name for "this instance." It's passed automatically β
you don't pass it when calling methods:
monitor = AmazonPriceMonitor() # __init__ called with self=monitor
monitor.add_product(data) # add_product called with self=monitor
monitor.stop_monitor() # stop_monitor called with self=monitor
---
TrendPilotLoop in trendpilot-loop.py (line 1629):
class TrendPilotLoop:
def __init__(self, dry_run=False):
self.dry_run = dry_run
self.log_entries = []
self.results = {}
def run_full_cycle(self, ...):
# calls module-level functions: observe(), analyze(), source()...
# stores results in self.results
The problem: self.results is set at the end of run_full_cycle but self.log_entries
is never actually used anywhere in the class. The "results" are just stored in a
dict that callers then have to reach into. The module-level functions like
observe() and analyze() have no access to self.dry_run β they receive it
as a parameter.
If you were going to use a class here, either:
The hybrid approach causes confusion.
---
Here's how you might refactor daily-content-agent.py into a class *if* you
wanted to manage state (like tracking which articles have been written):
class ContentAgent:
def __init__(self, workspace: Path, api_key: str):
self.workspace = workspace
self.api_key = api_key
self.blog_dir = workspace / "blog-drafts"
self.articles_written = 0 # Track progress this run
self._day_index = None # Lazy-compute once
@property
def day_index(self) -> int:
"""Compute day index once and cache it."""
if self._day_index is None:
start_date = datetime(2026, 7, 2)
self._day_index = max(0, (datetime.now() - start_date).days)
return self._day_index
def pick_keywords(self, product: str, count: int = 2) -> list[str]:
keywords = KEYWORDS.get(product, [])
if not keywords:
return []
start = (self.day_index * count) % len(keywords)
return [keywords[(start + i) % len(keywords)] for i in range(count)]
def write_article(self, product: str, keyword: str) -> Path:
# ... can use self.api_key, self.blog_dir, self.articles_written
self.articles_written += 1
return file_path
def run(self, products: list[str]):
for product in products:
keywords = self.pick_keywords(product)
for keyword in keywords:
self.write_article(product, keyword)
print(f"Written {self.articles_written} articles")
The @property decorator (line 3) is covered in Lesson 08, but notice it lets
you call agent.day_index like an attribute (no ()) while still computing it
the first time. The result is cached in self._day_index.
---
Classes can inherit from other classes. amazon-price-tracker doesn't use it,
but the exception we created in Lesson 01 does:
class QwenError(Exception):
pass
QwenError inherits from Exception. It gets all of Exception's behavior
(can be raised, caught, has a message) but is its own distinct type. You can
except QwenError without catching all exceptions.
You don't need deep inheritance hierarchies for your use cases. A one-level
"inherit from Exception to make a custom error type" is the most useful form.
---
Python modules (.py files) are also a unit of organization. A file full of
related functions is valid design. Don't make everything a class just because
you learned OOP.
Rule of thumb:
---
Exercise 1 β Read and understand:
Open amazon-price-tracker/main.py. Find where threading and Queue are
imported (line 17β18) but never used in the class. This is dead code.
Look at run_monitor() (line 525). What would you need to change to use a
Queue to receive "stop" signals from another thread instead of the self.running
flag? (Sketch it, don't implement it.)
Exercise 2 β Add a class attribute:
In AmazonPriceMonitor, check_interval = 300 is set in __init__. Move it
to a *class attribute* (outside __init__, at the class level):
class AmazonPriceMonitor:
CHECK_INTERVAL = 300 # class attribute β shared by all instances
def __init__(self, config_path: str = "config.json"):
self.check_interval = self.config.get("check_interval", self.CHECK_INTERVAL)
What's the difference between self.check_interval (instance attribute) and
AmazonPriceMonitor.CHECK_INTERVAL (class attribute)?
Exercise 3 β Create a class:
trendpilot-loop.py's _already_posted and _record_post functions both read
and write the same JSON file. Create a PostLog class that:
Replace the two module-level functions with this class. Instantiate it once at
the top of publish().
Exercise 4 β Think about it:
daily-content-agent.py runs as a cron job once per day. Do you actually need
a class for it? What would be the benefit? What would be the cost? Write 2β3
sentences with your opinion.
Source files: scripts/fb-reel-upload.py vs scripts/trendpilot-loop.py
---
fb-reel-upload.py parses command-line arguments manually (lines 21β37):
VIDEO_PATH = sys.argv[1] if len(sys.argv) > 1 else None
MODE = sys.argv[2] if len(sys.argv) > 2 else "slideshow"
MODE = MODE.lower()
if "--caption" in sys.argv:
idx = sys.argv.index("--caption")
if idx + 1 < len(sys.argv):
CAPTION_OVERRIDE = sys.argv[idx + 1]
if "--page" in sys.argv:
idx = sys.argv.index("--page")
if idx + 1 < len(sys.argv):
PAGE_TARGET = sys.argv[idx + 1].lower()
trendpilot-loop.py uses argparse (lines 1835β1844):
parser = argparse.ArgumentParser(description="TrendPilot AI β Closed-Loop Autopilot")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--observe-only", action="store_true")
parser.add_argument("--limit", type=int, default=20)
args = parser.parse_args()
The manual approach breaks silently in ways that argparse prevents.
---
Problem 1: Positional argument collision
VIDEO_PATH = sys.argv[1] if len(sys.argv) > 1 else None
MODE = sys.argv[2] if len(sys.argv) > 2 else "slideshow"
This assumes arg 1 is always the video path and arg 2 is always the mode.
But what if someone writes:
python3 fb-reel-upload.py --page epic video.mp4
Now sys.argv[1] is "--page" (not the video path) and sys.argv[2] is
"epic" (not the mode). The script silently tries to upload a file named
--page.
Problem 2: No --help
Run python3 fb-reel-upload.py --help. You get the Python default help
message (useless). With argparse, --help is automatically generated from
your argument definitions.
Problem 3: No type validation
--limit in trendpilot uses type=int. If you pass --limit foo, argparse
prints: error: argument --limit: invalid int value: 'foo'. With manual
parsing, you'd get a ValueError deep inside the script.
Problem 4: No required-argument enforcement
If you forget to pass VIDEO_PATH, the script crashes at line 161 with
"β Video not found: None". That's not a helpful error. argparse would have
said: error: the following arguments are required: video_path.
---
import argparse
def parse_args():
parser = argparse.ArgumentParser(
description="Upload video as Facebook Reel",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s video.mp4
%(prog)s video.mp4 --slideshow --page epic
%(prog)s video.mp4 --caption "Custom caption here"
"""
)
parser.add_argument(
"video_path",
help="Path to the video file to upload"
)
parser.add_argument(
"--mode",
choices=["slideshow", "scene"],
default="slideshow",
help="Caption style (default: slideshow)"
)
parser.add_argument(
"--caption",
help="Override the default caption"
)
parser.add_argument(
"--page",
choices=["epic", "brandboost", "both"],
default="both",
help="Which Facebook page to upload to (default: both)"
)
return parser.parse_args()
def main():
args = parse_args()
# Now use args.video_path, args.mode, args.caption, args.page
# No positional collision possible
if not os.path.exists(args.video_path):
print(f"β Video not found: {args.video_path}")
sys.exit(1)
caption = args.caption or CAPTIONS[args.mode]
# ...
---
Positional arguments (required, no --):
parser.add_argument("video_path", help="Path to video file")
# args.video_path
Optional flags (optional, have --):
parser.add_argument("--dry-run", action="store_true", help="Preview only")
# args.dry_run (note: hyphen becomes underscore)
Optional with value (optional, but requires a value if present):
parser.add_argument("--limit", type=int, default=20, help="Max items")
# args.limit β 20 by default, or int you passed
Choices (must be one of a fixed set):
parser.add_argument("--page", choices=["epic", "brandboost", "both"], default="both")
Short forms (common):
parser.add_argument("-n", "--dry-run", action="store_true")
# Either -n or --dry-run works
---
A good pattern: put your actual logic in functions, and keep main() thin:
# The logic β testable, importable
def upload_video(video_path: str, mode: str, caption: str, page: str):
...
# The CLI β parses args, calls logic
def main():
args = parse_args()
caption = args.caption or CAPTIONS[args.mode]
upload_video(args.video_path, args.mode, caption, args.page)
if __name__ == "__main__":
main()
This matters because:
1. You can test upload_video() without going through the CLI
2. Another script can from fb_reel_upload import upload_video and call it directly
3. main() stays readable β it's just "parse, then call"
---
Your scripts use sys.exit(1) correctly when they fail. The convention:
Shell scripts and cron jobs check this:
python3 fb-reel-upload.py video.mp4
if [ $? -ne 0 ]; then
echo "Upload failed!"
fi
---
Exercise 1 β Add --help to fb-reel-upload.py:
Rewrite the argument parsing section (lines 20β38) using argparse. Keep
all the same flags (--caption, --page) but now add them properly.
Run python3 fb-reel-upload.py --help and verify the help text is generated.
Exercise 2 β Fix the positional arg bug:
With your argparse version, test that this no longer breaks:
python3 fb-reel-upload.py --page epic cleaneats-tiktok-final.mp4
With the old sys.argv approach, this tries to upload a file called --page.
With argparse, it correctly identifies cleaneats-tiktok-final.mp4 as the video.
Exercise 3 β Add a subcommand:
trendpilot-loop.py has --observe-only, --learn-only, --source-only, etc.
These are flags that really should be *subcommands*. Rewrite the CLI to use
subcommands:
python3 trendpilot-loop.py observe
python3 trendpilot-loop.py learn
python3 trendpilot-loop.py run --dry-run --limit 5
Research parser.add_subparsers().
Exercise 4 β Type coercion:
In trendpilot-loop.py, --limit uses type=int. What happens if you run:
python3 trendpilot-loop.py --limit 3.5
What if you run:
python3 trendpilot-loop.py --limit five
Try both. What does argparse print? How would you add a validator to reject
limits below 1 or above 100?
Source files: scripts/fb-reel-upload.py, scripts/daily-content-agent.py
---
Line 16 of fb-reel-upload.py:
FB_EPICS_TOKEN = open('/home/ron/.openclaw/workspace/.fb_page_token').read().strip()
Two problems:
1. The file handle is never closed β it leaks until Python's garbage collector
gets around to it (nondeterministic)
2. If .fb_page_token doesn't exist, you get FileNotFoundError at *import
time*, before any helpful error message prints
The fix for both is the with statement (context manager):
try:
with open('/home/ron/.openclaw/workspace/.fb_page_token') as f:
FB_EPICS_TOKEN = f.read().strip()
except FileNotFoundError:
print("ERROR: .fb_page_token not found. Run setup first.")
sys.exit(1)
The with statement guarantees f.close() is called even if an exception
occurs mid-read. It's the Python idiom for any resource that needs cleanup.
---
# This:
with open("file.txt") as f:
data = f.read()
# f is closed here, guaranteed
# Is equivalent to:
f = open("file.txt")
try:
data = f.read()
finally:
f.close()
The with version is shorter and impossible to get wrong. Always use with
when opening files.
You can open multiple files in one with:
with open("input.txt") as src, open("output.txt", "w") as dst:
dst.write(src.read())
---
open("file.txt") # "r" default β read text, error if not found
open("file.txt", "r") # same
open("file.txt", "w") # write text, creates if not found, TRUNCATES if exists
open("file.txt", "a") # append text, creates if not found, does NOT truncate
open("file.txt", "rb") # read binary (for images, PDFs, etc.)
open("file.txt", "wb") # write binary
open("file.txt", "x") # create new, error if already exists (safe write)
Your fb-reel-upload.py log at line 213:
with open('/home/ron/.openclaw/workspace/logs/fb-reel-upload.log', 'a') as f:
f.write(f"\n[{datetime.now().isoformat()}]\n")
The 'a' mode is correct here β appending to the log without erasing it.
---
You use pathlib.Path well in daily-content-agent.py:
WORKSPACE = Path(__file__).parent.parent
BLOG_DIR = WORKSPACE / "blog-drafts"
LOG_FILE = WORKSPACE / "logs" / "daily-content.log"
But fb-reel-upload.py uses raw strings:
open('/home/ron/.openclaw/workspace/.fb_page_token')
open('/home/ron/.openclaw/workspace/logs/fb-reel-upload.log', 'a')
The hardcoded path means the script breaks if you move the workspace. Better:
from pathlib import Path
WORKSPACE = Path(__file__).parent.parent
TOKEN_FILE = WORKSPACE / ".fb_page_token"
LOG_FILE = WORKSPACE / "logs" / "fb-reel-upload.log"
try:
FB_EPICS_TOKEN = TOKEN_FILE.read_text().strip()
except FileNotFoundError:
print(f"ERROR: Token file not found: {TOKEN_FILE}")
sys.exit(1)
Path.read_text() opens, reads, and closes in one call. Equivalent to:
with open(TOKEN_FILE) as f:
FB_EPICS_TOKEN = f.read().strip()
---
You're already using many of these, but for completeness:
p = Path("/home/ron/.openclaw/workspace/scripts/daily-content-agent.py")
p.exists() # True/False
p.is_file() # True
p.is_dir() # False
p.name # "daily-content-agent.py"
p.stem # "daily-content-agent"
p.suffix # ".py"
p.parent # Path("/home/ron/.openclaw/workspace/scripts")
p.parent.parent # Path("/home/ron/.openclaw/workspace")
# Reading/writing
p.read_text() # file contents as string
p.read_bytes() # file contents as bytes
p.write_text("content") # write string (truncates first)
p.write_bytes(b"content") # write bytes
# Navigation
WORKSPACE = Path("/home/ron/.openclaw/workspace")
WORKSPACE / "scripts" / "my.py" # Path("/home/ron/.openclaw/workspace/scripts/my.py")
# Listing
list(WORKSPACE.glob("*.py")) # all .py files in workspace
list(WORKSPACE.glob("**/*.py")) # all .py files recursively
list(WORKSPACE.rglob("*.py")) # same
sorted(DATA_DIR.glob("*.json"), reverse=True) # you do this in trendpilot
# Making directories
p.mkdir(parents=True, exist_ok=True) # you do this correctly in trendpilot
# Stat
p.stat().st_mtime # modification time (seconds since epoch)
---
Pattern from trendpilot-loop.py's _record_post (lines 1432β1436):
posted = {}
if POST_LOG.exists():
try:
with open(POST_LOG) as f:
posted = json.load(f)
except (json.JSONDecodeError, OSError):
posted = {}
This is the right pattern. The two exceptions you need:
You can also use Path.read_text():
try:
posted = json.loads(POST_LOG.read_text())
except (json.JSONDecodeError, OSError, FileNotFoundError):
posted = {}
FileNotFoundError is a subclass of OSError, so catching OSError covers it.
But being explicit about FileNotFoundError makes the code more readable.
---
The naive write:
with open(path, "w") as f:
json.dump(data, f)
Problem: if the process crashes mid-write, you get a partial/corrupted file.
If this file was your posted_products.json, you'd lose your deduplication state.
Safer pattern using a temp file + atomic rename:
import tempfile
def write_json_safe(path: Path, data: dict):
"""Write JSON to a temp file, then rename β atomic on Linux."""
tmp = path.with_suffix('.tmp')
try:
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
tmp.rename(path) # atomic on same filesystem
except Exception:
tmp.unlink(missing_ok=True) # cleanup on failure
raise
This matters for files that track important state (like your post log or
keyword expansion marker). For blog articles, the naive write is fine.
---
daily-content-agent.py lines 32β40:
if not DASHSCOPE_API_KEY:
env_file = WORKSPACE / ".env"
if env_file.exists():
with open(env_file) as f:
for line in f:
if line.strip().startswith("DASHSCOPE_API_KEY="):
DASHSCOPE_API_KEY = line.strip().split("=", 1)[1]
break
This works, but doesn't handle:
The python-dotenv library handles all of this:
pip install python-dotenv
from dotenv import load_dotenv
load_dotenv(WORKSPACE / ".env") # loads all variables into os.environ
DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
But: your current code works for your use case. Don't add a dependency unless
it solves an actual problem you have.
---
Exercise 1 β Fix fb-reel-upload.py token loading:
Lines 16β19 open token files without with and without error handling.
Rewrite them using pathlib and proper error handling. If a token file doesn't
exist, print a helpful message and exit cleanly instead of crashing.
Exercise 2 β Fix fb-reel-upload.py hardcoded paths:
Define WORKSPACE = Path(__file__).parent.parent at the top of the file.
Replace all hardcoded /home/ron/.openclaw/workspace/... paths with
WORKSPACE / "..." equivalents.
Exercise 3 β Trace the glob:
In trendpilot-loop.py line 213:
live_files = sorted(DATA_DIR.glob("dropship_trends_*.json"), reverse=True)
What does * match in a glob pattern? What would **/*.json match? Open a
Python shell and try:
from pathlib import Path
list(Path(".").glob("*.py"))
Exercise 4 β Atomic write:
Your _record_post function in trendpilot writes the JSON directly:
with open(POST_LOG, "w") as f:
json.dump(posted, f, indent=2)
Rewrite it using the write_json_safe pattern from this lesson. Test that
if you interrupt it mid-write, the original file is not corrupted.
(Hint: you can simulate a crash with raise RuntimeError("crash") inside the
write, before the rename.)
Source files: scripts/daily-content-agent.py, scripts/trendpilot-loop.py
---
You make API calls in both scripts. Compare them:
daily-content-agent.py lines 212β238 β bare-bones, no retry:
def qwen_call(prompt, model="qwen-plus", temperature=0.7, max_tokens=4000):
headers = {
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
"Content-Type": "application/json",
}
payload = { ... }
try:
req = urllib.request.Request(QWEN_API_URL, data=json.dumps(payload).encode(),
headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
except Exception as e:
return {"error": str(e)} # β returns a dict on error (see Lesson 01)
trendpilot-loop.py lines 114β163 β retry, rate limit handling, specific errors:
def shopify_api_call(url, method="GET", data=None, headers=None, max_retries=3):
for attempt in range(max_retries):
try:
req = urllib.request.Request(url, ...)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 429:
retry_after = int(e.headers.get("Retry-After", "2"))
time.sleep(retry_after)
continue
elif e.code == 401:
return {"error": "Shopify token expired", "status": "token_expired"}
else:
return {"error": f"Shopify API {e.code}: ...", "status": "http_error"}
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # exponential backoff
continue
return {"error": f"Network error after {max_retries} retries: {e}"}
shopify_api_call is much better. But it still returns error dicts instead of raising.
And neither version handles one important case: what does the JSON actually contain?
---
Every API call is:
1. You send an HTTP request (method + URL + headers + body)
2. The server sends back a response (status code + body)
3. You parse the body (usually JSON)
4. You handle errors (bad status codes, network failures, unexpected JSON shape)
Your script ββPOSTβββΆ https://dashscope-intl.aliyuncs.com/...
β
βββ200 OKβββββββ
body: {"choices": [{"message": {"content": "article text"}}]}
Status codes that matter:
---
Your scripts use urllib.request (built into Python β no install required).
Most tutorials use the requests library (requires pip install requests).
# urllib.request (what your scripts use)
import urllib.request, json
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
# requests (simpler syntax)
import requests
resp = requests.post(url, json=payload, headers=headers, timeout=30)
resp.raise_for_status() # raises HTTPError for 4xx/5xx
data = resp.json()
Key difference: requests.raise_for_status() auto-raises on bad status codes.
With urllib, a 429 or 500 raises urllib.error.HTTPError β which you're already catching.
The requests version is cleaner for new code. For your existing scripts, stick with
urllib unless you're already adding dependencies β it works fine.
---
This line in your Qwen call is doing a lot:
return data["choices"][0]["message"]["content"]
What can go wrong:
The trendpilot-loop.py version handles this better (line 193):
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
This uses .get() with defaults so it never crashes on a bad response β it returns
an empty string instead. But an empty string might silently write a blank article.
Better: detect the empty case and raise:
def _parse_qwen_response(data: dict) -> str:
try:
content = data["choices"][0]["message"]["content"]
if not content:
raise ValueError(f"Empty content in response: {data}")
return content
except (KeyError, IndexError) as e:
raise ValueError(f"Unexpected Qwen response shape: {data}") from e
---
Here's what qwen_call looks like when you apply everything from this lesson
(plus Lesson 01's exception principle and Lesson 07's retry pattern):
import urllib.request, urllib.error
import json, time
class QwenError(Exception):
pass
def qwen_call(prompt, model="qwen-plus", temperature=0.7, max_tokens=4000,
max_retries=3) -> str:
"""Call Qwen API. Always returns str. Raises QwenError on failure."""
if not DASHSCOPE_API_KEY:
raise QwenError("DASHSCOPE_API_KEY not set")
headers = {
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
last_err = None
for attempt in range(max_retries):
try:
req = urllib.request.Request(
QWEN_API_URL,
data=json.dumps(payload).encode(),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
content = data["choices"][0]["message"]["content"]
if not content:
raise QwenError("Empty response from Qwen")
return content
except urllib.error.HTTPError as e:
body = e.read().decode()[:300]
if e.code == 429:
wait = int(e.headers.get("Retry-After", 2 ** attempt))
print(f" Qwen rate limited, waiting {wait}s (attempt {attempt+1})")
time.sleep(wait)
last_err = QwenError(f"Rate limited: {body}")
continue
elif e.code in (500, 503):
time.sleep(2 ** attempt)
last_err = QwenError(f"HTTP {e.code}: {body}")
continue
else:
raise QwenError(f"HTTP {e.code}: {body}") from e
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
wait = 2 ** attempt
print(f" Qwen network error, retrying in {wait}s: {e}")
time.sleep(wait)
last_err = QwenError(str(e))
raise last_err or QwenError("Max retries exceeded")
What changed from your current version:
---
Your scripts read API keys from .env files with this pattern:
DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
if not DASHSCOPE_API_KEY:
env_file = WORKSPACE / ".env"
if env_file.exists():
with open(env_file) as f:
for line in f:
if line.strip().startswith("DASHSCOPE_API_KEY="):
DASHSCOPE_API_KEY = line.strip().split("=", 1)[1]
break
This works. The only issue: if .env has DASHSCOPE_API_KEY="value" with quotes,
you'd include the quotes in the key. Add a .strip('"\''):
DASHSCOPE_API_KEY = line.strip().split("=", 1)[1].strip().strip('"\'')
Or use the python-dotenv package: pip install python-dotenv and
from dotenv import load_dotenv; load_dotenv() β it handles all the edge cases.
---
Exercise 1 β Read the retry logic:
In trendpilot-loop.py, find shopify_api_call (around line 114). Draw a flowchart
of what happens on:
Exercise 2 β Test the response parser:
Write a function parse_qwen_response(data: dict) -> str that handles:
What should each case return or raise?
Exercise 3 β Add retry to qwen_call:
In daily-content-agent.py, modify qwen_call to retry up to 3 times on
urllib.error.HTTPError with code 429, with a 5-second wait between attempts.
Don't change anything else β just add the retry loop.
Exercise 4 β Identify what status codes your APIs return:
Pick one of your API integrations (Shopify, Qwen, Facebook). Look at the code
and list which HTTP status codes you currently handle explicitly vs. which ones
fall through to the generic except Exception handler. What would happen if
Qwen returned a 503?
Source files: scripts/trendpilot-loop.py, scripts/daily-content-agent.py
---
trendpilot-loop.py has retry logic in shopify_api_call (lines 138β163).
daily-content-agent.py's qwen_call has no retry at all.
If you wanted retry in three different functions, you'd copy the loop each time.
That's three places to fix if you change the backoff formula. Decorators solve this.
---
A decorator is a function that wraps another function to add behavior before
or after it runs β without changing the function itself.
You already use them without thinking about it. In Lesson 04's OOP example:
@property
def is_profitable(self):
return self.margin > 0.3
@property is a built-in decorator. It wraps is_profitable so accessing
product.is_profitable runs the function instead of returning the function object.
The @ syntax is just shorthand:
# These two are identical:
@some_decorator
def my_function():
...
# Same as:
def my_function():
...
my_function = some_decorator(my_function)
---
Here's the retry logic that's currently inside shopify_api_call, extracted into
a decorator:
import time
import functools
def retry(max_attempts=3, wait=2, exceptions=(Exception,)):
"""Decorator: retry a function up to max_attempts times on exception."""
def decorator(func):
@functools.wraps(func) # preserves __name__ and __doc__
def wrapper(*args, **kwargs):
last_err = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
last_err = e
if attempt < max_attempts - 1:
sleep_time = wait * (2 ** attempt) # exponential backoff
print(f" Retry {attempt+1}/{max_attempts} for {func.__name__}: {e}")
time.sleep(sleep_time)
raise last_err
return wrapper
return decorator
Now instead of manually writing retry loops, you decorate:
import urllib.error
@retry(max_attempts=3, wait=1, exceptions=(urllib.error.URLError, TimeoutError))
def qwen_call(prompt, model="qwen-plus", temperature=0.7, max_tokens=4000) -> str:
headers = {"Authorization": f"Bearer {DASHSCOPE_API_KEY}", ...}
req = urllib.request.Request(QWEN_API_URL, ...)
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
The function body contains only the happy path. The retry logic lives in one place.
---
@functools.wraps(func) is important. Without it:
print(qwen_call.__name__) # β "wrapper" β wrong, hard to debug
print(qwen_call.__doc__) # β None β lost the docstring
# With @functools.wraps:
print(qwen_call.__name__) # β "qwen_call" β correct
print(qwen_call.__doc__) # β "Call Qwen Cloud API..." β preserved
Always use @functools.wraps(func) when writing decorators.
---
Another common one β seeing how long your API calls take:
import time
import functools
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f" {func.__name__} took {elapsed:.2f}s")
return result
return wrapper
@timed
def qwen_call(prompt, ...):
...
# Output: " qwen_call took 4.37s"
You can stack decorators:
@timed
@retry(max_attempts=3, wait=1, exceptions=(urllib.error.URLError,))
def qwen_call(prompt, ...):
...
They apply bottom-up: qwen_call is first wrapped by retry, then the result is
wrapped by timed. So timed sees the total time including retries.
---
Lesson 06 covered with open(...) for files. Context managers are the same
pattern for anything that has setup and teardown:
# File β you already use this
with open("data.json") as f:
data = json.load(f)
# Database connection
with sqlite3.connect("db.sqlite") as conn:
conn.execute("INSERT INTO ...")
# conn.close() called automatically
# Timing (stdlib)
import contextlib, time
@contextlib.contextmanager
def timer(label):
start = time.perf_counter()
yield # code inside the with block runs here
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.2f}s")
with timer("Qwen call"):
article = qwen_call(prompt)
# prints: "Qwen call: 4.37s"
---
@contextlib.contextmanager lets you write a context manager as a generator:
import contextlib
from pathlib import Path
@contextlib.contextmanager
def atomic_write(path: Path):
"""Write to a temp file, then rename to final path on success.
If the write fails halfway, the original file is untouched.
"""
tmp_path = path.with_suffix(path.suffix + ".tmp")
try:
with open(tmp_path, "w") as f:
yield f # caller writes to f here
tmp_path.rename(path) # rename is atomic on Linux
except:
tmp_path.unlink(missing_ok=True)
raise
# Usage:
with atomic_write(file_path) as f:
f.write(article)
# If qwen_call failed before we got here, the original file is intact
This is useful in your write_article() function β if the HTML generation crashes
after you've already written the markdown, you'd want atomic behavior.
---
Here's the pattern in daily-content-agent.py for writing each article:
# Current code (daily-content-agent.py ~line 570)
for keyword in keywords:
file_path = write_article(product, keyword, day_index, article_num)
With decorators and context managers, write_article becomes:
@timed
@retry(max_attempts=2, wait=5, exceptions=(QwenError,))
def write_article(product: str, keyword: str, day_index: int, article_num: int) -> str:
slug = slugify(keyword)
file_path = BLOG_DIR / f"{product}-{slug}.md"
prompt = build_prompt(product, keyword) # extracted into its own function
article = qwen_call(prompt) # raises QwenError on failure
with atomic_write(file_path) as f: # atomic β no half-written files
f.write(article)
return str(file_path)
Each concern is handled in one place:
---
Exercise 1 β Understand the mechanics:
Read this and predict the output before running it:
def shout(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done with {func.__name__}")
return result
return wrapper
@shout
def add(a, b):
return a + b
print(add(2, 3))
Then run it. Were you right?
Exercise 2 β Write the retry decorator:
Copy the retry decorator from this lesson into a file scripts/utils.py.
Add a test at the bottom:
attempt_count = 0
@retry(max_attempts=3, wait=0, exceptions=(ValueError,))
def flaky():
global attempt_count
attempt_count += 1
if attempt_count < 3:
raise ValueError("not yet")
return "ok"
result = flaky()
print(result, attempt_count) # should print: ok 3
Exercise 3 β Add @functools.wraps:
Take the shout decorator from Exercise 1 and add @functools.wraps(func).
Then run print(add.__name__) with and without it. What changes?
Exercise 4 β Write a context manager:
Write a context manager called log_step(name) that:
(and re-raises the exception after printing)
Test it by wrapping your qwen_call in a scratch script.
Source files: scripts/daily-content-agent.py, scripts/trendpilot-loop.py
---
daily-content-agent.py writes 14 articles per day (2 per product Γ 7 products).
Each Qwen API call takes roughly 10β30 seconds. They run one after the other:
for product in products:
for keyword in pick_keywords(product, day_index, count=2):
write_article(product, keyword, day_index, article_num)
That's up to 7 minutes of wall-clock time where most of your program is just
waiting for the network. Your CPU sits idle while bytes trickle in.
The same pattern appears in trendpilot-loop.py where it calls Qwen for product
descriptions, trend analysis, and strategy β all sequential.
With async, you could fire all 14 calls at once and wait for them all to finish.
Estimated improvement: 7 minutes β under 1 minute.
---
Before reaching for async, understand what you're waiting on:
| Waiting for | Tool | Why |
| ------------- | ------ | ----- |
| Network I/O (API calls, HTTP) | `asyncio` | The wait is in the kernel, CPU is free |
| File I/O on slow disk | `asyncio` with `aiofiles` | Same idea |
| CPU-heavy computation | `multiprocessing` | GIL blocks threads for CPU work |
| Blocking library code | `threading` | Releases the GIL during I/O |
Your scripts are waiting on network I/O. That's the asyncio case.
---
Normally, your program runs instructions one at a time:
Start Qwen call 1 βββΆ wait 15s βββΆ receive βββΆ write file
Start Qwen call 2 βββΆ wait 12s βββΆ receive βββΆ write file
...
Total: 15 + 12 + ... = 105 seconds
With async, Python can switch tasks while waiting:
Start Qwen call 1 βββΆ (waiting...) βββββββββββββββββββΆ receive βββΆ write file
Start Qwen call 2 βββΆ (waiting...) βββββββββββββΆ receive βββΆ write file
Start Qwen call 3 βββΆ (waiting...) βββββββββββββββΆ receive βββΆ write
...
Total: max(15, 12, ...) β 30 seconds (longest single call)
The key word is waiting. Async only helps when your program is blocked waiting
for something external (network, disk). If you're crunching numbers, async won't help.
---
import asyncio
# Mark a function as async with "async def"
async def say_hello():
print("Hello")
await asyncio.sleep(1) # "await" suspends here, lets other tasks run
print("World")
# Run it
asyncio.run(say_hello())
await is the pause point. While this coroutine is suspended at await,
the event loop can run other coroutines.
You can only use await inside async def functions.
---
Your current qwen_call uses urllib.request.urlopen which blocks the entire thread.
To use it with asyncio, switch to aiohttp (a third-party library):
pip install aiohttp
import aiohttp, asyncio, json
async def qwen_call_async(session: aiohttp.ClientSession,
prompt: str,
model: str = "qwen-plus",
temperature: float = 0.7,
max_tokens: int = 4000) -> str:
"""Async version of qwen_call. Pass a shared session for connection reuse."""
headers = {
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
async with session.post(QWEN_API_URL, json=payload, headers=headers,
timeout=aiohttp.ClientTimeout(total=120)) as resp:
resp.raise_for_status() # raises on 4xx/5xx
data = await resp.json()
return data["choices"][0]["message"]["content"]
Key differences from the sync version:
---
import asyncio
import aiohttp
async def write_article_async(session, product, keyword, day_index, article_num):
slug = slugify(keyword)
file_path = BLOG_DIR / f"{product}-{slug}.md"
prompt = build_prompt(product, keyword)
try:
article = await qwen_call_async(session, prompt)
file_path.write_text(article)
print(f" DONE {product}/{keyword[:40]}")
return str(file_path)
except Exception as e:
print(f" ERROR {product}/{keyword[:40]}: {e}")
file_path.write_text(f"# {keyword.title()}\n\n*Writing...*\n")
return str(file_path)
async def main_async():
products = ["resumeforge", "clara", "clozr", "localeye", "agentseek", "agentpay", "cleaneats"]
day_index = get_day_index()
tasks = []
async with aiohttp.ClientSession() as session:
for product in products:
for i, keyword in enumerate(pick_keywords(product, day_index, count=2)):
task = write_article_async(session, product, keyword, day_index, i + 1)
tasks.append(task)
# Fire all 14 tasks at once, wait for all to finish
results = await asyncio.gather(*tasks, return_exceptions=True)
return [r for r in results if isinstance(r, str)]
if __name__ == "__main__":
urls = asyncio.run(main_async())
submit_to_gsc(urls)
asyncio.gather(*tasks) starts all tasks concurrently and returns when all finish.
return_exceptions=True means a failed task returns the exception instead of
crashing everything β important when you have 14 tasks and one might hit a rate limit.
---
Sending 14 requests simultaneously might trigger Qwen's rate limiter (429).
Add a semaphore to cap concurrent requests:
async def main_async():
semaphore = asyncio.Semaphore(3) # max 3 concurrent Qwen calls
async def limited_write(session, product, keyword, day_index, article_num):
async with semaphore: # blocks if 3 are already running
return await write_article_async(session, product, keyword, day_index, article_num)
async with aiohttp.ClientSession() as session:
tasks = [
limited_write(session, product, keyword, day_index, i + 1)
for product in products
for i, keyword in enumerate(pick_keywords(product, day_index, count=2))
]
return await asyncio.gather(*tasks, return_exceptions=True)
With Semaphore(3), at most 3 calls run at once. 14 calls become roughly 5 batches
of 3 β still about 5x faster than sequential.
---
If you don't want to rewrite your functions as async, threading gives you
concurrency with your existing sync code. HTTP calls release the GIL during
network waits, so threads actually help here:
from concurrent.futures import ThreadPoolExecutor, as_completed
def main_threaded():
products = ["resumeforge", "clara", "clozr", "localeye", "agentseek", "agentpay", "cleaneats"]
day_index = get_day_index()
tasks = [
(product, keyword, day_index, i + 1)
for product in products
for i, keyword in enumerate(pick_keywords(product, day_index, count=2))
]
results = []
with ThreadPoolExecutor(max_workers=3) as pool:
futures = {
pool.submit(write_article, product, keyword, day_index, n): (product, keyword)
for product, keyword, day_index, n in tasks
}
for future in as_completed(futures):
product, keyword = futures[future]
try:
results.append(future.result())
except Exception as e:
print(f" ERROR {product}/{keyword}: {e}")
return results
ThreadPoolExecutor is easier to add to existing sync code β no need to change
function signatures. The tradeoff: threads use more memory than async coroutines.
Rule of thumb: If you're starting fresh, use asyncio. If you're adding
concurrency to existing sync code, start with ThreadPoolExecutor.
---
Don't add async complexity for scripts that run one-off tasks quickly. Async pays
off when:
For a script you run manually once, sequential is fine and much simpler to debug.
---
Exercise 1 β Understand the event loop:
Run this and predict the output before you do:
import asyncio
async def task(name, delay):
print(f"Start {name}")
await asyncio.sleep(delay)
print(f"Done {name}")
async def main():
await asyncio.gather(
task("A", 2),
task("B", 1),
task("C", 3),
)
asyncio.run(main())
What order does "Done" print? Why?
Exercise 2 β Time the difference:
Write two versions of "fetch N URLs" β one sequential with urllib.request, one
concurrent with ThreadPoolExecutor(max_workers=5). Use 10 calls to
https://httpbin.org/delay/1 (each takes 1 second). Time both with time.perf_counter().
You should see roughly 10s vs 2s.
Exercise 3 β Add a semaphore:
In the main_async function above, change Semaphore(3) to Semaphore(1).
What does that make the concurrent version equivalent to? What's the point of
asyncio.gather at that point?
Exercise 4 β Add concurrency to daily-content-agent.py:
Add ThreadPoolExecutor(max_workers=3) to the main() function in
daily-content-agent.py so all 14 write_article calls run concurrently
(up to 3 at a time). Don't change write_article itself. Time how long it
takes before and after.
Source files: amazon-price-tracker/main.py, scripts/daily-content-agent.py
---
You have scripts running in production β writing articles, posting to Facebook,
managing Shopify inventory β and zero automated tests. That means:
amazon-price-tracker/main.py is your most structured script. It has classes,
type hints, real database code. It's also completely untested. If check_price_drops
has an off-by-one error in its percentage calculation, you'd only find out when you
stopped getting alerts.
---
Not everything needs a test. The rule: test pure functions (same input β same
output, no network, no files). Don't test code that's mostly network calls β test the
logic around those calls.
In amazon-price-tracker/main.py, these are worth testing:
# Pure logic β easy to test
def should_send_alert(current_price: float, target_price: float) -> bool:
return current_price <= target_price
# Pure logic β easy to test
def calculate_savings(original: float, current: float) -> float:
return original - current
def calculate_savings_pct(original: float, current: float) -> float:
if original == 0:
return 0.0
return ((original - current) / original) * 100
These don't hit the network or database. You can run them in milliseconds.
# Harder β touches SQLite, file system
def setup_database(self):
conn = sqlite3.connect(self.db_path)
...
# Hard β network call to Amazon
def fetch_amazon_price(self, asin: str):
...
For the database and network code, you test the *behavior* by mocking the call.
---
Install: pip install pytest
Create a file: tests/test_price_logic.py
# tests/test_price_logic.py
def should_send_alert(current_price: float, target_price: float) -> bool:
return current_price <= target_price
def calculate_savings_pct(original: float, current: float) -> float:
if original == 0:
return 0.0
return ((original - current) / original) * 100
# Tests β any function named test_* is auto-discovered by pytest
def test_alert_triggers_when_below_target():
assert should_send_alert(current_price=4.50, target_price=5.00) is True
def test_alert_does_not_trigger_when_above_target():
assert should_send_alert(current_price=6.00, target_price=5.00) is False
def test_alert_triggers_at_exact_target():
assert should_send_alert(current_price=5.00, target_price=5.00) is True
def test_savings_pct_normal():
pct = calculate_savings_pct(original=10.00, current=7.50)
assert pct == 25.0
def test_savings_pct_zero_original():
# Guard against division by zero
pct = calculate_savings_pct(original=0.0, current=5.00)
assert pct == 0.0
def test_savings_pct_no_savings():
pct = calculate_savings_pct(original=10.00, current=10.00)
assert pct == 0.0
Run: pytest tests/
Output:
collected 6 items
tests/test_price_logic.py ...... [100%]
6 passed in 0.03s
Each . is a passing test. A F is a failure β pytest shows you exactly what
the expected vs actual values were.
---
unittest.mock lets you replace a real function with a fake one during a test.
Use this to test code that normally makes network calls:
# tests/test_price_monitor.py
from unittest.mock import patch, MagicMock
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from amazon-price-tracker.main import AmazonPriceMonitor
@patch("main.requests.get") # replace requests.get with a fake
def test_fetch_price_returns_float(mock_get):
# Set up what the fake should return
mock_response = MagicMock()
mock_response.json.return_value = {
"data": {"product": {"current_price": "9.99"}}
}
mock_response.status_code = 200
mock_get.return_value = mock_response
monitor = AmazonPriceMonitor.__new__(AmazonPriceMonitor)
monitor.base_url = "https://real-time-amazon-data.p.rapidapi.com"
monitor.api_key = "fake-key"
price = monitor.fetch_amazon_price("B08H95Y452")
assert price == 9.99
assert isinstance(price, float)
@patch("main.requests.get") intercepts the requests.get call and returns your
mock_response instead. The network never gets called. The test runs in milliseconds.
---
daily-content-agent.py has pure functions worth testing immediately:
# tests/test_content_agent.py
import sys
sys.path.insert(0, "scripts")
from daily_content_agent import slugify, pick_keywords
def test_slugify_basic():
assert slugify("how to write a resume") == "how-to-write-a-resume"
def test_slugify_truncates_at_60():
long = "a" * 100
assert len(slugify(long)) <= 60
def test_slugify_lowercases():
assert slugify("UPPER CASE") == "upper-case"
def test_pick_keywords_returns_correct_count():
keywords = pick_keywords("resumeforge", day_index=0, count=2)
assert len(keywords) == 2
def test_pick_keywords_rotates_by_day():
# Day 0 and day 15 should start at different positions
kw_day0 = pick_keywords("resumeforge", day_index=0, count=1)
kw_day15 = pick_keywords("resumeforge", day_index=15, count=1)
# They might overlap (wrapping), but the logic should rotate
assert isinstance(kw_day0[0], str)
assert isinstance(kw_day15[0], str)
def test_pick_keywords_unknown_product():
# Unknown product returns empty list
keywords = pick_keywords("nonexistent_product", day_index=0, count=2)
assert keywords == []
Run: pytest tests/test_content_agent.py -v
The -v flag shows each test name and pass/fail individually.
---
Every test follows the same three-step pattern:
def test_alert_triggers_when_below_target():
# Arrange β set up inputs
current_price = 4.50
target_price = 5.00
# Act β call the code being tested
result = should_send_alert(current_price, target_price)
# Assert β check the result
assert result is True
Keep each test focused on one behavior. If a test fails, the name should tell
you exactly what broke.
---
A test you only run when you remember is not much better than no test.
Option 1: Run before every commit with a shell alias
# In ~/.bashrc or ~/.zshrc
alias commit='pytest tests/ && git commit'
Option 2: GitHub Actions (runs on every push)
Create .github/workflows/test.yml:
name: Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pytest aiohttp
- run: pytest tests/ -v
Every time you push to GitHub, this runs. If a test fails, GitHub marks the commit
red and sends you an email.
---
Your scripts already use cron. Here's how the full deployment lifecycle works for
a script like daily-content-agent.py:
View current cron jobs:
crontab -l
Add a new cron job:
crontab -e
Cron syntax: minute hour day month weekday command
# Run daily-content-agent.py at 6:00 AM every day
0 6 * * * /usr/bin/python3 /home/ron/.openclaw/workspace/scripts/daily-content-agent.py >> /home/ron/.openclaw/workspace/logs/daily-content.log 2>&1
# Run price tracker every 5 minutes
*/5 * * * * /usr/bin/python3 /home/ron/.openclaw/workspace/amazon-price-tracker/main.py >> /home/ron/.openclaw/workspace/logs/price-tracker.log 2>&1
>> logfile 2>&1 redirects both stdout and stderr to the log file, appending.
Always use absolute paths in cron β cron doesn't have your PATH or current
directory. python3 might not be found; use /usr/bin/python3 or the full path
from which python3.
Check if cron is running what you think:
grep CRON /var/log/syslog | tail -20
---
The key insight: code that's hard to test is usually hard to understand and maintain.
Testability is a proxy for good design.
The biggest improvement you can make to daily-content-agent.py:
# Before: logic mixed with side effects β hard to test
def write_article(product, keyword, day_index, article_num):
slug = slugify(keyword)
file_path = BLOG_DIR / f"{product}-{slug}.md"
prompt = f"""Write an SEO blog article...""" # built inline
article = qwen_call(prompt) # network call
if isinstance(article, dict) and "error" in article:
...
file_path.write_text(article) # file write
html_path.write_text(md_to_html(...)) # another file write
return str(file_path)
# After: pure functions separated from side effects
def build_article_prompt(product: str, keyword: str) -> str:
"""Pure β no network, no files. Easy to test."""
...
def parse_article_response(raw: str) -> str:
"""Pure β validates and cleans the response. Easy to test."""
...
def write_article(product, keyword, day_index, article_num):
"""Thin orchestrator β hard to test, but thin means less can go wrong."""
prompt = build_article_prompt(product, keyword) # testable
article = qwen_call(prompt) # external β mock in tests
cleaned = parse_article_response(article) # testable
file_path.write_text(cleaned) # side effect
Now build_article_prompt and parse_article_response are just functions that
take strings and return strings. You can test 20 cases in 20ms without touching
the network or file system.
---
Exercise 1 β Your first test file:
Create tests/test_slugify.py. Import slugify from daily-content-agent.py
and write 4 tests:
Run pytest tests/test_slugify.py -v. Fix any failures you find β they might
reveal an actual bug.
Exercise 2 β Test pick_keywords rotation:
Write a test that verifies pick_keywords with day_index=0 returns different
keywords than day_index=1 (for the same product and count). If this test fails,
the rotation logic is broken.
Exercise 3 β Mock the API:
In daily-content-agent.py, find a function that calls qwen_call and does
something with the result. Write a test using unittest.mock.patch that:
Exercise 4 β Add tests to CI:
Push your tests/ folder to GitHub. Create .github/workflows/test.yml using
the template above. Make a commit and watch the Actions tab β does the green
checkmark appear?