Skip to content

Commit a19cc7c

Browse files
travisjneumanclaude
andcommitted
feat: add Module 02 — CLI Tools curriculum
Five projects covering Click and Typer for building command-line tools: 01 Click Basics, 02 Multi-Command CLI, 03 Interactive Prompts, 04 File Processor CLI (with Rich progress bars), 05 Typer Migration. Each project includes project.py with extensive comments, README with alter/break/fix/explain exercises, and a notes.md template. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a89f1aa commit a19cc7c

20 files changed

Lines changed: 1176 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Module 02 / Project 01 -- Click Basics
2+
3+
Home: [README](../../../../README.md)
4+
5+
## Focus
6+
7+
- `@click.command()` decorator to define a CLI entry point
8+
- `@click.option()` for optional flags like `--shout`
9+
- `@click.argument()` for required positional values
10+
- Automatic `--help` text generation
11+
12+
## Why this project exists
13+
14+
Most Python scripts start with `if __name__ == "__main__"` and a handful of `input()` calls. That works for throwaway code, but real tools need named options, help text, and predictable argument parsing. Click handles all of that with decorators you stack on top of a plain function. This project shows you how.
15+
16+
## Run
17+
18+
```bash
19+
cd projects/modules/02-cli-tools/01-click-basics
20+
21+
# Basic greeting
22+
python project.py World
23+
24+
# Greeting with the --shout flag
25+
python project.py World --shout
26+
27+
# View auto-generated help
28+
python project.py --help
29+
30+
# Greeting with a custom greeting word
31+
python project.py World --greeting Howdy
32+
```
33+
34+
## Expected output
35+
36+
```text
37+
$ python project.py World
38+
Hello, World!
39+
40+
$ python project.py World --shout
41+
HELLO, WORLD!
42+
43+
$ python project.py World --greeting Howdy
44+
Howdy, World!
45+
46+
$ python project.py --help
47+
Usage: project.py [OPTIONS] NAME
48+
49+
Greet someone by name. A small first step into Click.
50+
51+
Options:
52+
--greeting TEXT Word to use for the greeting. [default: Hello]
53+
--shout Uppercase the entire output.
54+
--help Show this message and exit.
55+
```
56+
57+
## Alter it
58+
59+
1. Add a `--repeat` option (integer, default 1) that prints the greeting N times.
60+
2. Add a `--farewell` flag that prints a goodbye message after the greeting.
61+
3. Change the default greeting word from "Hello" to something else and confirm `--help` reflects the change.
62+
63+
## Break it
64+
65+
1. Remove the `@click.argument("name")` decorator and run the script. What error do you get?
66+
2. Change `is_flag=True` on `--shout` to `type=int`. Try running with `--shout`. What happens?
67+
3. Pass two positional arguments instead of one. How does Click respond?
68+
69+
## Fix it
70+
71+
1. Restore the missing decorator and confirm the greeting works again.
72+
2. Revert `--shout` back to a boolean flag and verify `--shout` toggles uppercasing.
73+
3. Read the Click docs on `nargs` and decide whether your tool should accept multiple names.
74+
75+
## Explain it
76+
77+
1. What does `@click.command()` do that a bare function does not?
78+
2. How does Click generate the `--help` text -- where does it pull the description from?
79+
3. What is the difference between `@click.option()` and `@click.argument()` in terms of required vs optional?
80+
4. Why does Click use decorators instead of requiring you to subclass something?
81+
82+
## Mastery check
83+
84+
You can move on when you can:
85+
- write a Click command from scratch without copying this file,
86+
- explain what `is_flag=True` does vs a typed option,
87+
- add a new option, re-run `--help`, and confirm it appears,
88+
- break and recover in one session.
89+
90+
## Next
91+
92+
Continue to [02 - Multi-Command CLI](../02-multi-command-cli/).
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Notes -- Module 02 / Project 01 -- Click Basics
2+
3+
## What I learned
4+
5+
6+
## What confused me
7+
8+
9+
## What I want to explore next
10+
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Module 02 / Project 01 -- Click Basics.
2+
3+
This script builds a simple greeting CLI to demonstrate the three
4+
core Click decorators: @click.command(), @click.option(), and
5+
@click.argument().
6+
7+
Run it with:
8+
python project.py World
9+
python project.py World --shout
10+
python project.py --help
11+
"""
12+
13+
# click is a third-party library for building command-line interfaces.
14+
# Install it with: pip install click
15+
import click
16+
17+
18+
# @click.command() turns this function into a CLI entry point.
19+
# Click reads the docstring and uses it as the help description.
20+
@click.command()
21+
# @click.argument() defines a required positional value.
22+
# "name" becomes the first thing the user types after the script name.
23+
@click.argument("name")
24+
# @click.option() defines an optional flag.
25+
# --greeting has a default value so the user can skip it.
26+
@click.option(
27+
"--greeting",
28+
default="Hello",
29+
help="Word to use for the greeting.",
30+
show_default=True,
31+
)
32+
# is_flag=True means --shout is a boolean toggle: present = True, absent = False.
33+
@click.option(
34+
"--shout",
35+
is_flag=True,
36+
help="Uppercase the entire output.",
37+
)
38+
def greet(name, greeting, shout):
39+
"""Greet someone by name. A small first step into Click."""
40+
41+
# Build the message from the greeting word and the name argument.
42+
message = f"{greeting}, {name}!"
43+
44+
# If the user passed --shout, convert everything to uppercase.
45+
if shout:
46+
message = message.upper()
47+
48+
# click.echo() is Click's version of print().
49+
# It handles encoding issues on Windows and plays nicely with pipes.
50+
click.echo(message)
51+
52+
53+
# Standard Python entry-point guard.
54+
# When you run "python project.py", Python sets __name__ to "__main__".
55+
# Without this guard, importing the file would execute the CLI immediately.
56+
if __name__ == "__main__":
57+
# Call the decorated function. Click takes over argument parsing here.
58+
greet()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Module 02 / Project 02 -- Multi-Command CLI
2+
3+
Home: [README](../../../../README.md)
4+
5+
## Focus
6+
7+
- `@click.group()` to bundle related commands under one tool
8+
- Defining subcommands with `@group.command()`
9+
- Sharing options across subcommands
10+
- Building a practical file utility
11+
12+
## Why this project exists
13+
14+
Real CLI tools rarely do just one thing. Git has `commit`, `push`, `log`. Docker has `build`, `run`, `stop`. Click's group system lets you organize multiple commands under a single entry point so users type `tool info myfile.txt` instead of remembering five separate scripts. This project shows you how to build that structure.
15+
16+
## Run
17+
18+
```bash
19+
cd projects/modules/02-cli-tools/02-multi-command-cli
20+
21+
# Show top-level help
22+
python project.py --help
23+
24+
# Show file info (size, modified date)
25+
python project.py info project.py
26+
27+
# Count lines, words, and characters
28+
python project.py count project.py
29+
30+
# Show the first 5 lines (default)
31+
python project.py head project.py
32+
33+
# Show the first 10 lines
34+
python project.py head project.py --lines 10
35+
```
36+
37+
## Expected output
38+
39+
```text
40+
$ python project.py info project.py
41+
File: project.py
42+
Size: 2,341 bytes
43+
Modified: 2026-02-24 14:30:00
44+
45+
$ python project.py count project.py
46+
Lines: 87
47+
Words: 312
48+
Chars: 2,341
49+
50+
$ python project.py head project.py --lines 3
51+
"""Module 02 / Project 02 -- Multi-Command CLI.
52+
...first 3 lines of file...
53+
```
54+
55+
(Exact numbers will vary depending on the file.)
56+
57+
## Alter it
58+
59+
1. Add a `tail` subcommand that shows the last N lines of a file.
60+
2. Add a `--bytes` flag to the `info` subcommand that displays size in KB or MB when the file is large enough.
61+
3. Add a `search` subcommand that takes a `--pattern` option and prints matching lines with line numbers.
62+
63+
## Break it
64+
65+
1. Remove the `@cli.command()` decorator from `info`. Run `python project.py info project.py`. What happens?
66+
2. Pass a filename that does not exist to `count`. How does the tool respond?
67+
3. Change `@click.group()` to `@click.command()` and try running a subcommand. What error appears?
68+
69+
## Fix it
70+
71+
1. Restore the decorator and confirm `info` appears in `--help` again.
72+
2. Add a file-existence check before opening the file, and print a clear error message if the file is missing.
73+
3. Revert to `@click.group()` and verify all subcommands work.
74+
75+
## Explain it
76+
77+
1. What is the relationship between `@click.group()` and `@cli.command()`?
78+
2. How does Click know which subcommand the user wants to run?
79+
3. Why is `click.Path(exists=True)` useful and how does it differ from checking `os.path.exists()` yourself?
80+
4. If two subcommands need the same option, what are your options for avoiding duplication?
81+
82+
## Mastery check
83+
84+
You can move on when you can:
85+
- create a group with at least three subcommands from memory,
86+
- explain how Click dispatches to the correct subcommand,
87+
- add a new subcommand without breaking existing ones,
88+
- handle missing-file errors gracefully.
89+
90+
## Next
91+
92+
Continue to [03 - Interactive Prompts](../03-interactive-prompts/).
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Notes -- Module 02 / Project 02 -- Multi-Command CLI
2+
3+
## What I learned
4+
5+
6+
## What confused me
7+
8+
9+
## What I want to explore next
10+
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Module 02 / Project 02 -- Multi-Command CLI.
2+
3+
A file utility with three subcommands:
4+
info -- show file size and last-modified date
5+
count -- count lines, words, and characters
6+
head -- print the first N lines
7+
8+
Demonstrates click.group() for organizing related commands.
9+
10+
Run it with:
11+
python project.py info project.py
12+
python project.py count project.py
13+
python project.py head project.py --lines 10
14+
"""
15+
16+
import os
17+
from datetime import datetime
18+
19+
import click
20+
21+
22+
# @click.group() turns this function into a command group.
23+
# Instead of doing work itself, it acts as a dispatcher for subcommands.
24+
@click.group()
25+
def cli():
26+
"""A small file utility with info, count, and head subcommands."""
27+
# The group function body usually stays empty.
28+
# Click calls the appropriate subcommand after this runs.
29+
pass
30+
31+
32+
# --------------------------------------------------------------------------- #
33+
# Subcommand: info
34+
# --------------------------------------------------------------------------- #
35+
36+
# @cli.command() registers "info" as a subcommand of the cli group.
37+
@cli.command()
38+
# click.Path(exists=True) tells Click to verify the file exists
39+
# before the function even runs. If the file is missing, Click
40+
# prints a clear error and exits -- no try/except needed.
41+
@click.argument("filepath", type=click.Path(exists=True))
42+
def info(filepath):
43+
"""Show file size and last-modified date."""
44+
45+
# os.path.getsize returns the file size in bytes.
46+
size = os.path.getsize(filepath)
47+
48+
# os.path.getmtime returns the modification time as a Unix timestamp.
49+
# We convert it to a human-readable string.
50+
mtime = os.path.getmtime(filepath)
51+
modified = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
52+
53+
# Print a tidy summary. The comma formatting (:,) adds thousand separators.
54+
click.echo(f"File: {filepath}")
55+
click.echo(f"Size: {size:,} bytes")
56+
click.echo(f"Modified: {modified}")
57+
58+
59+
# --------------------------------------------------------------------------- #
60+
# Subcommand: count
61+
# --------------------------------------------------------------------------- #
62+
63+
@cli.command()
64+
@click.argument("filepath", type=click.Path(exists=True))
65+
def count(filepath):
66+
"""Count lines, words, and characters in a file."""
67+
68+
# Read the entire file into memory.
69+
# For very large files you would stream line by line, but for a
70+
# learning project reading everything at once is clearer.
71+
with open(filepath, "r", encoding="utf-8") as f:
72+
content = f.read()
73+
74+
# splitlines() breaks the text on any newline character.
75+
lines = content.splitlines()
76+
77+
# split() with no arguments splits on any whitespace and
78+
# ignores leading/trailing whitespace -- exactly what wc does.
79+
words = content.split()
80+
81+
# len() on a string gives the character count (including newlines).
82+
chars = len(content)
83+
84+
click.echo(f"Lines: {len(lines):,}")
85+
click.echo(f"Words: {len(words):,}")
86+
click.echo(f"Chars: {chars:,}")
87+
88+
89+
# --------------------------------------------------------------------------- #
90+
# Subcommand: head
91+
# --------------------------------------------------------------------------- #
92+
93+
@cli.command()
94+
@click.argument("filepath", type=click.Path(exists=True))
95+
# --lines defaults to 5. The user can override it.
96+
@click.option(
97+
"--lines", "-n",
98+
default=5,
99+
show_default=True,
100+
help="Number of lines to display from the top.",
101+
)
102+
def head(filepath, lines):
103+
"""Show the first N lines of a file."""
104+
105+
with open(filepath, "r", encoding="utf-8") as f:
106+
# Read all lines, then slice. For huge files you could use
107+
# itertools.islice, but clarity wins here.
108+
all_lines = f.readlines()
109+
110+
# Slice the list to get only the first N lines.
111+
selected = all_lines[:lines]
112+
113+
# Print each line. rstrip() removes the trailing newline so
114+
# click.echo does not double-space the output.
115+
for line in selected:
116+
click.echo(line.rstrip())
117+
118+
119+
# Entry-point guard.
120+
if __name__ == "__main__":
121+
# Calling cli() hands control to Click's group dispatcher.
122+
# Click inspects sys.argv, finds the subcommand name, and
123+
# calls the matching function.
124+
cli()

0 commit comments

Comments
 (0)