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
- Paths: the value that looks like a string but isn't
pathlib.Path: object-oriented paths- Relative vs absolute paths and
Path.cwd() - Anatomy of a path:
name,stem,suffix,parent - Asking and creating:
exists(),is_file(),is_dir(),mkdir() - Finding files with
glob() osandos.path: the legacy (equivalence table)shutil: copying, moving and deleting trees- The definitive layout of the
papyrus/project - Nightly backup with a date stamp
- Bulk renaming: the daily reports
- Common mistakes and tips
- 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 withPath.cwd().
print(Path.cwd()) # C:\bookshops\papyrus (wherever you launched python from)
print((Path("data") / "catalog.json").resolve()) # the absolute version of the relative oneThe 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 fromThis 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 lineThe 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/2026in 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 treecopy2()copies the file preserving metadata such as the modification time (its siblingcopy()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. Anrmtreeon the wrong path is unrecoverable. Golden rule: never on a path built from user input, and always after anis_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 name — datetime (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.jsonDissect 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.csvThe 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(): withoutparents=Trueit fails when a segment is missing; withoutexist_ok=Trueit 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 insorted(). rename()onto a name that already exists: on some systems it silently overwrites the destination. Checkexists()first, as innormalize_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 amove()to your owntrash/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 withPathand the/operator. - Tip: always name date-stamped files in ISO (
yyyy-mm-dd). Alphabetical order = chronological order, the per-monthglobpatterns 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 remainThe 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.csv → dict[str, Book] round trip paying the str→float 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
