Module 6: Real Boundaries Db Files Http

5. The file boundary with `tmp_path`

Description

Second boundary: files. Reservo doesn't live on its database alone —it constantly needs to move bookings to and from a file: export a room's agenda for a report, back up the month's bookings, import a batch from an old system—. Each of those operations crosses the file system boundary: it writes text to a disk that survives your process, or reads text someone else left there. It's a humbler boundary than the database —no transactions or an engine with complex rules—, but it shares with it the same trap, the one that's the thread of the whole module: in a file, everything becomes text. A Booking with its datetime and its integer price_cents, when written to a CSV, becomes a line of characters; when you read it back, you don't get Python objects, you get text, and the types that aren't text have to be reconstructed by hand.

To test this boundary without dirtying your disk or fighting with paths, pytest gives you a purpose-built tool: the tmp_path fixture. When a test declares a parameter called tmp_path, pytest passes it a unique temporary directory, freshly created and empty, different for each test, and deletes it automatically when the test ends. It's the file boundary with a safety net: you write real files, on a real disk, with all the real serialization, but in a disposable corner nobody else touches and that cleans itself. In this lesson you're going to export Reservo bookings to a CSV file in tmp_path, look at the text left on the disk, import it back, and see with your own eyes why price_cents comes back as an integer only if you reconvert it, while start comes back as text —the same datetime lesson from the database, now in a file—.

Connection to the module: this lesson is the file boundary, between the database one (lessons 3 and 4) and the HTTP one (lesson 6). It reencounters the serialization you saw in SQLite —everything to text— in a different resource, so you see it wasn't a SQLite quirk but the law of every data boundary. And it debuts tmp_path, the tool lesson 7 will elevate to a principle: using temporary, unique, self-cleaning resources is one of the three keys to a fast and deterministic boundary test. The border with module 7 is respected: here you use tmp_path to have a real, disposable file; the fine isolation strategies —creating and destroying resources around each test, keeping the tests independent when they share state— are there. tmp_path is the first taste of that isolation, served by pytest.

Analogy: the printed receipt

Think of the difference between telling someone what you bought and handing them the printed receipt. When you tell them out loud, in the same room, the message is ideas in your head passing to theirs —nothing turns into anything else, nothing is written down—. When you hand them the receipt, something different happens: the purchase was printed on paper, became a string of characters —"COFFEE 45.00", "TOTAL 45.00"— that exists outside of you, that the other person can put in their pocket and read tomorrow, and that's no longer an object or a number in your mind: it's text on a paper. If that person wants to treat the "45.00" as a number again —to add it to other receipts—, they have to reconvert it: read the characters "4", "5", ".", "0", "0" and understand that they represent the quantity forty-five. The paper doesn't store numbers; it stores the marks that represent them.

Exporting bookings to a file is printing the receipt. The Booking —an object with a datetime and an integer— is printed as a line of text in a CSV: focus,m-ana,2026-03-10T09:00:00,...,6000. That file exists on the disk, survives your program, and another system can read it tomorrow. But when you import it, you don't get the object back: you get the printed text, and to treat 6000 as an integer again you have to reconvert it with int(...), just as whoever reads the receipt reconverts "45.00" to a number. The 2026-03-10T09:00:00, if nobody reconverts it, stays as text —a printed date, not a datetime—. This lesson is printing the receipt, reading it, and understanding what's preserved only if you reconstruct it.

Reservo's export and import

Here's the code that crosses the file boundary, in both directions. It uses the stdlib's csv module —zero dependencies—, which writes and reads rows of comma-separated text.

# reservo/export.py — export and import bookings to a real CSV file
import csv

from reservo.models import Booking

FIELDS = ["id", "room_id", "member_id", "start", "end", "status", "price_cents"]


def _as_text(value):
    # A datetime serializes to ISO; a str (already serialized) is left as-is.
    return value.isoformat() if hasattr(value, "isoformat") else value


def export_bookings(bookings, path):
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for b in bookings:
            writer.writerow({
                "id": b.id,
                "room_id": b.room_id,
                "member_id": b.member_id,
                "start": _as_text(b.start),
                "end": _as_text(b.end),
                "status": b.status,
                "price_cents": b.price_cents,
            })


def import_bookings(path):
    with open(path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        return [
            Booking(
                id=r["id"],
                room_id=r["room_id"],
                member_id=r["member_id"],
                start=r["start"],                 # comes back as str (text from the file)
                end=r["end"],
                status=r["status"],
                price_cents=int(r["price_cents"]),  # the file is text: must re-convert to int
            )
            for r in reader
        ]

Look at the two lines that are the whole story. In export_bookings, the datetime is converted to text with _as_text (which does .isoformat()), because a file only stores characters —just like SQLite—. In import_bookings, when reading, each field comes back as text, so price_cents=int(r["price_cents"]) reconverts it to the integer it was; but start=r["start"] is left as text, without reconverting to datetime —the same decision (or omission) that produces the str in the SQLite repository—. The CSV is the printed receipt: everything comes out as characters, and you only recover the types you explicitly reconstruct.

Worked example: the round-trip and the text on the disk

Let's test the boundary with two tests. The first does the complete round-trip —export two bookings, import them, and verify they come back with their data—. The second looks at the file: it reads the raw text left on the disk, to see with our own eyes what the booking turned into. We use FakeBookingRepository so the bookings keep their real datetime until the moment of exporting —that way the serialization to text happens at the file boundary, not before, and we see it isolated—.

# tests/test_file_boundary.py — the file boundary with tmp_path
from datetime import datetime

from reservo.export import export_bookings, import_bookings
# ...Reservo imports: FakeBookingRepository, BookingService, doubles, models...

FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
STUDIO = Room(id="studio", name="Studio", capacity=8, hourly_cents=4000)
ANA = Member(id="m-ana", name="Ana", tier="pro")
CLOCK = datetime(2026, 3, 1, 9)


def make_two_bookings():
    repo = FakeBookingRepository()
    service = BookingService(Calendar(), FixedClock(CLOCK),
                             StubPaymentGateway(ok=True), SpyEmailSender(), repo)
    a = service.book(FOCUS, ANA, datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
    b = service.book(STUDIO, ANA, datetime(2026, 3, 11, 9), datetime(2026, 3, 11, 11))
    return [a, b]


def test_export_then_import_round_trips(tmp_path):
    bookings = make_two_bookings()
    path = tmp_path / "bookings.csv"

    export_bookings(bookings, path)
    restored = import_bookings(path)

    assert {b.id for b in restored} == {b.id for b in bookings}
    focus = next(b for b in restored if b.room_id == "focus")
    assert focus.price_cents == 6000          # Focus 3 h pro
    assert isinstance(focus.price_cents, int)  # re-converted from text
    studio = next(b for b in restored if b.room_id == "studio")
    assert studio.price_cents == 6400          # Studio 2 h pro: 4000*2*0.8


def test_the_exported_file_is_real_text(tmp_path):
    bookings = make_two_bookings()
    path = tmp_path / "bookings.csv"
    export_bookings(bookings, path)

    text = path.read_text(encoding="utf-8")
    print("\n--- bookings.csv ---\n" + text + "--------------------")
    assert path.exists()
    lines = text.strip().splitlines()
    assert lines[0] == "id,room_id,member_id,start,end,status,price_cents"
    assert len(lines) == 3                     # header + 2 bookings
    assert "2026-03-10T09:00:00" in text       # the datetime, already as text
    assert "6000" in text

Notice path = tmp_path / "bookings.csv": tmp_path is a Path object to a unique temporary directory pytest created for this test; with the / operator we build the path of the file inside it. We write there, and pytest will delete the whole directory when done —we don't have to clean anything—. The first test verifies the round-trip: the two bookings come back with their ids, and the price_cents comes back as the correct integer (6000 for Focus 3 h pro, 6400 for Studio 2 h pro) because import_bookings reconverted it with int(...). The second reads the file's raw text and prints it, so you see the receipt.

What to expect. On my machine (Python 3.14.0, pytest 9.1.1), with -s to see the file's print:

python3 -m pytest tests/test_file_boundary.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 3 items

tests/test_file_boundary.py::test_export_then_import_round_trips PASSED
tests/test_file_boundary.py::test_the_exported_file_is_real_text
--- bookings.csv ---
id,room_id,member_id,start,end,status,price_cents
bk-m-ana-...,focus,m-ana,2026-03-10T09:00:00,2026-03-10T12:00:00,confirmed,6000
bk-m-ana-...,studio,m-ana,2026-03-11T09:00:00,2026-03-11T11:00:00,confirmed,6400
--------------------
PASSED
tests/test_file_boundary.py::test_price_survives_but_start_comes_back_as_text PASSED

============================== 3 passed in 0.02s ===============================

There's the printed receipt, made visible. The two bookings, which in Python were Booking objects with datetime and integers, were left on the disk as three lines of text: a header and two rows. The start is 2026-03-10T09:00:00 —text, without quotes or a type, just characters—; the price_cents is 6000 —the digits, not a Python integer—. Each row's id carries a numeric suffix that depends on your time zone (we elide it with bk-m-ana-..., as in the rest of the guide). This file exists on the real disk; if it weren't for tmp_path, it would still be there after the test. And when you import it, all of that comes back as text: only price_cents recovers its integer type, because your code reconverted it on purpose.

The price comes back as int, the start comes back as text

A dedicated test on the asymmetry is worth it, because it's the heart of the file boundary and the exact echo of module 1's bug.

def test_price_survives_but_start_comes_back_as_text(tmp_path):
    bookings = make_two_bookings()
    path = tmp_path / "bookings.csv"
    export_bookings(bookings, path)
    restored = import_bookings(path)

    focus = next(b for b in restored if b.room_id == "focus")
    assert isinstance(focus.price_cents, int)  # we re-convert it with int(...)
    assert isinstance(focus.start, str)        # nobody re-converts it to datetime
    assert focus.start == "2026-03-10T09:00:00"

The same file produces two different destinies depending on what the importer does with each field. price_cents comes back as int because import_bookings wraps it in int(...); start comes back as str because nobody wraps it in datetime.fromisoformat(...). The boundary doesn't decide the types; your import code decides them. The file delivers text for everything; you recover objects only where you reconstruct. If Reservo needed start to come back as datetime, you'd have to reconvert it in the importer —exactly the fix module 5 applied to the SQLite repository for the same problem—. It's the same boundary law, in two different resources: serialization flattens everything to text, and the reconstruction is your responsibility.

tmp_path: a real, disposable file, per test

Let's pause a moment on tmp_path, because it's the tool that makes testing this boundary viable. When a test declares tmp_path as a parameter, pytest does three things for you: it creates a temporary directory unique to that test (two tests never share the same, so they don't step on each other), it hands it to you as a pathlib.Path object (with which you build paths with /), and it deletes it automatically when the test ends (pytest keeps the ones from the last few runs in case you need to inspect them, and cleans up the old ones). The result: you write and read real files, on the real disk, with all the real serialization, but without dirtying your project, without collisions between tests, and without having to remember to delete anything. It's the file boundary with the three virtues lesson 7 will ask of every boundary test: real, isolated, and self-cleaning. Compared to the raw tempfile.mkstemp + finally: os.remove(...) you saw in module 5, tmp_path is the version pytest gives you ready-made.

Common mistakes

Expecting price_cents to come back as an integer on its own. What happens: someone imports a CSV and compares booking.price_cents == 6000, and the test fails because '6000' == 6000 is False. Why it happens: they forget that the file stores everything as text. How to detect it: if a numeric comparison fails against a value read from a file, and the error shows the number in quotes ('6000'), it's a str that wasn't reconverted. How to fix it: when importing, wrap the numeric fields in int(...) (or float(...)), as import_bookings does with price_cents. The file boundary, like the database one, flattens everything to text; the types are reconstructed in the import.

Writing to a file with a fixed name in the project directory. What happens: someone exports to "bookings.csv" without a path, the file appears in the project folder, and stays there after the test —dirtying the repository, or worse, being read by the next test—. Why it happens: it's the fastest to write. How to detect it: if after running your tests loose files appear, or a test depends on another through a shared file, this is the problem. How to fix it: use tmp_path for a unique, self-cleaning temporary file per test. Never write test files to fixed project paths.

Opening the file without newline="" or without encoding. What happens: using the csv module, someone opens the file with a bare open(path, "w"); on some platforms blank lines appear between rows, or non-ASCII characters break. Why it happens: the csv module handles its own line breaks, and the default encoding varies by system. How to detect it: rows separated by empty lines, or a UnicodeError with accents. How to fix it: when writing CSV, open with open(path, "w", newline="", encoding="utf-8"), as in export_bookingsnewline="" lets csv control the breaks and encoding="utf-8" fixes the encoding—. It's a file-boundary detail that an in-memory double never forces you to consider, and another reason to test against the real file.

Exercises

Exercise 1 — Predict the file. Without running anything, write the three exact lines export_bookings would produce for two bookings: Focus 3 h for pro Ana (start 2026-03-10T09:00) and Boardroom 1 h for pro Ana (start 2026-03-12T15:00), knowing that Boardroom costs 8000 cents per hour. Pay attention to the header and each one's price.

See solution

The three lines would be (with the id elided, which depends on the time zone):

id,room_id,member_id,start,end,status,price_cents
bk-m-ana-...,focus,m-ana,2026-03-10T09:00:00,2026-03-10T12:00:00,confirmed,6000
bk-m-ana-...,boardroom,m-ana,2026-03-12T15:00:00,2026-03-12T16:00:00,confirmed,6400
  • The header is always the list of FIELDS: id,room_id,member_id,start,end,status,price_cents.
  • Focus 3 h pro: 2500 * 3 = 7500, with a 20% pro discount → 7500 * 80 // 100 = 6000. The start/end go as ISO text.
  • Boardroom 1 h pro: 8000 * 1 = 8000, with the pro discount → 8000 * 80 // 100 = 6400. One hour, so end is 16:00:00.

What to see: everything comes out as plain text, without quotes or types —the datetime as an ISO string, the price as digits—. It's the printed receipt: characters, not objects.

Exercise 2 — The field that breaks on reimport. A colleague adds to Reservo a function next_hour(booking) that does booking.start + timedelta(hours=1). It works with freshly created bookings, but blows up with bookings that come from import_bookings. Without running anything, explain the exact error and how you'd fix it in the importer.

See solution

next_hour blows up with the imported bookings because their start is a str, not a datetime. import_bookings leaves start=r["start"] as text ("2026-03-10T09:00:00"), without reconverting it. When next_hour tries booking.start + timedelta(hours=1), it's doing str + timedelta, and Python raises TypeError: can only concatenate str (not "datetime.timedelta") to str (or an unsupported operand type(s) depending on the operation). It's the exact twin of module 5's bug, where cancel couldn't subtract a str that came from SQLite: the same serialization to text, the same TypeError when trying to use the text as if it were a datetime.

The fix is in the importer: reconstruct the type when reading, just like it's done with price_cents. Instead of start=r["start"], put start=datetime.fromisoformat(r["start"]) (and the same for end). That way the imported Booking object has real datetimes again, and next_hour works. The moral of the file boundary, again: the file delivers text for everything; the types your code doesn't explicitly reconstruct stay as text and blow up when someone tries to use them as the original type. The boundary doesn't warn you; the TypeError at use time does —and that's why you test against the real file, not against a double that would return the object intact—.

Exercise 3 — Why tmp_path and not a fixed name. The test writes to tmp_path / "bookings.csv" instead of to "bookings.csv". Describe two concrete problems that would appear if two different tests each exported to a fixed file called "bookings.csv" in the project directory, and how tmp_path avoids them.

See solution

Two concrete problems with a fixed shared file:

  1. Interference between tests (state that leaks). If test A exports three bookings to "bookings.csv" and test B exports two to the same file, whichever runs second overwrites —or, if one reads the file expecting its own, finds the other's data—. Worse: if a test imports "bookings.csv" without having written it, it can read what a previous test left and pass (or fail) for the wrong reason. The result is tests that depend on the execution order, the classic symptom of unisolated real state.
  2. Garbage in the project (lack of cleaning). The "bookings.csv" file stays in the project folder after running the tests, dirtying the repository and risking being committed by mistake. And if a test assumes the file doesn't exist at the start, it will fail the second time you run the suite.

tmp_path avoids both: it gives each test a unique temporary directory (so A and B never share bookings.csv —each has its own, in its own directory—, eliminating the interference) and deletes it automatically when done (so no garbage is left and you don't depend on an initial state). It's exactly the isolation lesson 7 generalizes: real but unique and disposable resources per test. Using a fixed shared name is trading that isolation for a source of intermittent failures.

Summary and next step

In this lesson you crossed the second boundary, the files one, with pytest's tmp_path fixture. With the printed receipt you understood that writing to a file turns your objects into text that exists outside your process, and that reading it back you get text —not objects—, so the types have to be reconstructed. You proved it with real output: you exported two Reservo bookings to a CSV, looked at the raw text on the disk (...,2026-03-10T09:00:00,...,6000), and checked that price_cents comes back as an integer only because import_bookings reconverts it with int(...), while start comes back as text —the same datetime bug from module 1, now in a file—. And you saw how tmp_path gives you a real file, unique per test and self-cleaning, without dirtying anything.

Before moving on you should be able to: export and import data to a file and explain why everything comes back as text; reconstruct the correct types in the import (int, datetime.fromisoformat); and use tmp_path for an isolated temporary file instead of a fixed path.

What comes next is the third and last boundary, the most "external" of all: HTTP. In lesson 6 you're going to spin up a fake PaymentGateway served over real HTTP with the stdlib's http.server —in a thread, on an ephemeral port— and test the HttpPaymentGateway client against that real server: a POST that crosses TCP, the response that comes back, and a timeout that cuts off a slow wait. It's the boundary where non-determinism (the network that lags or fails) becomes the protagonist, and where the line with testing-backend-applications-guide matters most.

Resources