Yesterday's lambda sorted products by a key you passed in as a string. I want to start with a question: what would happen if you already had all the products sitting in a list and you called inventory_report("Morning Batch", products)? Would the function receive the products correctly?
No — that would pass the whole list as a single argument. If the function uses *products to collect individual dicts, it would get a tuple containing one list, not a tuple of dicts.
Exactly. And that's today's first unlock. The * operator works in two directions. In a definition, *products collects incoming positional arguments into a tuple. In a call, *my_list spreads an iterable into separate positional arguments — as if you'd typed each element out by hand separated by commas.
So if I have a list of ten products and I write inventory_report("Title", *products), Python literally turns that into inventory_report("Title", p1, p2, p3, ...)? At the moment of the call?
At the moment of the call, yes. The spread happens before the function even runs. Then the function's *products parameter collects those individual arguments back into a tuple. Pack → spread → collect — three steps, two lines of code:
products = [
{"name": "Widget-A", "sku": "SKU-1001", "stock": 150, "price": 24.99},
{"name": "Bolt-Set-M6", "sku": "SKU-2042", "stock": 0, "price": 4.99},
]
# These two calls are identical:
inventory_report("Morning Batch", products[0], products[1])
inventory_report("Morning Batch", *products)The * on the call side and the * in the definition are inverses. One releases, one catches. Same symbol, opposite jobs.
Packing on the definition side, unpacking on the call side. Once that pair is in your head you can read any Python call involving * and know what's happening. Now — ** does the same for keyword arguments. A dict can be spread into keyword arguments:
options = {"show_total": False}
inventory_report("Quick Scan", *products, **options)
# identical to:
inventory_report("Quick Scan", *products, show_total=False)That's how decorator forwarding works. The decorator receives *args and **kwargs and passes both directly to the wrapped function without needing to know its signature.
You just derived the standard decorator pattern from first principles. Now let me add the second piece for today — docstrings. A docstring is a triple-quoted string as the very first statement of a function body. It's not a comment. Python stores it in function.__doc__ and tools like help() read it live.
How is that different from a # comment above the function?
A # comment is for whoever reads the source file. A docstring is for whoever uses the function without opening the source. Six months from now at 11pm when you can't remember what the third parameter does, you'll type help(inventory_report) at the REPL and the docstring will answer. Comments are invisible to that.
def inventory_report(title: str, *products: dict, show_total: bool = True) -> str:
"""Build a formatted inventory report string.
Args:
title: The report heading printed on the first line.
*products: Variable number of product dicts, each with
keys 'name', 'sku', 'stock', and 'price'.
show_total: When True, appends a total items line. Default True.
Returns:
A newline-joined string ready to print or log.
"""
lines = [title]
for p in products:
lines.append(f" {p['name']} ({p['sku']}): {p['stock']} in stock @ ${p['price']}")
if show_total:
lines.append(f" Total items: {sum(p['stock'] for p in products)}")
return "\n".join(lines)I notice *products in the Args section has no asterisk. Is that intentional?
Convention. The asterisk is implementation syntax, not part of the name. In the docstring you describe what the parameter means, not how Python collects it. Same rule applies to **kwargs — document without the stars.
And sum(p['stock'] for p in products) — that's a generator expression. No intermediate list, just streams directly into sum().
Right. And products at that point is the tuple the collector built — you iterate it exactly like any sequence. Now let's see the full call in practice:
monday_batch = [
{"name": "Widget-A", "sku": "SKU-1001", "stock": 150, "price": 24.99},
{"name": "Safety-Vest", "sku": "SKU-3300", "stock": 30, "price": 14.99},
{"name": "Drill-Bit", "sku": "SKU-2042", "stock": 200, "price": 9.99},
]
report = inventory_report("Monday AM", *monday_batch)
# Monday AM
# Widget-A (SKU-1001): 150 in stock @ $24.99
# Safety-Vest (SKU-3300): 30 in stock @ $14.99
# Drill-Bit (SKU-2042): 200 in stock @ $9.99
# Total items: 380Twelve products, forty products — same function call, same two lines. And I can call help(inventory_report) at any point and get the full contract.
Show Diane the help() output. She'll think you wrote a user manual.
I'm absolutely doing that. She doesn't need to know it was four lines of triple-quoted text.
Next week: control flow. range, break, match. The flow of execution stops being purely linear and you'll start directing loops rather than just writing them. But first — write inventory_report. Docstring first, then build the lines, then the conditional total.
Docstring, lines list, loop, conditional total, join and return. I have the whole shape.
The * and ** operators work in two directions: on the definition side they collect arguments into a container; on the call side they spread a container into individual arguments.
# Definition side — *products collects positional args into a tuple
def inventory_report(title: str, *products: dict, show_total: bool = True) -> str:
...
# Call side — *monday_batch spreads a list into separate positional arguments
monday_batch = [
{"name": "Widget-A", "sku": "SKU-1001", "stock": 150, "price": 24.99},
{"name": "Drill-Bit","sku": "SKU-2042", "stock": 200, "price": 9.99},
]
inventory_report("Monday AM", *monday_batch) # spreads list
inventory_report("Monday AM", *monday_batch, **{"show_total": False}) # spreads list + dictDocstrings (PEP 257): A triple-quoted string as the first statement of a function, class, or module body. Python stores it in __doc__ and makes it accessible via help(), IDEs, and documentation generators. A # comment above the function is invisible to tooling — a docstring is not.
PEP 257 multi-line format:
*args or **kwargs in the Args section — document the name and meaning onlyPitfall 1: Passing a list without spreading it. inventory_report("Title", products) passes the whole list as the first product. The * on the call side is required: inventory_report("Title", *products).
Pitfall 2: Confusing the two * directions. In a definition *name is a net that catches; in a call *iterable is a valve that releases. Same symbol, opposite semantics. Context (definition vs call) determines which applies.
Pitfall 3: Treating docstrings as optional. Functions that seem obvious now become mysteries in three months. The cost of writing a docstring is negligible. The cost of not having one when help() is called at 11pm is an hour.
** on the call side spreads dicts into keyword arguments — func(**config_dict) is a common pattern for passing configuration objects. Combined with *args/**kwargs in the definition, it enables full argument forwarding: def wrapper(*args, **kwargs): return original(*args, **kwargs) — the foundation of Python decorators.
Google, NumPy, and Sphinx docstring formats extend PEP 257 with structured sections. Most large Python codebases standardize on one format and enforce it via linting (pydocstyle, flake8-docstrings). The inspect.getdoc() function normalizes whitespace in docstrings — useful when reading them programmatically.
Sign up to write and run code in this lesson.
Yesterday's lambda sorted products by a key you passed in as a string. I want to start with a question: what would happen if you already had all the products sitting in a list and you called inventory_report("Morning Batch", products)? Would the function receive the products correctly?
No — that would pass the whole list as a single argument. If the function uses *products to collect individual dicts, it would get a tuple containing one list, not a tuple of dicts.
Exactly. And that's today's first unlock. The * operator works in two directions. In a definition, *products collects incoming positional arguments into a tuple. In a call, *my_list spreads an iterable into separate positional arguments — as if you'd typed each element out by hand separated by commas.
So if I have a list of ten products and I write inventory_report("Title", *products), Python literally turns that into inventory_report("Title", p1, p2, p3, ...)? At the moment of the call?
At the moment of the call, yes. The spread happens before the function even runs. Then the function's *products parameter collects those individual arguments back into a tuple. Pack → spread → collect — three steps, two lines of code:
products = [
{"name": "Widget-A", "sku": "SKU-1001", "stock": 150, "price": 24.99},
{"name": "Bolt-Set-M6", "sku": "SKU-2042", "stock": 0, "price": 4.99},
]
# These two calls are identical:
inventory_report("Morning Batch", products[0], products[1])
inventory_report("Morning Batch", *products)The * on the call side and the * in the definition are inverses. One releases, one catches. Same symbol, opposite jobs.
Packing on the definition side, unpacking on the call side. Once that pair is in your head you can read any Python call involving * and know what's happening. Now — ** does the same for keyword arguments. A dict can be spread into keyword arguments:
options = {"show_total": False}
inventory_report("Quick Scan", *products, **options)
# identical to:
inventory_report("Quick Scan", *products, show_total=False)That's how decorator forwarding works. The decorator receives *args and **kwargs and passes both directly to the wrapped function without needing to know its signature.
You just derived the standard decorator pattern from first principles. Now let me add the second piece for today — docstrings. A docstring is a triple-quoted string as the very first statement of a function body. It's not a comment. Python stores it in function.__doc__ and tools like help() read it live.
How is that different from a # comment above the function?
A # comment is for whoever reads the source file. A docstring is for whoever uses the function without opening the source. Six months from now at 11pm when you can't remember what the third parameter does, you'll type help(inventory_report) at the REPL and the docstring will answer. Comments are invisible to that.
def inventory_report(title: str, *products: dict, show_total: bool = True) -> str:
"""Build a formatted inventory report string.
Args:
title: The report heading printed on the first line.
*products: Variable number of product dicts, each with
keys 'name', 'sku', 'stock', and 'price'.
show_total: When True, appends a total items line. Default True.
Returns:
A newline-joined string ready to print or log.
"""
lines = [title]
for p in products:
lines.append(f" {p['name']} ({p['sku']}): {p['stock']} in stock @ ${p['price']}")
if show_total:
lines.append(f" Total items: {sum(p['stock'] for p in products)}")
return "\n".join(lines)I notice *products in the Args section has no asterisk. Is that intentional?
Convention. The asterisk is implementation syntax, not part of the name. In the docstring you describe what the parameter means, not how Python collects it. Same rule applies to **kwargs — document without the stars.
And sum(p['stock'] for p in products) — that's a generator expression. No intermediate list, just streams directly into sum().
Right. And products at that point is the tuple the collector built — you iterate it exactly like any sequence. Now let's see the full call in practice:
monday_batch = [
{"name": "Widget-A", "sku": "SKU-1001", "stock": 150, "price": 24.99},
{"name": "Safety-Vest", "sku": "SKU-3300", "stock": 30, "price": 14.99},
{"name": "Drill-Bit", "sku": "SKU-2042", "stock": 200, "price": 9.99},
]
report = inventory_report("Monday AM", *monday_batch)
# Monday AM
# Widget-A (SKU-1001): 150 in stock @ $24.99
# Safety-Vest (SKU-3300): 30 in stock @ $14.99
# Drill-Bit (SKU-2042): 200 in stock @ $9.99
# Total items: 380Twelve products, forty products — same function call, same two lines. And I can call help(inventory_report) at any point and get the full contract.
Show Diane the help() output. She'll think you wrote a user manual.
I'm absolutely doing that. She doesn't need to know it was four lines of triple-quoted text.
Next week: control flow. range, break, match. The flow of execution stops being purely linear and you'll start directing loops rather than just writing them. But first — write inventory_report. Docstring first, then build the lines, then the conditional total.
Docstring, lines list, loop, conditional total, join and return. I have the whole shape.
The * and ** operators work in two directions: on the definition side they collect arguments into a container; on the call side they spread a container into individual arguments.
# Definition side — *products collects positional args into a tuple
def inventory_report(title: str, *products: dict, show_total: bool = True) -> str:
...
# Call side — *monday_batch spreads a list into separate positional arguments
monday_batch = [
{"name": "Widget-A", "sku": "SKU-1001", "stock": 150, "price": 24.99},
{"name": "Drill-Bit","sku": "SKU-2042", "stock": 200, "price": 9.99},
]
inventory_report("Monday AM", *monday_batch) # spreads list
inventory_report("Monday AM", *monday_batch, **{"show_total": False}) # spreads list + dictDocstrings (PEP 257): A triple-quoted string as the first statement of a function, class, or module body. Python stores it in __doc__ and makes it accessible via help(), IDEs, and documentation generators. A # comment above the function is invisible to tooling — a docstring is not.
PEP 257 multi-line format:
*args or **kwargs in the Args section — document the name and meaning onlyPitfall 1: Passing a list without spreading it. inventory_report("Title", products) passes the whole list as the first product. The * on the call side is required: inventory_report("Title", *products).
Pitfall 2: Confusing the two * directions. In a definition *name is a net that catches; in a call *iterable is a valve that releases. Same symbol, opposite semantics. Context (definition vs call) determines which applies.
Pitfall 3: Treating docstrings as optional. Functions that seem obvious now become mysteries in three months. The cost of writing a docstring is negligible. The cost of not having one when help() is called at 11pm is an hour.
** on the call side spreads dicts into keyword arguments — func(**config_dict) is a common pattern for passing configuration objects. Combined with *args/**kwargs in the definition, it enables full argument forwarding: def wrapper(*args, **kwargs): return original(*args, **kwargs) — the foundation of Python decorators.
Google, NumPy, and Sphinx docstring formats extend PEP 257 with structured sections. Most large Python codebases standardize on one format and enforce it via linting (pydocstyle, flake8-docstrings). The inspect.getdoc() function normalizes whitespace in docstrings — useful when reading them programmatically.