Index 01 02 03 04 05 06 07 08 09 10
BEGINNER TRACK

Python for Ron β€” Absolute Beginner Series

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).

---

How to Use This Curriculum

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.

---

The 10 Lessons

#FileKey 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

---

A Note on Errors

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.

---

After This Series

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 β†’

BEGINNER β€” Lesson 01

Lesson 01: What is Python? Your First Program

What is Python, actually?

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.

---

How to run Python

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

---

Your very first line of code

Type this in the REPL and press Enter:


print("Hello, world!")

You'll see:


Hello, world!

What just happened?

---

Variables β€” giving names to things

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.

---

Strings β€” working with text

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.

---

f-strings β€” the better way to combine text

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 +.

---

Comments β€” notes to yourself

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.

---

Your first real script

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.

---

Practice exercises

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.

---

What you learned

ConceptWhat 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.

BEGINNER β€” Lesson 02

Lesson 02: Numbers and Math

Two kinds of numbers

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.

---

Basic arithmetic


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.

---

Why does 10 / 3 give 3.3333...?

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)

---

Mixing integers and floats

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)

---

Storing the result in a variable


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.

---

Updating a variable with math

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: -=, *=, /=

---

Converting between types

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:

---

Order of operations

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.

---

Rounding


pi = 3.14159265
print(round(pi))       # 3    (round to nearest integer)
print(round(pi, 2))    # 3.14 (round to 2 decimal places)

---

A real example: calculating BMI


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.

---

Practice exercises

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?)

---

What you learned

ConceptExample
------------------
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.

BEGINNER β€” Lesson 03

Lesson 03: Making Decisions (if/else)

Why your code needs to make decisions

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.

---

The if statement


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.

---

if/else β€” do one thing or the other


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.

---

if/elif/else β€” multiple conditions


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.

---

Booleans β€” True and False

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

---

Comparison operators

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"

---

Combining conditions with and / or / not

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")

---

Checking if something is in a list


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.

---

A real example: patient triage


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.

---

The "truthiness" trap

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.

---

Practice exercises

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.

---

What you learned

ConceptWhat 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.

BEGINNER β€” Lesson 04

Lesson 04 β€” Doing Things Repeatedly

The Power of Repetition

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.

---

First: What Is a List?

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.

---

The `for` Loop β€” Do This For Each Item


>>> 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.

---

Looping Over Numbers with `range()`

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).

---

Doing Math in a Loop


>>> 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.

---

Loops + Decisions = Real Logic

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.

---

Accumulating Results

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).

---

The `while` Loop β€” Keep Going Until a Condition Changes

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.

---

Breaking Out Early with `break`


>>> 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.

---

Skipping Items with `continue`


>>> 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.

---

What You Just Learned

ConceptWhat It IsExample
-----------------------------
ListCollection of values`[88, 92, 95]`
`for` loopRepeat for each item`for x in list:`
`range()`Generate number sequences`range(1, 11)`
`while` loopRepeat 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`

---

Why This Matters

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.

---

Exercises (15 minutes)

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))

---

Common Mistakes

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!

---

Next: Lesson 05 β€” Organizing Data β†’

BEGINNER β€” Lesson 05

Lesson 05: Organizing Data (Lists, Dictionaries, Tuples)

The problem this solves

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.

---

Lists β€” ordered collections

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.

Accessing items by position


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.

Changing items


patients = ["Alice", "Bob", "Carlos"]
patients[1] = "Robert"
print(patients)   # ["Alice", "Robert", "Carlos"]

Common list operations


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?

Slicing β€” getting a portion of a 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

---

Dictionaries β€” named storage

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.

Accessing values


print(patient["name"])       # Alice
print(patient["spo2"])       # 97

Adding or changing values


patient["heart_rate"] = 82       # add new key
patient["spo2"] = 95             # update existing key

Checking if a key exists


if "heart_rate" in patient:
    print(f"Heart rate: {patient['heart_rate']}")

Safe access with .get()

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

Looping over a dictionary


patient = {"name": "Alice", "age": 45, "spo2": 97}

for key, value in patient.items():
    print(f"{key}: {value}")

Output:


name: Alice
age: 45
spo2: 97

---

Lists of dictionaries β€” the most common pattern

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.

---

Tuples β€” like lists, but locked

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.

---

Nested structures β€” data inside data

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.

---

Common patterns you'll see in real code

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.

---

Practice exercises

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.

---

What you learned

StructureSyntaxUse 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.

BEGINNER β€” Lesson 06

Lesson 06 β€” Reusing Code with Functions

The Problem Functions Solve

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.

---

Your First Function


>>> def greet():
...     print("Good morning, NICU team")
...
>>> greet()
Good morning, NICU team
>>> greet()
Good morning, NICU team

Anatomy:

---

Functions With Input (Parameters)

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

---

Functions That Return Values

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"

---

Default Parameter Values

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'

---

Calling Functions Inside Functions

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

---

Functions + Loops: Processing a Whole List


>>> 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.

---

Variable Scope β€” Where Variables Live

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.

---

What You Just Learned

ConceptWhat It IsExample
-----------------------------
`def`Define a function`def check_spo2(x):`
ParameterInput variable`spo2` in `def f(spo2):`
`return`Send a value back`return result`
CallingRunning the function`check_spo2(91)`
Default valueOptional parameter`def f(x, threshold=88):`
ScopeWhere a variable livesLocal vs global

---

Why This Matters

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.

---

Exercises (15 minutes)

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)

---

Common Mistakes

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!

---

Next: Lesson 07 β€” Working with Text β†’

BEGINNER β€” Lesson 07

Lesson 07: Working with Text (Strings)

Why text manipulation matters

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.

---

Strings are sequences


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

---

Essential string methods

Methods are functions attached to a value. You call them with a dot: value.method().

Case


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)

Cleaning up whitespace

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)

Searching


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

Replacing


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.

---

Splitting and joining β€” working with delimited data

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.

---

f-strings β€” the full power

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

---

Multi-line strings

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.

---

Checking and validating strings


"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

---

Common real-world patterns

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

---

A real example: parsing a log file line

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.

---

Practice exercises

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.

---

What you learned

MethodWhat 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.

BEGINNER β€” Lesson 08

Lesson 08: Reading and Writing Files

Why files matter

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.

---

The basic pattern: open, use, close


# Open a file for reading
file = open("patients.txt", "r")
content = file.read()
file.close()

print(content)

Arguments to open():

ModeMeaning
---------------
`"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

---

The better pattern: with statement

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.

---

Reading files

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

---

Writing files

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")

---

File paths

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")

---

Handling errors: what if the file doesn't exist?

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.

---

Working with CSV files

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.

---

Working with JSON 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.

---

A real example: process a CSV and write a report


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.

---

Practice exercises

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.

---

What you learned

ConceptExample
------------------
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.

BEGINNER β€” Lesson 09

Lesson 09: Scripts That Do Real Things

What "real things" means

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.

---

The os module β€” talking to the operating system

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.

---

Creating, moving, and deleting files


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.

---

Walking a directory tree


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.

---

Using glob to find files by pattern


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.

---

Getting the current time


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

---

Running shell commands from Python

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

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().

---

Command-line arguments

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.

---

A real example: batch file processor


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.

---

Practice exercises

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.

---

What you learned

ModuleWhat 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.

BEGINNER β€” Lesson 10

Lesson 10: Your First Real Script

What this lesson is

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.

---

The problem

You have a file of products exported from somewhere. Each row has: name, category, price, cost, and stock count. You want a script that:

---

Step 1: Create sample input data

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

---

Step 2: Build the script

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()

---

Step 3: Run it


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%
==================================================

---

Understanding what you just wrote

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.

---

Now customize it

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

---

What "real" code looks like

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.

---

What to learn next

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.

---

Full checklist: what you learned in this series

LessonConcepts
-----------------
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()`
05Lists, dictionaries, tuples, indexing, nesting
06Defining functions, parameters, `return`, scope
07String methods, `.split()`, `.join()`, f-string formatting
08`open()`, `with`, reading/writing files, CSV, JSON
09`os`, `shutil`, `glob`, `subprocess`, `sys.argv`, `datetime`
10Putting it all together in a real, structured script

You built this. The code you copied last month β€” you can read it now.

Index 01 02 03 04 05 06 07 08 09 10
INTERMEDIATE TRACK

Python Learning Curriculum β€” Ron Sublett

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.

---

Your Four Reference Scripts

ScriptWhat it doesSkill level
----------------------------------
`scripts/daily-content-agent.py`Writes 14 SEO articles/day via Qwen APIGood procedural Python
`scripts/fb-reel-upload.py`Uploads videos to two FB pagesHas a real crash bug
`scripts/trendpilot-loop.py`Full autonomous e-commerce loopBest error handling of the four
`amazon-price-tracker/main.py`Price monitoring with DB + alertsBest OOP of the four

---

Lessons

#TitleYour codeThe problem
----------------------------------
[01](01-functions.md)Functions Done Right`qwen_call()` in both scriptsReturns 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 StructuresKEYWORDS dict, product catalogRight tool for the right shape
[04](04-oop.md)Classes & OOPProcedural vs `TrendPilotLoop` vs `AmazonPriceMonitor`When classes actually help
[05](05-cli-argparse.md)CLI & argparse`fb-reel-upload.py` sys.argv vs trendpilot argparseManual arg parsing breaks silently
[06](06-file-io.md)File I/O & Context ManagersToken files opened without `with`File handles that never close
[07](07-working-with-apis.md)Working with APIsQwen + Shopify calls in both scriptsRetry, rate limits, response parsing
[08](08-decorators-and-context-managers.md)Decorators & Context ManagersCopy-pasted retry logicWrite it once, use it everywhere
[09](09-async-and-concurrency.md)Async & Concurrency14 sequential Qwen calls7 minutes β†’ under 1 minute
[10](10-testing-and-deployment.md)Testing & Deployment`amazon-price-tracker/main.py` + cronThe bug that slipped through

---

How to use this

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.

INTERMEDIATE β€” Lesson 01

Lesson 01 β€” Functions Done Right

Source files: scripts/daily-content-agent.py and scripts/trendpilot-loop.py

---

The Problem

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.

---

Why This Matters

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 Fix: Raise Exceptions, Don't Return Error Dicts

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)

---

Key Concepts

Return type consistency

A function should return the same type every time. If it sometimes fails, it

should raise an exception β€” not return a different type.

`-> type` annotations

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.

Custom exceptions

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.

The `from e` in `raise QwenError(...) from e`

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.

---

Default Arguments

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.

---

Exercises

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.

INTERMEDIATE β€” Lesson 02

Lesson 02 β€” Error Handling

Source files: scripts/fb-reel-upload.py, scripts/trendpilot-loop.py

---

The Crash Bug in fb-reel-upload.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!")

---

The Exception Hierarchy

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.

---

Good vs Bad Exception Handling

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:

---

Exponential Backoff

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.

---

try / except / else / finally

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

---

The `raise` Statement

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.

---

Exercises

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.)

INTERMEDIATE β€” Lesson 03

Lesson 03 β€” Data Structures

Source files: scripts/daily-content-agent.py, scripts/trendpilot-loop.py

---

You Already Use the Right Ones

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.

---

The KEYWORDS Dict: A Good Pattern

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.

---

The product_info Dict: Should Be a Dataclass

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.

---

Sets: Deduplication

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.

---

Counter: Frequency Counts

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.

---

defaultdict: Skip the Key-Exists Check

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

---

When to Use What

StructureUse whenExample in your code
------------------------------------------
`list`Ordered, may have duplicateskeyword lists, trend list
`tuple`Ordered, immutable, fixed sizereturn multiple values
`dict`Key-value lookups, flexible keysKEYWORDS, product_info
`set`Membership testing, deduplication`existing` in load_extra_keywords
`dataclass`Structured records with named fieldsShould replace product_info
`Counter`Frequency countsCategory analysis
`defaultdict`Dict where missing keys have a defaultGrouping by category
`Enum`Fixed set of named constantsProductCategory in amazon-price-tracker

---

Exercises

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.

INTERMEDIATE β€” Lesson 04

Lesson 04 β€” Classes & OOP

Source files: All four scripts

---

Three Levels of Organization in Your Code

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?*

---

When a Class Helps

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.

---

Anatomy of a Class


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

---

What TrendPilotLoop Does Wrong

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.

---

Writing a Useful Class: ContentAgent

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.

---

Inheritance (Brief)

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.

---

Class vs Module

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:

---

Exercises

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.

INTERMEDIATE β€” Lesson 05

Lesson 05 β€” CLI & argparse

Source files: scripts/fb-reel-upload.py vs scripts/trendpilot-loop.py

---

Two Ways to Parse Arguments

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.

---

The Problem with Manual sys.argv Parsing

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.

---

argparse Rewrite of fb-reel-upload.py


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]
    # ...

---

argparse Reference

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

---

Separating Logic from the CLI

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"

---

sys.exit() and Return Codes

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

---

Exercises

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?

INTERMEDIATE β€” Lesson 06

Lesson 06 β€” File I/O & Context Managers

Source files: scripts/fb-reel-upload.py, scripts/daily-content-agent.py

---

The Unclosed File Bug

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.

---

How Context Managers Work


# 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())

---

File Modes


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.

---

pathlib.Path vs Strings

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()

---

Useful pathlib Methods

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)

---

Reading JSON Files Safely

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.

---

Writing JSON Safely

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.

---

The .env File Parsing You're Doing Manually

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.

---

Exercises

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.)

INTERMEDIATE β€” Lesson 07

Lesson 07 β€” Working with APIs

Source files: scripts/daily-content-agent.py, scripts/trendpilot-loop.py

---

The Problem: Two Scripts, Two Completely Different API Styles

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?

---

How HTTP APIs Actually Work

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:

---

urllib.request vs requests

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.

---

The Tricky Part: Parsing the Response

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

---

A Production-Grade API Call Function

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:

---

Reading API Keys Safely

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.

---

Exercises

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?

INTERMEDIATE β€” Lesson 08

Lesson 08 β€” Decorators and Context Managers

Source files: scripts/trendpilot-loop.py, scripts/daily-content-agent.py

---

The Problem: Copy-Pasted Retry Logic

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.

---

What Is a Decorator?

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)

---

Writing a Retry Decorator

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.

---

How the Decorator Stack Works

@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.

---

A Timing Decorator

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.

---

Context Managers: Beyond Files

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"

---

Writing Your Own Context Manager

@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.

---

Applying Both to Your Scripts

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:

---

Exercises

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.

INTERMEDIATE β€” Lesson 09

Lesson 09 β€” Async and Concurrency

Source files: scripts/daily-content-agent.py, scripts/trendpilot-loop.py

---

The Problem: 14 Sequential API Calls

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.

---

Two Different Problems, Two Different Tools

Before reaching for async, understand what you're waiting on:

Waiting forToolWhy
------------------------
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.

---

What Async Actually Means

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.

---

asyncio Basics


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.

---

Async Version of qwen_call

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:

---

Running 14 Calls Concurrently


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.

---

Rate Limits: Don't Be Too Aggressive

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.

---

Threading: The Alternative

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.

---

When NOT to Use Async

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.

---

Exercises

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.

INTERMEDIATE β€” Lesson 10

Lesson 10 β€” Testing and Deployment

Source files: amazon-price-tracker/main.py, scripts/daily-content-agent.py

---

The Problem: Zero Tests, Zero Safety Net

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.

---

What's Worth Testing

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.

---

pytest Basics

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.

---

Testing with Mocks: Fake the Network

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.

---

Testing Your Content Agent Logic

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.

---

Test Structure: Arrange, Act, Assert

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.

---

Running Tests Automatically: CI and Pre-commit

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.

---

Cron Deployment

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

---

Structuring Code for Testability

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.

---

Exercises

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?