Programming with Python › Python Setup and First Programs › Day 45
Hands-on lab — Day 45: Strings and Text Processing
- ← Back to the Day 45 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-045-strings-and-text-processing/
Commands
Setup
cd labs/sections/programming-with-python/day-045-strings-and-text-processing Run
python3 examples/text_report.py
python3 starter/text_report.py Test
bash tests/run_tests.sh File tree
examples/sample.txt examples/text_report.py expected-output/FIELDS.md expected-output/sample-run.txt metadata.yml README.md requirements/README.md security.md starter/strings-worksheet.md starter/text_report.py tests/run_tests.sh troubleshooting.md
Lab README
Day 045 lab — Build a Text Report
Lesson
- Lesson title: Strings and Text Processing
- Day number: 45 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-045-strings-and-text-processing
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-045-strings-and-text-processingwhen the site is running.
Purpose
Day 45's lesson explains how Python treats text: strings as immutable sequences, slicing, the essential methods, and f-strings. This lab makes it concrete. You take a fixed block of sample text and, using only string methods and f-strings, produce a clean text report: a title-cased heading, line and word and character counts, the number of unique words, the single most common word, and a neatly aligned statistics table. It is the shape of nearly every text-cleaning task you will ever write — count, normalize, summarize, format.
Learning objectives
- Read a text file into a string with an explicit UTF-8 encoding.
- Split text into words and count them with a plain dictionary.
- Normalize words with
strip,lower, and friends (methods return new strings; the original is never changed). - Slice strings with positive and negative indices, including reversal
(
s[::-1]). - Build formatted output with f-strings and format specs (
:<20,:>10,:.1f). - Run an automated test script and read its pass/fail output.
Prerequisites
- The Day 45 lesson (read it first — it explains every string feature this lab uses).
- Python 3 installed (
python3 --versionreports a 3.x version). - No prior text-processing experience; every step is spelled out.
Supported operating systems
- macOS — fully supported (captured on macOS with Python 3.14).
- Linux — fully supported (any distribution with Python 3 and bash).
- Windows — run inside WSL for the
bashtest step, or run the Python program directly in PowerShell and check the numbers againstexpected-output/FIELDS.md.
Hardware requirements
Any computer that can run Python 3. The program reads one small text file and prints a report; it needs no special memory, disk, or GPU.
Required software
python3(3.8 or newer; standard library only —pathlib).bashfor the test runner (preinstalled on macOS and Linux).
Free and open-source options
Everything here is free and open source: Python and its standard library ship at no cost, and no account, API key, or third-party package is required.
Installation
None. Clone the repository (or copy this directory) and change into it:
cd labs/sections/programming-with-python/day-045-strings-and-text-processing
File structure
day-045-strings-and-text-processing/
├── README.md ← you are here
├── metadata.yml ← machine-readable lab metadata
├── starter/
│ ├── text_report.py ← YOUR working file (5 exercises)
│ └── strings-worksheet.md ← record a word count, a slice, an f-string
├── examples/
│ ├── text_report.py ← completed reference implementation
│ └── sample.txt ← the fixed text the report is built from
├── tests/
│ └── run_tests.sh ← automated checks (exact counts + assertions)
├── expected-output/
│ ├── sample-run.txt ← a real captured run
│ └── FIELDS.md ← what a correct run prints, line by line
├── requirements/
│ └── README.md ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md
How to run
From this directory:
## 1. See the finished result first
python3 examples/text_report.py
## 2. Your task: complete the five exercises in the starter, then run it
python3 starter/text_report.py
## 3. Check your work
bash tests/run_tests.sh
What the commands do
python3 examples/text_report.py— runs the reference program: readsexamples/sample.txt, title-cases the first line, splits the text into words, counts them in a dictionary, finds the most common word, and prints an aligned statistics table using f-string format specs.python3 starter/text_report.py— the same program with five pieces left as numbered exercises (read the file, build the heading, count words, find the most common word, print the table). Each exercise comment names the exact method or expression to use. Edit only those lines.bash tests/run_tests.sh— runs the reference program and checks the exact counts (9 lines, 105 words, 556 characters, 66 unique words,"river"11 times), then confirms a set of slicing and string-method facts directly withpython3, and finally checks that the starter still runs.
Expected output
See expected-output/sample-run.txt — a real
captured run:
============================================
The River And The Reader
============================================
Preview: the river and the reader A river starts ...
Heading reversed: redaeR ehT dnA reviR ehT
First / last body word: 'river' / 'you'
Statistic Value
------------------------------
Lines 9
Words 105
Characters 556
Unique words 66
Most common word: "river" (11 times, 10.5% of words)
Because the program reads a fixed sample and does no other I/O, this output is
the same on every platform. expected-output/FIELDS.md
describes each line.
Validation steps
- Run
python3 starter/text_report.pyafter finishing the exercises — it must print the full report with no error. - Confirm the four counts match the reference: 9 lines, 105 words, 556 characters, 66 unique words.
- Confirm the most common word line reads
"river" (11 times, 10.5% of words). - Run the tests (next section) — all checks must pass.
Tests
bash tests/run_tests.sh
Expected final line: 9 checks, 0 failure(s). The script exits 0 on success
and non-zero on any failure, so it can run in CI. It uses no network.
Cleanup
Nothing to clean up: the program only reads the local sample and prints to the
terminal. To reset your work, restore the starter from git:
git checkout -- starter/text_report.py.
Troubleshooting
See troubleshooting.md — Python not found, encoding errors, f-string quoting problems, off-by-one slices, and mis-aligned columns.
Security notes
See security.md. Short version: the program reads one local file, makes no network calls, writes nothing, and needs no elevated privileges.
Extension exercises
- Add a line-length stat: the longest line and its length, found with
max(lines, key=len)and an f-string that prints both. - Print the top three words instead of just the most common one (sort
counts.items()by count, then slice[:3]). - Add a
--upperstyle toggle by readingsys.argv: when present, print the heading with.upper()instead of.title(), and note in the worksheet how the two methods differ.
Navigation
- Previous day: Day 44 — Variables and Types
(
labs/sections/programming-with-python/day-044-variables-and-types/, to be written). - Next day: Day 46 — Numbers, Math, and Precision
(
labs/sections/programming-with-python/day-046-numbers-math-and-precision/, to be written).
Expected output
FIELDS.md
# Expected output — Day 045 lab
`sample-run.txt` in this directory is a real captured run of
`examples/text_report.py` on the committed `examples/sample.txt`
(Python 3.14, macOS, 2026-07-12). Because the program reads a fixed sample
file and does no I/O beyond printing, the output is **identical on every
platform and every modern Python 3** — there are no machine-specific values.
A correct run prints, in order:
1. A line of 44 `=` characters.
2. The title-cased heading, centred: `The River And The Reader`.
3. Another line of 44 `=` characters, then a blank line.
4. `Preview: the river and the reader A river starts ...`
5. `Heading reversed: redaeR ehT dnA reviR ehT`
6. `First / last body word: 'river' / 'you'`, then a blank line.
7. A statistics table:
- header row `Statistic` / `Value`
- a dashed rule
- `Lines` = **9**
- `Words` = **105**
- `Characters` = **556**
- `Unique words` = **66**
8. A blank line, then
`Most common word: "river" (11 times, 10.5% of words)`.
The centred heading and table rows contain trailing spaces from the f-string
width specs; that is expected. If your numbers differ, you edited the sample
file — restore it from git (`git checkout -- examples/sample.txt`).
sample-run.txt
============================================
The River And The Reader
============================================
Preview: the river and the reader A river starts ...
Heading reversed: redaeR ehT dnA reviR ehT
First / last body word: 'river' / 'you'
Statistic Value
------------------------------
Lines 9
Words 105
Characters 556
Unique words 66
Most common word: "river" (11 times, 10.5% of words)
Source files
examples/sample.txt (556 bytes)
the river and the reader
A river starts as a thin trickle high in the cold mountains.
That river grows stronger as water joins it from smaller streams.
Every river carries silt and boats and quiet stories toward the sea.
People gather near a river because a river always means life.
In spring a river swells, and in summer a river runs low and clear.
A patient reader can follow a river from its source to its mouth.
Text is a river too: words flow, words join, words split, words merge.
Learn to shape that flow and no river of words will ever drown you.
examples/text_report.py (3261 bytes)
#!/usr/bin/env python3
"""Build a Text Report -- Day 045 lab (completed reference implementation).
Reads examples/sample.txt and prints a clean, aligned report built entirely
from string methods and f-strings:
* a title-cased heading taken from the first line,
* line, word, character, and unique-word counts,
* the most common word (found with split + a plain dict),
* a formatted statistics table using f-string format specs,
* small demonstrations of slicing and strip/split/join.
It reads only the local sample file next to this script. No network, no
arguments, no writing outside standard output.
"""
from pathlib import Path
# Characters we peel off the edges of a word before counting it. Using
# str.strip keeps this beginner-friendly; a complex pattern would call for
# the regular-expression tools introduced on Day 38.
PUNCTUATION = ".,;:!?\"'()[]-"
REPORT_WIDTH = 44
LABEL_WIDTH = 20
VALUE_WIDTH = 10
def load_text(path):
"""Return the full contents of a UTF-8 text file as one str."""
return Path(path).read_text(encoding="utf-8")
def normalize(token):
"""Lower-case a token and strip surrounding punctuation.
"River," -> "river"; "words." -> "words"; "too:" -> "too".
"""
return token.strip(PUNCTUATION).lower()
def count_words(text):
"""Return a dict mapping each normalized word to how often it appears."""
counts = {}
for token in text.split():
word = normalize(token)
if word: # skip tokens that were pure punctuation
counts[word] = counts.get(word, 0) + 1
return counts
def most_common(counts):
"""Return (word, n) for the most frequent word; ties break on first seen."""
best_word = ""
best_n = 0
for word, n in counts.items():
if n > best_n:
best_word, best_n = word, n
return best_word, best_n
def main():
sample_path = Path(__file__).resolve().parent / "sample.txt"
text = load_text(sample_path)
lines = text.splitlines()
heading = lines[0].strip().title()
words = text.split()
counts = count_words(text)
common_word, common_n = most_common(counts)
n_lines = len(lines)
n_words = len(words)
n_chars = len(text)
n_unique = len(counts)
bar = "=" * REPORT_WIDTH
print(bar)
print(f"{heading:^{REPORT_WIDTH}}")
print(bar)
print()
# Slicing + strip/split/join in one line: collapse all whitespace, then
# take the first 40 characters as a preview.
preview = " ".join(text.split())[:40]
print(f"Preview: {preview}...")
print(f"Heading reversed: {heading[::-1]}")
print(f"First / last body word: {words[1]!r} / {words[-1].strip(PUNCTUATION)!r}")
print()
print(f"{'Statistic':<{LABEL_WIDTH}}{'Value':>{VALUE_WIDTH}}")
print("-" * (LABEL_WIDTH + VALUE_WIDTH))
print(f"{'Lines':<{LABEL_WIDTH}}{n_lines:>{VALUE_WIDTH}}")
print(f"{'Words':<{LABEL_WIDTH}}{n_words:>{VALUE_WIDTH}}")
print(f"{'Characters':<{LABEL_WIDTH}}{n_chars:>{VALUE_WIDTH}}")
print(f"{'Unique words':<{LABEL_WIDTH}}{n_unique:>{VALUE_WIDTH}}")
print()
pct = common_n / n_words * 100
print(f'Most common word: "{common_word}" ({common_n} times, {pct:.1f}% of words)')
if __name__ == "__main__":
main()
metadata.yml (569 bytes)
lesson_id: D045
day: 45
kind: python-program
languages: [python]
setup_commands:
- cd labs/sections/programming-with-python/day-045-strings-and-text-processing
run_commands:
- python3 examples/text_report.py
- python3 starter/text_report.py
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- 'git checkout -- starter/text_report.py # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-12'
executed_on: 'macOS, Python 3.14.0, bash tests/run_tests.sh → 9 checks, 0 failure(s).'
requirements/README.md (786 bytes)
# Dependencies — Day 045 lab
**Only Python 3 and a POSIX shell.** This lab installs nothing:
- `python3` (version 3.8 or newer — any modern install works; captured on
3.14). Check with `python3 --version`.
- `bash` (for the test runner — preinstalled on macOS and Linux).
The program uses only the Python standard library (`pathlib`), which ships
with Python itself. There is deliberately no `requirements.txt`: string and
text processing are core language features, so no third-party package is
needed.
**Windows:** run the commands inside WSL (Windows Subsystem for Linux) so the
`bash tests/run_tests.sh` step works, or run the Python program directly with
`python examples\text_report.py` in PowerShell and check the numbers against
`expected-output/FIELDS.md` by hand.
starter/strings-worksheet.md (1304 bytes)
# Strings worksheet — Day 045
Fill this in as you complete the lab. Everything you need comes from running
`python3 examples/text_report.py` and experimenting in a Python shell
(`python3`), using only string methods and f-strings.
## 1. Word count of the sample
Run the completed report (or your finished starter) and record the total word
count it prints:
- Words in `examples/sample.txt`: **______**
## 2. One slice result
Pick any string from the sample (for example the title-cased heading
`The River And The Reader`) and write a slice expression plus its result. Try
it in the shell first: `python3 -c "print('The River And The Reader'[4:9])"`.
- Slice expression I used: `______________________`
- Result it produced: `______________________`
## 3. An f-string you wrote
Write one f-string of your own that embeds at least one expression and one
format spec (for example `:>10` to right-align a number, or `:.1f` for one
decimal place). Show the f-string and the line it prints.
- My f-string: `f"______________________"`
- What it printed: `______________________`
## Notes
Anything that surprised you (an off-by-one in a slice, a method that returned a
new string instead of changing the original, a quoting problem inside an
f-string) — jot it here so you remember it next time.
starter/text_report.py (2810 bytes)
#!/usr/bin/env python3
"""Build a Text Report -- Day 045 lab STARTER.
Complete the five numbered exercises below, then run your program:
python3 starter/text_report.py
Check your work with:
bash tests/run_tests.sh
The finished reference lives in examples/text_report.py -- read it only after
you have tried each exercise yourself. Edit ONLY the lines the exercises point
to; leave the rest of the scaffolding in place.
"""
from pathlib import Path
# Characters we peel off the edges of a word before counting it.
PUNCTUATION = ".,;:!?\"'()[]-"
REPORT_WIDTH = 44
LABEL_WIDTH = 20
VALUE_WIDTH = 10
def normalize(token):
"""Lower-case a token and strip surrounding punctuation: 'River,' -> 'river'."""
return token.strip(PUNCTUATION).lower()
def main():
sample_path = Path(__file__).resolve().parent.parent / "examples" / "sample.txt"
# Exercise 1: read the whole file into `text` as one UTF-8 string.
# Replace None with: sample_path.read_text(encoding="utf-8")
text = None
if text is None:
print("Exercise 1 not done yet: read the sample file into `text`.")
return
lines = text.splitlines()
words = text.split()
# Exercise 2: build a title-cased heading from the first line.
# Use lines[0].strip().title() (str methods, chained left to right)
heading = "HEADING GOES HERE"
# Exercise 3: count words into a dict using split + normalize.
# For each token in `words`: word = normalize(token); if word is not empty,
# add 1 to counts[word] (start each new word at 0 with counts.get).
counts = {}
# ... write the counting loop here ...
# Exercise 4: find the most common word and how many times it appears.
# Loop over counts.items() and keep the (word, n) with the largest n.
common_word, common_n = "", 0
# ... write the loop here ...
n_lines = len(lines)
n_words = len(words)
n_chars = len(text)
n_unique = len(counts)
bar = "=" * REPORT_WIDTH
print(bar)
print(f"{heading:^{REPORT_WIDTH}}")
print(bar)
print()
# Exercise 5: print the four statistic rows using f-string format specs so
# the label is left-aligned in LABEL_WIDTH and the number right-aligned in
# VALUE_WIDTH. The 'Lines' row is done for you; add 'Words', 'Characters',
# and 'Unique words' the same way.
print(f"{'Statistic':<{LABEL_WIDTH}}{'Value':>{VALUE_WIDTH}}")
print("-" * (LABEL_WIDTH + VALUE_WIDTH))
print(f"{'Lines':<{LABEL_WIDTH}}{n_lines:>{VALUE_WIDTH}}")
# ... add the Words, Characters, and Unique words rows here ...
print()
if n_words:
pct = common_n / n_words * 100
print(f'Most common word: "{common_word}" ({common_n} times, {pct:.1f}% of words)')
if __name__ == "__main__":
main()
tests/run_tests.sh (3180 bytes)
#!/usr/bin/env bash
# Tests for the Day 045 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# Runs the completed reference program on the committed sample and checks the
# exact counts and formatted lines it must produce, then confirms a couple of
# slicing/method facts directly with python3. No network is used.
set -u
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
grep_check() {
local label="$1" pattern="$2" text="$3"
if printf '%s\n' "${text}" | grep -Eq "${pattern}"; then
check "${label}" "yes"
else
check "${label}" "no"
fi
}
echo "Running examples/text_report.py ..."
if ! output="$(python3 "${lab_dir}/examples/text_report.py" 2>&1)"; then
check "reference program exits successfully" "no"
printf '%s\n' "${output}" | sed 's/^/ /'
else
check "reference program exits successfully" "yes"
grep_check "title-cased heading present" '^ *The River And The Reader *$' "${output}"
grep_check "Lines count is 9" '^Lines +9$' "${output}"
grep_check "Words count is 105" '^Words +105$' "${output}"
grep_check "Characters count is 556" '^Characters +556$' "${output}"
grep_check "Unique words count is 66" '^Unique words +66$' "${output}"
grep_check "most common word is river (11)" 'Most common word: "river" \(11 times, 10\.5% of words\)' "${output}"
fi
echo "Checking slicing and string-method facts with python3 ..."
if python3 - <<'PY'
# Slicing: negative index, reverse, and a range slice.
h = "The River And The Reader"
assert h[0] == "T", "s[0] should be the first character"
assert h[-1] == "r", "s[-1] should be the last character"
assert h[4:9] == "River", "s[4:9] should slice out 'River'"
assert h[::-1] == "redaeR ehT dnA reviR ehT", "s[::-1] should reverse the string"
# Methods produce NEW strings; the original is unchanged (immutability).
assert "River,".strip(".,") == "River"
assert " hi ".strip() == "hi"
assert "a,b,c".split(",") == ["a", "b", "c"]
assert "-".join(["a", "b", "c"]) == "a-b-c"
assert "Hello".lower() == "hello" and "Hello".upper() == "HELLO"
assert "banana".count("a") == 3
assert f"{3.14159:.2f}" == "3.14"
assert f"{42:>5}" == " 42"
PY
then
check "slicing/method assertions all hold" "yes"
else
check "slicing/method assertions all hold" "no"
fi
echo "Checking the starter is present and runnable ..."
if python3 "${lab_dir}/starter/text_report.py" >/dev/null 2>&1; then
check "starter program runs without crashing" "yes"
else
check "starter program runs without crashing" "no"
fi
if grep -q 'text = None' "${lab_dir}/starter/text_report.py"; then
echo "Note: starter/text_report.py still has unfinished exercises (that is expected before you complete them)."
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 045 lab
python3: command not found
Python 3 is not on your PATH. On macOS install it from python.org or with
Homebrew (brew install python); on Linux use your package manager
(sudo apt install python3 on Debian/Ubuntu). On some systems the command is
just python — check with python --version and use whichever reports a 3.x
version.
UnicodeDecodeError when reading the sample
You are reading the file with the wrong encoding, or the file was re-saved in
a non-UTF-8 encoding. The program passes encoding="utf-8" explicitly for
exactly this reason — always name the encoding when you read text, rather than
relying on the platform default (which differs between macOS, Linux, and
Windows). If you edited sample.txt, save it as UTF-8 or restore it with
git checkout -- examples/sample.txt.
SyntaxError inside an f-string — usually about quotes
An f-string delimited with double quotes cannot contain an unescaped double
quote inside its {...} expression. Two easy fixes:
- Use single quotes inside a double-quoted f-string (or vice versa):
f"name is {d['key']}". - Put the literal quote in the text part, not the expression:
f'Most common word: "{word}"'(single-quoted f-string, literal double quotes around the value).
Before Python 3.12 you also could not reuse the same quote character inside the braces at all; if you are on an older Python, switch the inner quotes.
IndexError: list index out of range
lines[0] or words[1] failed because the text was empty. Make sure
Exercise 1 actually read the file into text (the starter prints a reminder
and stops if text is still None).
The table columns do not line up
The alignment comes from the format specs :<{LABEL_WIDTH} (left-align) and
:>{VALUE_WIDTH} (right-align). If a label is longer than LABEL_WIDTH it
pushes the number across; widen LABEL_WIDTH at the top of the file, or
shorten the label.
Off-by-one in a slice
Remember s[a:b] includes index a but stops before b, so it has
b - a characters. "River"[0:3] is "Riv", not "Rive". Negative indices
count from the end: s[-1] is the last character.
Security notes
Security notes — Day 045 lab
- What the program does: reads one local file,
examples/sample.txt, which sits next to the script, and prints a report to your terminal. It makes no network connections, writes no files, and needs no arguments or environment variables. - File access: the path is built with
Path(__file__).resolve().parent, so the program always reads the sample shipped with the lab and never a file you pass in. It opens that file read-only. - Privileges: everything runs as your normal user. Nothing here needs
sudo; if a tutorial ever tells you to run a script as root without reading it, stop and read it first — a habit this course keeps reinforcing. - Reading before running: both Python files are short and commented. Read them before you run them. Running unread code — especially anything that reads files or reaches the network — is one of the most common ways developers get compromised.
- Your own text: if you later point the program at your own documents, remember that text can contain private information. This lab deliberately ships a harmless sample so nothing sensitive is ever printed or committed.