Programming with PythonFunctions and Program Design › Day 60

Hands-on lab — Day 60: A Tour of the Standard Library

Commands

Setup

cd labs/sections/programming-with-python/day-060-a-tour-of-the-standard-library
python3 --version

Run

python3 examples/toolkit.py --dir sample-data
python3 examples/toolkit.py --dir sample-data --out report.json
cat report.json

Test

bash tests/run_tests.sh

File tree

examples/toolkit.py
expected-output/FIELDS.md
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
sample-data/a.csv
sample-data/b.csv
sample-data/notes.txt
sample-data/sub/c.csv
sample-data/x.json
sample-data/y.json
security.md
starter/toolkit.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 060 lab — Stdlib Toolkit

Lesson

Purpose

Day 60's lesson tours the Python standard library — the "batteries included" modules that ship with Python — and teaches the judgement to prefer them before adding a third-party dependency. This lab makes that concrete. You build Stdlib Toolkit, a small directory-audit tool that uses four drawers of the toolbox together: pathlib to walk a folder, collections.Counter to tally file extensions, datetime to stamp the report, and json to write it out — proving you solved a genuine, useful task with nothing installed. You build it from a starter, one exercise at a time, then run an automated test suite that checks the tally deterministically (ignoring the volatile timestamp). This is the everyday glue of AI work: before anything is trained, you must describe your data directory precisely and reproducibly.

Learning objectives

  • Walk a directory tree with pathlib (Path, .rglob, .is_file) instead of gluing path strings together.
  • Tally categories with collections.Counter and read the result largest-first with .most_common().
  • Stamp a report with datetime.now().isoformat() for a sortable, unambiguous timestamp.
  • Write and read structured results with json, and keep the tool testable by taking all input from arguments (argparse).
  • Feel the standard-library-first habit directly: build a real tool with no pip install at all.

Prerequisites

  • The Day 60 lesson (read it first — it tours every module this lab uses).
  • Days 57–59: functions and the main guard, modules and imports, and scope.
  • Day 56: reading and writing JSON with json.load/json.dump, and command-line tools with argparse.
  • A text editor and a terminal. No experience beyond this course is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS with Apple Silicon, Python 3.14.0).
  • Linux — fully supported (any distribution with Python 3 and bash).
  • Windows — use WSL and follow the Linux path, or substitute python for python3 if that is how Python is exposed. The tool is pure standard-library Python and behaves identically everywhere.

Hardware requirements

Any computer that runs Python 3. The tool reads only file names and metadata and writes a tiny JSON report; it needs no special memory, disk, or GPU.

Required software

  • python3 (3.8 or newer; tested on 3.14.0).
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only — pathlib, collections, datetime, json, argparse, and sys all ship with Python. No packages to install. See requirements/README.md.

Free and open-source options

Everything here is free and open source: Python, bash, and the standard library. No account, API key, network access, or purchase is needed — which is the whole point of the lab. Every module it uses is part of Python itself.

Installation

None beyond Python itself. Move into this directory and you are ready:

cd labs/sections/programming-with-python/day-060-a-tour-of-the-standard-library
python3 --version   # confirm Python 3.8+ is available

File structure

day-060-a-tour-of-the-standard-library/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── sample-data/                    ← bundled folder to audit (6 files, one nested)
│   ├── a.csv, b.csv
│   ├── sub/c.csv
│   ├── x.json, y.json
│   └── notes.txt
├── starter/
│   └── toolkit.py                  ← YOUR working file (4 numbered exercises)
├── examples/
│   └── toolkit.py                  ← complete reference implementation
├── tests/
│   └── run_tests.sh                ← automated checks (deterministic tally, import)
├── expected-output/
│   ├── sample-run.txt              ← real captured session with the reference
│   ├── test-run.txt                ← real captured run of the test suite
│   └── FIELDS.md                   ← required behaviour on every platform
├── requirements/
│   └── README.md                   ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

How to run

From this directory:

## 1. See the finished tool first. Audit the bundled sample folder.
python3 examples/toolkit.py --dir sample-data

## 2. Write the same report to a file, then inspect it.
python3 examples/toolkit.py --dir sample-data --out report.json
cat report.json

## 3. Your task: complete the four exercises in the starter, then run it.
python3 starter/toolkit.py --dir sample-data

## 4. Prove a function is importable (the payoff of the main guard).
python3 -c "import sys; sys.path.insert(0, 'examples'); from toolkit import tally_extensions; print(tally_extensions(['a.csv', 'b.csv', 'c.txt']))"

## 5. Check your work, then clean up.
bash tests/run_tests.sh
rm -f report.json

What the commands do

  • --dir sample-data — walks the folder recursively with pathlib, tallies file extensions with collections.Counter, stamps the moment with datetime, and prints a JSON report. This is the four-drawer toolkit in one run.
  • --out report.json — additionally writes the same report to a file with json.dump, so you can save it and compare against a later run.
  • cat report.json — shows the persisted, human-readable JSON report on disk.
  • python3 -c "...tally_extensions..." — imports one function from the module and calls it without running the whole tool, which works only because the main guard holds main back on import.
  • bash tests/run_tests.sh — builds a throwaway directory with a known mix of files (including an uppercase .CSV and a file with no extension), runs the reference tool, and asserts on the stable fields only — the volatile timestamp is deliberately ignored. Exits 0 only if every check passes.

Expected output

See expected-output/sample-run.txt — a real captured session. The report for the bundled sample-data folder is:

{
  "generated_at": "2026-07-13T11:56:58",
  "directory": "sample-data",
  "total_files": 6,
  "by_extension": {
    ".csv": 3,
    ".json": 2,
    ".txt": 1
  }
}

The generated_at timestamp is the current time and will differ on your machine; every other field is deterministic. expected-output/FIELDS.md lists the required behaviour for every input on every platform.

Validation steps

  1. python3 examples/toolkit.py --dir sample-data prints a report with total_files: 6 and by_extension {".csv": 3, ".json": 2, ".txt": 1}.
  2. python3 examples/toolkit.py --dir sample-data --out report.json then cat report.json shows the same report saved as valid JSON.
  3. Complete the four exercises in starter/toolkit.py, run it on sample-data, and confirm the tally matches the reference.
  4. python3 -c "...tally_extensions(['a.csv', 'b.csv', 'c.txt'])..." prints {'.csv': 2, '.txt': 1}.
  5. Run the tests (next section) — every check must pass.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 14 checks, 0 failure(s). Once you complete all four starter exercises, the suite runs your version through the same deterministic tally plus the main-guard check, giving 19 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run in CI. A full captured run is in expected-output/test-run.txt.

Cleanup

The tool writes only the report file you name with --out. Remove any you created:

rm -f report.json my-report.json

To reset your work, restore the starter from git: git checkout -- starter/toolkit.py. The test runner cleans up its own temporary directory automatically.

Troubleshooting

See troubleshooting.md for the full list: python vs python3, running from the lab directory, why the timestamp changes every run, case-insensitive extension counting, files with no extension, importing vs running, and permissions.

Security notes

See security.md. Short version: the tool makes no network calls and needs no privileges; it reads only file names and metadata and writes only the report you name. Preferring the standard library is itself a security posture — fewer dependencies mean a smaller attack surface — and the tool reads data with json, never eval().

Extension exercises

  1. Add a --top N option (with argparse) so the report includes a top_extensions list of the N most common extensions, via Counter.most_common(N).
  2. Make the tool robust: if the --dir directory does not exist, print a clear message to standard error and exit non-zero instead of crashing.
  3. Add a --format option (json or text) where text prints an aligned, human-readable one-line-per-extension summary.
  4. Extend the report using two more drawers: total_bytes (sum p.stat().st_size with pathlib) and mean_bytes (with statistics, guarding the empty-folder case).
  5. Write your own tests/test_toolkit.py that imports tally_extensions and asserts its behaviour, including the case-insensitive and no-extension cases, printing all tests passed only if every assertion holds.
  • Previous day: Day 59 — Scope and Namespaces (labs/sections/programming-with-python/day-059-scope-and-namespaces/).
  • Next day: Day 61 — Writing Readable Code (labs/sections/programming-with-python/day-061-writing-readable-code/, to be written).
  • Week 9 theme: Functions and Program Design — building, organising, and reaching for well-made code, of which the standard library is the largest and best-tested example.

Expected output

FIELDS.md

# Expected output — Day 060 lab

These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). The tally is deterministic:
given the same directory contents, the tool produces the same counts on
every platform Python 3 runs on. The one field that changes between runs is
`generated_at`, because it is the current time — the tests deliberately
ignore it.

## Files

- `sample-run.txt` — the reference tool run against the bundled `sample-data`
  folder: printed report, the same report written with `--out report.json`,
  the file on disk, and a `python3 -c` import check of `tally_extensions`.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (14 checks, 0 failures). Absolute paths are shown as
  `<repo>`; on your machine they are your real repository path.

## Required behaviour on every platform

The bundled `sample-data` folder holds six files — three `.csv` (one nested
in `sub/`), two `.json`, and one `.txt` — so a correct tool prints:

| Field | Value for `sample-data` | Notes |
| --- | --- | --- |
| `generated_at` | an ISO-8601 timestamp, e.g. `2026-07-13T11:56:58` | the current time; changes every run; not tested |
| `directory` | `sample-data` | the string passed to `--dir` |
| `total_files` | `6` | files found by walking the tree recursively |
| `by_extension` | `{".csv": 3, ".json": 2, ".txt": 1}` | ordered largest-first |

The test suite builds its own temporary tree with seven files — including one
uppercase `.CSV` and one file with no extension — and asserts:

| Behaviour | Expected |
| --- | --- |
| `total_files` | `7` |
| `.csv` count | `3` — `.CSV` and `.csv` count together (case-insensitive) |
| `.json` count | `2` |
| `.txt` count | `1` |
| no-extension file | counted under `(none)` (`1`), not dropped |
| ordering | `by_extension` is largest-count first (`.csv` before `.json`) |
| `--out FILE` | writes the same report as valid JSON to `FILE` |

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before each command; the program's own output is identical.
- `rglob("*")` walks subdirectories on every platform, so the nested
  `sub/c.csv` is always found and counted.
- The test runner uses `mktemp -d` to build a throwaway directory tree and
  removes it on exit. On Linux `mktemp -d -t toolkit-test.XXXXXX` behaves the
  same as on macOS; the path is quoted and removed regardless.
- On Windows, run inside WSL and follow the Linux path; the tool is pure
  standard-library Python and behaves identically.

sample-run.txt

$ python3 examples/toolkit.py --dir sample-data
{
  "generated_at": "2026-07-13T11:56:58",
  "directory": "sample-data",
  "total_files": 6,
  "by_extension": {
    ".csv": 3,
    ".json": 2,
    ".txt": 1
  }
}

$ python3 examples/toolkit.py --dir sample-data --out report.json
{
  "generated_at": "2026-07-13T11:56:58",
  "directory": "sample-data",
  "total_files": 6,
  "by_extension": {
    ".csv": 3,
    ".json": 2,
    ".txt": 1
  }
}

$ cat report.json
{
  "generated_at": "2026-07-13T11:56:58",
  "directory": "sample-data",
  "total_files": 6,
  "by_extension": {
    ".csv": 3,
    ".json": 2,
    ".txt": 1
  }
}

$ python3 -c "import sys; sys.path.insert(0, 'examples'); from toolkit import tally_extensions; print(tally_extensions(['a.csv', 'b.csv', 'c.txt']))"
{'.csv': 2, '.txt': 1}

test-run.txt

Testing <repo>/labs/sections/programming-with-python/day-060-a-tour-of-the-standard-library/examples/toolkit.py ...
  ok: report is valid JSON, exit 0
  ok: total_files is 7
  ok: .csv counted 3 (case-insensitive)
  ok: .json counted 2
  ok: .txt counted 1
  ok: no-extension file counted as (none)
  ok: by_extension is largest-first
  ok: --out writes a valid JSON report
Testing importability of examples/toolkit.py ...
  ok: import tally_extensions computes correct tallies
Testing starter/toolkit.py ...
  ok: starter is valid Python
Note: starter/toolkit.py still has unfinished exercises — testing structure only.
  ok: starter defines walk_files
  ok: starter defines tally_extensions
  ok: starter defines build_report
  ok: starter defines write_report

14 checks, 0 failure(s).

Source files

examples/toolkit.py (2867 bytes)
#!/usr/bin/env python3
"""toolkit.py — Stdlib Toolkit: a directory audit built from the standard
library only.

It walks a folder with pathlib, tallies file extensions with
collections.Counter, stamps the moment with datetime, and writes a JSON
report with json — four drawers of the standard-library toolbox, nothing
installed.

    python3 examples/toolkit.py --dir sample-data
    python3 examples/toolkit.py --dir sample-data --out report.json
"""
import argparse
import json
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path


def walk_files(directory):
    """Return a sorted list of every file under directory (recursively).

    Uses pathlib: Path(directory).rglob("*") yields every entry in the tree,
    and .is_file() keeps only files (not subdirectories).
    """
    root = Path(directory)
    return sorted(p for p in root.rglob("*") if p.is_file())


def tally_extensions(items):
    """Tally file extensions across items (paths or names).

    Case-insensitive; a file with no extension is counted as "(none)".
    Returns a plain dict ordered largest-count first.
    """
    counter = Counter()
    for item in items:
        suffix = Path(item).suffix.lower() or "(none)"
        counter[suffix] += 1
    return dict(counter.most_common())


def build_report(directory, files):
    """Assemble the audit report dict from the walked files.

    Stamps the current time with datetime and tallies extensions.
    """
    return {
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "directory": str(directory),
        "total_files": len(files),
        "by_extension": tally_extensions(files),
    }


def write_report(path, report):
    """Write the report to path as pretty-printed JSON."""
    with open(path, "w", encoding="utf-8") as handle:
        json.dump(report, handle, indent=2)
        handle.write("\n")


def build_parser():
    """Build the argparse parser: --dir to audit, optional --out to save."""
    parser = argparse.ArgumentParser(
        prog="toolkit.py",
        description="Audit a directory: count files by extension and stamp a JSON report.",
    )
    parser.add_argument(
        "--dir",
        required=True,
        help="the directory to audit (walked recursively)",
    )
    parser.add_argument(
        "--out",
        default=None,
        help="optional path to also write the JSON report to",
    )
    return parser


def main(argv):
    """Entry point: parse arguments, walk, build the report, print (and save)."""
    parser = build_parser()
    args = parser.parse_args(argv[1:])
    files = walk_files(args.dir)
    report = build_report(args.dir, files)
    if args.out:
        write_report(args.out, report)
    print(json.dumps(report, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
metadata.yml (717 bytes)
lesson_id: D060
day: 60
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-060-a-tour-of-the-standard-library
  - python3 --version
run_commands:
  - python3 examples/toolkit.py --dir sample-data
  - python3 examples/toolkit.py --dir sample-data --out report.json
  - cat report.json
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -f report.json my-report.json
  - 'git checkout -- starter/toolkit.py  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-13'
executed_on: 'macOS (Apple Silicon), Python 3.14.0, bash tests/run_tests.sh -> 14 checks, 0 failure(s), exit 0'
requirements/README.md (981 bytes)
# Dependencies — Day 060 lab

**Python 3 only. No third-party packages — that is the entire point of this lab.**

- `python3` (3.8 or newer; tested on 3.14.0). Preinstalled on most Linux
  distributions and installable on macOS; you set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Only the Python standard library is used — specifically `pathlib`,
  `collections`, `datetime`, `json`, `argparse`, and `sys`, all of which ship
  with Python. There is deliberately no `requirements.txt`: this lab exists to
  show that a real, useful directory-audit tool needs nothing installed at
  all.

Check your Python is present and new enough:

```bash
python3 --version
```

If that prints `Python 3.8` or higher, you are ready. Windows users: run the
commands inside WSL, or use `python` in place of `python3` if that is how
Python is exposed on your system. The tool itself is pure standard-library
Python and behaves identically everywhere.
sample-data/a.csv (21 bytes)
id,name
1,Ada
2,Alan
sample-data/b.csv (19 bytes)
id,value
1,10
2,20
sample-data/notes.txt (41 bytes)
Sample notes for the Stdlib Toolkit lab.
sample-data/sub/c.csv (24 bytes)
label,count
cat,3
dog,2
sample-data/x.json (31 bytes)
{"model": "demo", "epochs": 3}
sample-data/y.json (32 bytes)
{"split": "train", "size": 100}
starter/toolkit.py (3583 bytes)
#!/usr/bin/env python3
"""toolkit.py — YOUR working file.

Build the Stdlib Toolkit one exercise at a time, using the standard library
only. Each numbered exercise names exactly what to write. The finished
reference is in examples/toolkit.py — try each exercise yourself before
peeking.

When all four exercises are done, this file behaves like the reference:

    python3 starter/toolkit.py --dir sample-data
    python3 starter/toolkit.py --dir sample-data --out report.json

Then run:  bash tests/run_tests.sh
"""
import argparse
import json
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path


def walk_files(directory):
    """Return a sorted list of every file under directory (recursively)."""
    # Exercise 1: WALK THE DIRECTORY (pathlib).
    # 1. Make a Path from directory.
    # 2. Use .rglob("*") to yield every entry in the tree.
    # 3. Keep only the ones where .is_file() is True.
    # 4. Return them as a sorted list.
    # Hint: return sorted(p for p in Path(directory).rglob("*") if p.is_file())
    raise NotImplementedError("Exercise 1: implement walk_files")


def tally_extensions(items):
    """Tally file extensions across items (paths or names).

    Case-insensitive; a file with no extension is counted as "(none)".
    Returns a plain dict ordered largest-count first.
    """
    # Exercise 2: TALLY EXTENSIONS (collections.Counter).
    # 1. Make a Counter.
    # 2. For each item, take Path(item).suffix.lower(); if it is empty,
    #    use the string "(none)" instead.
    # 3. Add one to the counter for that suffix.
    # 4. Return dict(counter.most_common()) so it is ordered largest first.
    raise NotImplementedError("Exercise 2: implement tally_extensions")


def build_report(directory, files):
    """Assemble the audit report dict from the walked files."""
    # Exercise 3: BUILD THE STAMPED REPORT (datetime + your tally).
    # Return a dict with these keys:
    #   "generated_at": datetime.now().isoformat(timespec="seconds")
    #   "directory":    str(directory)
    #   "total_files":  len(files)
    #   "by_extension": tally_extensions(files)
    raise NotImplementedError("Exercise 3: implement build_report")


def write_report(path, report):
    """Write the report to path as pretty-printed JSON."""
    # Exercise 4: WRITE THE REPORT AS JSON (json).
    # 1. Open path for writing (encoding="utf-8").
    # 2. Use json.dump(report, handle, indent=2) to write it.
    # 3. Write a trailing newline so the file ends cleanly.
    raise NotImplementedError("Exercise 4: implement write_report")


def build_parser():
    """Build the argparse parser: --dir to audit, optional --out to save. (Provided.)"""
    parser = argparse.ArgumentParser(
        prog="toolkit.py",
        description="Audit a directory: count files by extension and stamp a JSON report.",
    )
    parser.add_argument(
        "--dir",
        required=True,
        help="the directory to audit (walked recursively)",
    )
    parser.add_argument(
        "--out",
        default=None,
        help="optional path to also write the JSON report to",
    )
    return parser


def main(argv):
    """Entry point: parse arguments, walk, build the report, print (and save). (Provided.)"""
    parser = build_parser()
    args = parser.parse_args(argv[1:])
    files = walk_files(args.dir)
    report = build_report(args.dir, files)
    if args.out:
        write_report(args.out, report)
    print(json.dumps(report, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
tests/run_tests.sh (5822 bytes)
#!/usr/bin/env bash
# Tests for the Day 060 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Exercises the reference Stdlib Toolkit (examples/toolkit.py) against a
# freshly built temporary directory with a KNOWN mix of files, so the run is
# deterministic. Every check parses the JSON report and asserts on the stable
# fields (total_files, by_extension, directory); the volatile "generated_at"
# timestamp is deliberately NOT checked. It then imports tally_extensions to
# check its return value directly, and finally checks the learner's starter —
# structurally while exercises are unfinished, and to the same strict standard
# once they are complete. No network, non-interactive. Exits 0 only if every
# check passes.
set -u

# Keep the working tree clean: do not let imported modules write __pycache__.
export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ref="${lab_dir}/examples/toolkit.py"
starter="${lab_dir}/starter/toolkit.py"
failures=0
checks=0

# A fresh temporary directory tree per run; removed on exit.
tmp="$(mktemp -d -t toolkit-test.XXXXXX)"
cleanup() { rm -rf "${tmp}"; }
trap cleanup EXIT

# Build a KNOWN tree: 3 csv (one nested), 2 json, 1 txt, 1 no-extension,
# and one .CSV to prove the tally is case-insensitive.
mkdir -p "${tmp}/data/sub"
printf 'a\n' > "${tmp}/data/a.csv"
printf 'b\n' > "${tmp}/data/b.csv"
printf 'c\n' > "${tmp}/data/sub/c.CSV"       # uppercase on purpose
printf '{}\n' > "${tmp}/data/x.json"
printf '{}\n' > "${tmp}/data/y.json"
printf 'note\n' > "${tmp}/data/notes.txt"
printf 'readme\n' > "${tmp}/data/README"      # no extension -> "(none)"
# Expected: 7 files; .csv=3 (case-insensitive), .json=2, .txt=1, (none)=1

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# field <json-text> <python-expr-on-d> : print the value of a report field.
field() {
  printf '%s' "$1" | python3 -c "import sys, json; d = json.load(sys.stdin); print($2)"
}

run_cli_checks() {
  local script="$1"
  echo "Testing ${script} ..."
  local out
  out="$(python3 "${script}" --dir "${tmp}/data")"
  local code=$?
  # Valid JSON and exit 0.
  if [ "${code}" -eq 0 ] && printf '%s' "${out}" | python3 -c "import sys, json; json.load(sys.stdin)" 2>/dev/null; then
    check "report is valid JSON, exit 0" "yes"
  else
    check "report is valid JSON, exit 0" "no"
    echo "    (exit ${code}; output: ${out})"
    return
  fi
  # Deterministic fields.
  [ "$(field "${out}" 'd["total_files"]')" = "7" ] \
    && check "total_files is 7" "yes" || check "total_files is 7" "no"
  [ "$(field "${out}" 'd["by_extension"][".csv"]')" = "3" ] \
    && check ".csv counted 3 (case-insensitive)" "yes" || check ".csv counted 3 (case-insensitive)" "no"
  [ "$(field "${out}" 'd["by_extension"][".json"]')" = "2" ] \
    && check ".json counted 2" "yes" || check ".json counted 2" "no"
  [ "$(field "${out}" 'd["by_extension"][".txt"]')" = "1" ] \
    && check ".txt counted 1" "yes" || check ".txt counted 1" "no"
  [ "$(field "${out}" 'd["by_extension"]["(none)"]')" = "1" ] \
    && check "no-extension file counted as (none)" "yes" || check "no-extension file counted as (none)" "no"
  # Tally is ordered largest-first (.csv before .json before .txt).
  [ "$(field "${out}" 'list(d["by_extension"])[0]')" = ".csv" ] \
    && check "by_extension is largest-first" "yes" || check "by_extension is largest-first" "no"

  # --out writes a valid JSON file with the same total.
  local outfile="${tmp}/report.json"
  rm -f "${outfile}"
  python3 "${script}" --dir "${tmp}/data" --out "${outfile}" >/dev/null
  if [ -f "${outfile}" ] && python3 -c "import json; d = json.load(open('${outfile}')); assert d['total_files'] == 7" 2>/dev/null; then
    check "--out writes a valid JSON report" "yes"
  else
    check "--out writes a valid JSON report" "no"
  fi
  rm -f "${outfile}"
}

# --- Reference toolkit: always tested strictly ---
run_cli_checks "${ref}"

# --- Import tally_extensions and check return values ---
echo "Testing importability of examples/toolkit.py ..."
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from toolkit import tally_extensions; \
assert tally_extensions(['a.csv', 'b.csv', 'c.txt']) == {'.csv': 2, '.txt': 1}; \
assert tally_extensions(['A.CSV', 'b.csv']) == {'.csv': 2}; \
assert tally_extensions(['README']) == {'(none)': 1}"; then
  check "import tally_extensions computes correct tallies" "yes"
else
  check "import tally_extensions computes correct tallies" "no"
fi

# --- Learner starter ---
echo "Testing starter/toolkit.py ..."
if python3 -c "compile(open('${starter}').read(), '${starter}', 'exec')" 2>/dev/null; then
  check "starter is valid Python" "yes"
else
  check "starter is valid Python" "no"
fi

if grep -q 'NotImplementedError' "${starter}"; then
  echo "Note: starter/toolkit.py still has unfinished exercises — testing structure only."
  grep -q 'def walk_files' "${starter}" && check "starter defines walk_files" "yes" || check "starter defines walk_files" "no"
  grep -q 'def tally_extensions' "${starter}" && check "starter defines tally_extensions" "yes" || check "starter defines tally_extensions" "no"
  grep -q 'def build_report' "${starter}" && check "starter defines build_report" "yes" || check "starter defines build_report" "no"
  grep -q 'def write_report' "${starter}" && check "starter defines write_report" "yes" || check "starter defines write_report" "no"
else
  run_cli_checks "${starter}"
  grep -q '__name__ == "__main__"' "${starter}" && check "starter has the main guard" "yes" || check "starter has the main guard" "no"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 060 lab

python: command not found

Use python3 explicitly, as every command in this lab does. On macOS and most Linux systems, bare python may be missing or point to an old version. Check with python3 --version.

No such file or directory for sample-data

Run the commands from the lab directory — the folder that contains examples/, starter/, and sample-data/. If you are in the repository root, cd into the lab first:

cd labs/sections/programming-with-python/day-060-a-tour-of-the-standard-library
python3 examples/toolkit.py --dir sample-data

The starter raises NotImplementedError when I run it

That is expected until you finish the exercises. Each unfinished function raises NotImplementedError on purpose so you cannot mistake an empty function for a working one. Replace each raise NotImplementedError(...) line with the real body described in the comment above it. Once all four exercises are done, the file behaves like the reference.

toolkit.py: error: the following arguments are required: --dir

That message comes from argparse itself: --dir is required, so you must say which directory to audit. Argparse prints the usage line and exits with code 2, its own convention for usage errors. Add --dir sample-data.

The generated_at timestamp is different every time I run it

That is correct, not a bug: datetime.now() returns the current moment, so generated_at changes on every run. This is exactly why the tests ignore it and check only the stable fields (total_files, by_extension, directory). When you compare two reports, ignore this field or compare the tallies directly.

.CSV and .csv are counted as two different types

You are tallying the suffix without lowercasing it. Use Path(item).suffix.lower() so .CSV, .Csv, and .csv all count together. The reference does this, and the test suite checks it with a deliberately uppercase .CSV file.

A file with no extension disappears from the tally

Path("README").suffix is an empty string "", and an empty key is easy to lose. Map the empty suffix to a visible label — the reference uses Path(item).suffix.lower() or "(none)" — so files without an extension are still counted.

ModuleNotFoundError: No module named 'toolkit' in the import check

Python looks for modules on its search path (sys.path), which does not include the examples/ subfolder by default. The import one-liner adds it first:

python3 -c "import sys; sys.path.insert(0, 'examples'); from toolkit import tally_extensions; print(tally_extensions(['a.csv', 'b.csv']))"

Run it from the lab directory (the folder that contains examples/).

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly, as the README shows: bash tests/run_tests.sh. You do not need to chmod +x anything.

Security notes

Security notes — Day 060 lab

  • What the tool does: reads the directory you name with --dir, reads only file names and metadata (it never opens or reads file contents), and prints or writes a small JSON report. It makes no network connections, needs no privileges, and writes only the report file you name with --out. The test runner builds a throwaway directory with mktemp -d and removes it on exit.

  • Fewer dependencies is a security posture. The lab's whole lesson is standard-library-first, and that habit is also a security habit: every third-party package you install is code from someone else that runs with your program's privileges, and the package supply chain has been attacked through malicious or hijacked releases. A tool that uses only the standard library has a far smaller attack surface — there is nothing extra to trust, update, or be compromised through.

  • Read data with json, never eval(). When the tool reads a JSON report back, it uses json.load, which turns text into plain Python values and can never execute code. Do not reach for eval()/exec() to "parse" a report or any file contents; those run the string as Python, so a malicious value could delete files or open a network connection.

  • Treat directory input as untrusted. The tool walks whatever path you give --dir. Point it at your own project folders, not at system directories you do not control. It only reads metadata, but it is good practice to know exactly what you are pointing a file-walking tool at.

  • Fail loudly, not silently. Every command prints its result and returns an exit code, so a person or a script can tell whether it worked. When you build the extension challenge (a missing --dir printing to standard error and exiting non-zero), keep that discipline — a clear error beats a silent wrong answer.

  • Reading before running: every file in this lab is short and commented. Read examples/toolkit.py and tests/run_tests.sh before running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.