Three lessons of persistence have left the Papyrus folder looking like a bazaar: additions_log.txt, catalog.csv, sales.csv, catalog.json, members.json, config.json… all piled up next to the .py files. And there are pending chores that aren't about content but about management: creating folders, checking what exists, listing a month's reports, renaming in bulk, copying the catalog to a backup with the date in its name. This lesson covers all of it, with pathlib.Path in the lead role — the modern, object-oriented way to work with paths —, the os module that module 3 left promised (today you'll understand why we postponed it), and shutil for copying and moving. By the end, Papyrus will have a professional project layout and an automatic nightly backup. The module closes where it began: Ana switches off the computer — and no longer loses a thing.

Contents

  1. Paths: the value that looks like a string but isn't
  2. pathlib.Path: object-oriented paths
  3. Relative vs absolute paths and Path.cwd()
  4. Anatomy of a path: name, stem, suffix, parent
  5. Asking and creating: exists(), is_file(), is_dir(), mkdir()
  6. Finding files with glob()
  7. os and os.path: the legacy (equivalence table)
  8. shutil: copying, moving and deleting trees
  9. The definitive layout of the papyrus/ project
  10. Nightly backup with a date stamp
  11. Bulk renaming: the daily reports
  12. Common mistakes and tips
  13. Exercises with solutions

Paths: the value that looks like a string but isn't

Until now we've passed open() strings like "catalog.json", and for a loose file next to the script that was enough. But a real path has structure (folders, name, extension), operations of its own (joining, going up a level, changing the extension) and one awkward problem: the separator changes with the operating system\ on Windows, / on macOS/Linux. Manipulating paths by concatenating strings (folder + "\\" + name) produces fragile, non-portable code. Python has always had os.path for this (functions over strings) and, for years now, something better: treating the path as an object with methods — exactly the philosophy of module 5.

pathlib.Path: object-oriented paths

Path (from the pathlib module, standard library) already made a cameo in 06-01 with exists(); now it takes the leading role:

from pathlib import Path

data = Path("data")                       # a relative path (no disk touched yet)
catalog = data / "catalog.json"           # the / operator joins paths — on any system!
print(catalog)                            # data\catalog.json (Windows) · data/catalog.json (macOS/Linux)

The star is the / operator: Path overloads division (one more magic method, __truediv__ — 05-05 in action inside the standard library) to join path segments with the correct separator on each system. Never again "data" + "\\" + "catalog.json".

Creating a Path creates nothing on disk: it's a value that describes a path, whether it exists or not. And it fits everything you've learned: open() (and therefore csv and json) accepts Path objects wherever we used to pass strings — it even has shortcuts you already know implicitly:

with open(catalog, "r", encoding="utf-8") as f:     # a Path where a string went: identical
    ...

text = catalog.read_text(encoding="utf-8")          # shortcut: read everything (open+read)
catalog.write_text(text, encoding="utf-8")          # shortcut: write everything (open "w"+write)

The read_text/write_text shortcuts are convenient for small, single-operation files; for everything else, the with open(...) from 06-01 remains the pattern.

Relative vs absolute paths and Path.cwd()

  • An absolute path starts from the system root: C:\bookshops\papyrus\data\catalog.json. Unambiguous, but tied to one specific machine.
  • A relative path (data/catalog.json) is resolved from the current working directory, which you can query with Path.cwd().
print(Path.cwd())                        # C:\bookshops\papyrus  (wherever you launched python from)
print((Path("data") / "catalog.json").resolve())   # the absolute version of the relative one

The classic trap: the working directory is where you run from, not where the script lives. If you run python papyrus\register.py from C:\bookshops, the program's relative paths are resolved from C:\bookshops — and suddenly it "can't find" a data/ folder that does exist, just one level further down. The professional antidote is to anchor paths to the file itself with the __file__ variable (a cousin of the __name__ from 03-04):

BASE = Path(__file__).parent             # the folder THIS script lives in
DATA = BASE / "data"                     # always correct, no matter where you run from

This pair of lines will head every Papyrus program from now on.

Anatomy of a path: name, stem, suffix, parent

A Path can be dissected with read-only attributes:

path = Path("data") / "backups" / "catalog-2026-07-13.json"

print(path.name)      # catalog-2026-07-13.json    (full name)
print(path.stem)      # catalog-2026-07-13         (name without extension)
print(path.suffix)    # .json                      (the extension, dot included)
print(path.parent)    # data\backups               (the containing folder, another Path)

And to derive new paths without touching strings: path.with_name("other.json") changes the full name and path.with_suffix(".csv") only the extension. These four pieces — stem, suffix, with_name, glob — are the complete kit for the bulk rename you'll do at the end.

Asking and creating: exists(), is_file(), is_dir(), mkdir()

DATA = Path("data")

print(DATA.exists())                     # is there anything at that path?
print(DATA.is_dir())                     # and is it a folder?
print((DATA / "catalog.json").is_file()) # is it a file?

DATA.mkdir(parents=True, exist_ok=True)  # creates the folder — the canonical line

The two arguments of mkdir() deserve their own paragraph, because without them the method is fussy:

  • parents=True: also creates any missing intermediate folders (data/backups/2026 in one go). Without it, if an intermediate segment is missing: FileNotFoundError.
  • exist_ok=True: doesn't complain if the folder already exists. Without it, second run of the program: FileExistsError.

Together they make mkdir idempotent — you can call it a thousand times with the same result — ideal for a program's startup: "make sure my folders exist". And yes: exists() is the stopgap we've been using since 06-01 for files that might not be there; it's still a patch with cracks in it, and module 7 (imminent now) brings the real tool.

Finding files with glob()

glob(pattern) returns the files in a folder that match a wildcard pattern — * means "anything":

REPORTS = Path("reports")

for path in sorted(REPORTS.glob("*.csv")):         # every CSV in reports/
    print(path.name)

july = sorted(REPORTS.glob("report-2026-07-*.csv"))   # only July 2026's
print(len(july), "reports this month")

Usage details: it returns Path objects (with all their attributes ready), in no guaranteed order — hence the sorted() —, and with rglob(pattern) the search descends recursively through subfolders. Notice the dividend of naming with ISO dates (yyyy-mm-dd, like the SaleLine objects of 05-06): the names sort chronologically on their own and the per-month or per-year patterns come for free.

os and os.path: the legacy (equivalence table)

Module 3 mentioned os ("interaction with the operating system") and promised to develop it here. Promise kept — with a nuance: for paths and files, os/os.path are the veteran API, older than pathlib, based on functions that take and return strings. You'll find it in millions of lines of existing code and in old internet answers, so you must know how to read it; for writing new code, this course uses pathlib.

Task os / os.path (legacy) pathlib (modern)
Join paths os.path.join("data", "catalog.json") Path("data") / "catalog.json"
Exists? os.path.exists(path) path.exists()
Is it a file / folder? os.path.isfile(p) / os.path.isdir(p) p.is_file() / p.is_dir()
Create folders os.makedirs(p, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
Name / extension os.path.basename(p) / os.path.splitext(p)[1] p.name / p.suffix
Containing folder os.path.dirname(p) p.parent
Working directory os.getcwd() Path.cwd()
List contents os.listdir(p) (names, strings) p.iterdir() / p.glob("*") (Path objects)
Rename/move os.rename(a, b) a.rename(b)
Delete a file os.remove(p) p.unlink()

The underlying difference is module 5's: os.path is loose functions operating on passive strings; pathlib packages data and behavior into one object. The same evolution the catalog went through. (os remains irreplaceable for other things — environment variables with os.environ, processes — which stay outside this course for now.)

shutil: copying, moving and deleting trees

pathlib doesn't copy files; that's the job of shutil (shell utilities), its natural companion:

import shutil

shutil.copy2("data/catalog.json", "backups/catalog.json")   # copies a file (with metadata)
shutil.move("report.csv", "reports/report.csv")             # moves (or renames across folders)
shutil.copytree("data", "data_mirror")                      # copies an ENTIRE folder
shutil.rmtree("data_mirror")                                # ⚠ deletes a COMPLETE tree
  • copy2() copies the file preserving metadata such as the modification time (its sibling copy() doesn't; for backups, copy2).
  • move() works both for moving between folders and for renaming.
  • rmtree() deserves red letters: it deletes the folder and everything in it, recursively, with no recycle bin and no confirmation. An rmtree on the wrong path is unrecoverable. Golden rule: never on a path built from user input, and always after an is_dir() on a path you could read out loud without breaking a sweat.

The definitive layout of the papyrus/ project

With folders, anchored paths and idempotent mkdir, we finally tidy up the bazaar:

papyrus/
├── register.py              ← the main program
├── book_card.py             ← additions (06-01)
├── papyrus_utils.py         ← our own module from 03-04
├── config.json              ← configuration (06-03), next to the code
├── data/                    ← the shop's live state
│   ├── catalog.json         ← the canonical catalog (06-03)
│   ├── sales.csv            ← sales history (06-02)
│   ├── members.json         ← members and purchases (06-03)
│   └── additions_log.txt    ← additions log (06-01)
├── backups/                 ← date-stamped backups
└── reports/                 ← daily till reports (06-02)

And the startup code that guarantees it, ready to head register.py:

from pathlib import Path

BASE = Path(__file__).parent            # anchored to the script, not to the cwd
DATA = BASE / "data"
BACKUPS = BASE / "backups"
REPORTS = BASE / "reports"

for folder in (DATA, BACKUPS, REPORTS):
    folder.mkdir(parents=True, exist_ok=True)      # idempotent: a thousand runs, one result

CATALOG_PATH = DATA / "catalog.json"
SALES_PATH = DATA / "sales.csv"
CONFIG_PATH = BASE / "config.json"

From here on, load_catalog_json(CATALOG_PATH) and friends (06-02/06-03) receive Path objects and everything works the same — but tidy and portable.

Nightly backup with a date stamp

Ana no longer loses the catalog at shutdown… but what if one day a corrupted catalog gets saved over the good one? A single file is a single point of failure. The classic solution: at till close, copy the catalog to backups/ with the date in the namedatetime (03-05) builds the name and shutil.copy2 does the work:

import shutil
from datetime import date
from pathlib import Path

def backup_catalog(catalog_path, backup_folder):
    """Date-stamped copy of the catalog. Returns the backup's path, or None if there's nothing to copy."""
    if not catalog_path.exists():                    # the familiar stopgap; module 7 will retire it
        return None
    backup_folder.mkdir(parents=True, exist_ok=True)
    dest = backup_folder / f"{catalog_path.stem}-{date.today()}{catalog_path.suffix}"
    shutil.copy2(catalog_path, dest)
    return dest

print(backup_catalog(CATALOG_PATH, BACKUPS))     # backups\catalog-2026-07-13.json

Dissect the dest line: stem (catalog) + ISO date + suffix (.json) = catalog-2026-07-13.json. Every night, a new file; no backup treads on the previous one; and sorted(BACKUPS.glob("catalog-*.json"))[-1] — glob + ISO ordering — always returns the most recent backup. Three lessons of this module collaborating in one expression.

Bulk renaming: the daily reports

Ana's last request. Before the till was automated, reports were saved by hand as report-13-07-2026.csv — day-first dates, which when sorted alphabetically shuffle the months (13-07 lands before 02-08... and after 01-01 of any year). There are dozens. Renaming them by hand? Out of the question: glob + stem + rename do it in bulk:

from pathlib import Path

def normalize_reports(folder):
    """report-dd-mm-yyyy.csv → report-yyyy-mm-dd.csv (chronological order for free)."""
    for path in folder.glob("report-??-??-????.csv"):      # ? = exactly one character
        _, day, month, year = path.stem.split("-")         # the usual split (04-05)
        new = path.with_name(f"report-{year}-{month}-{day}{path.suffix}")
        if new.exists():                                   # don't clobber: rename overwrites silently
            print(f"WARNING: {new.name} already exists; leaving {path.name} alone")
            continue
        path.rename(new)
        print(f"{path.name}  →  {new.name}")

normalize_reports(REPORTS)
# report-13-07-2026.csv  →  report-2026-07-13.csv
# report-01-07-2026.csv  →  report-2026-07-01.csv

The pattern report-??-??-????.csv (each ? matches a single character) avoids capturing the already normalized reports — which have the four-digit year right after the hyphen. This is the general recipe for bulk renaming: glob to select, stem/split to take the name apart, with_name to reassemble it, exists to avoid clobbering, rename to execute. Memorize it: you'll use it on photos, downloads and logs for the rest of your life.

Common Mistakes and Tips

  • Concatenating paths as strings (folder + "/" + name): fragile and non-portable. Path's / operator exists for this and picks the correct separator on each system.
  • Trusting the working directory: "it worked in my terminal" is the symptom. Relative paths depend on where you run from; anchor with BASE = Path(__file__).parent.
  • Bare mkdir(): without parents=True it fails when a segment is missing; without exist_ok=True it fails on the second run. The canonical form carries both, almost always.
  • Forgetting that glob() doesn't sort: the order depends on the file system. If order matters — and with date-stamped names it almost always does — wrap it in sorted().
  • rename() onto a name that already exists: on some systems it silently overwrites the destination. Check exists() first, as in normalize_reports().
  • Trigger-happy rmtree(): it deletes whole trees with no recycle bin. Verify the path (print it first in a dry run), never build it from user input, and ask yourself whether a move() to your own trash/ folder isn't the better policy.
  • Windows paths in strings with \: "data\notes.txt" hides a \n (a newline!, 01-03). If you write literal paths, use / — Windows accepts it too — or better, build them with Path and the / operator.
  • Tip: always name date-stamped files in ISO (yyyy-mm-dd). Alphabetical order = chronological order, the per-month glob patterns write themselves, and you save yourself the bulk rename you just had to do.

Exercises

Exercise 1: project inventory

Write inventory(base) that receives the path of papyrus/ and walks data/, backups/ and reports/, printing, per folder, each file with its extension and its size in bytes (path.stat().st_size), and at the end the total file count. Use glob("*") and is_file() to ignore any subfolders. Extra: group the totals by suffix with a Counter (04-06).

Exercise 2: backup retention

The nightly backups pile up without limit. Write clean_backups(folder, keep=7) that lists the catalog-*.json files in backups/, sorts them by name (ISO dates: alphabetical order is chronological) and deletes with unlink() all but the keep most recent, printing each deletion. Test it by creating ten fake backups by hand with write_text().

Exercise 3: full till close

Bring the whole module together in close_till(base): (1) ensure the folders with the canonical mkdir line; (2) save the catalog with save_catalog_json() (06-03) to data/catalog.json; (3) make the date-stamped backup with backup_catalog(); (4) generate reports/report-<today>.csv with till_report() (06-02) reading data/sales.csv; and (5) print a summary with the names of the files created. Run it twice in a row and check that it neither fails nor duplicates anything odd.

Solutions

Solution 1:

from collections import Counter
from pathlib import Path

def inventory(base):
    total = 0
    by_type = Counter()
    for folder in (base / "data", base / "backups", base / "reports"):
        print(f"\n{folder.name}/")
        for path in sorted(folder.glob("*")):
            if path.is_file():                       # ignores subfolders
                print(f"  {path.name:<32} {path.suffix:<6} {path.stat().st_size} bytes")
                by_type[path.suffix] += 1
                total += 1
    print(f"\nTotal: {total} files — {dict(by_type)}")

inventory(BASE)

stat() gives access to the file's metadata (size, timestamps); st_size is the size in bytes.

Solution 2:

def clean_backups(folder, keep=7):
    backups = sorted(folder.glob("catalog-*.json"))    # ISO ⇒ alphabetical order = chronological
    for path in backups[:-keep]:                       # all but the last N (04-01)
        path.unlink()
        print(f"Deleted old backup: {path.name}")

# test bench: ten fake backups
for day in range(1, 11):
    (BACKUPS / f"catalog-2026-07-{day:02d}.json").write_text("{}", encoding="utf-8")
clean_backups(BACKUPS)     # deletes 01 through 03; the 7 most recent remain

The negative slicing from 04-01 implements the retention policy in one line. Note the {day:02d}: without the zero padding (catalog-2026-07-1.json), alphabetical order would stop being chronological and the cleanup would delete the wrong backups.

Solution 3:

from datetime import date

def close_till(base):
    data, backups, reports = base / "data", base / "backups", base / "reports"
    for folder in (data, backups, reports):
        folder.mkdir(parents=True, exist_ok=True)                # (1) idempotent

    catalog_path = data / "catalog.json"
    save_catalog_json(catalog, catalog_path)                     # (2) 06-03
    backup = backup_catalog(catalog_path, backups)               # (3) this lesson

    report_path = reports / f"report-{date.today()}.csv"
    till_report(str(date.today()), data / "sales.csv", report_path)   # (4) 06-02

    print(f"Close {date.today()}: catalog saved, backup {backup.name}, "
          f"report {report_path.name}")                          # (5)

close_till(BASE)

Second run on the same day: the folders already exist (exist_ok), the catalog and the backup are regenerated under the same names and the report is rewritten — all stable. This close_till() is, in miniature, the whole of module 6 working as a single piece.

Conclusion

Module 6 settles the debt left open at the close of module 5: the state of Papyrus no longer dies with the process. In 06-01, open() with its modes, the with that guarantees the close and the non-negotiable encoding="utf-8" gave the shop its first memory — additions_log.txt fulfilled the promise book_card.py had been dragging along since module 1; CSV turned the catalog and the sales into tables that Excel and the distributor understand, with DictReader/DictWriter and the dict[str, Book]catalog.csvdict[str, Book] round trip paying the strfloat toll in a single place (06-02); JSON brought types that survive the journey, nesting for the members with their purchases, the asdict()/Book(**data) bridge and a config.json that takes the constants out of the code (06-03); and pathlib tidied the bazaar into data/, backups/ and reports/, with paths anchored to __file__, glob to find things, shutil.copy2 for the date-stamped nightly backup and bulk renaming as the general recipe (06-04). Ana switches off the computer and, for the first time since module 1, loses nothing. But notice how many times this module said "stopgap": Path.exists() before reading, "don't break the JSON while editing", "pray the CSV brings numbers". What if the file isn't there, if the backup is corrupted, if float(row["price"]) receives "free", if Julia keys in an impossible price? Today, any of those things stops the program with an error splashed across the screen. Module 7 teaches Papyrus to roll with the punches: exceptions — detecting them, handling them with try/except, raising them with intent, and even designing your own. Your objects already know how to think and to remember; now they'll learn to fail with grace.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved