Programming with PythonPython Setup and First Programs › Day 45

Hands-on lab — Day 45: 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

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 --version reports 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 bash test step, or run the Python program directly in PowerShell and check the numbers against expected-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).
  • bash for 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: reads examples/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 with python3, 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

  1. Run python3 starter/text_report.py after finishing the exercises — it must print the full report with no error.
  2. Confirm the four counts match the reference: 9 lines, 105 words, 556 characters, 66 unique words.
  3. Confirm the most common word line reads "river" (11 times, 10.5% of words).
  4. 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

  1. Add a line-length stat: the longest line and its length, found with max(lines, key=len) and an f-string that prints both.
  2. Print the top three words instead of just the most common one (sort counts.items() by count, then slice [:3]).
  3. Add a --upper style toggle by reading sys.argv: when present, print the heading with .upper() instead of .title(), and note in the worksheet how the two methods differ.
  • 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.