The blueprint is on the table; today the bricks go in. This lesson walks with you milestone by milestone, but it does not do the work for you: you will find skeletons, contracts and checkpoints, and a single fully solved piece — the SalesService — because it integrates five modules of the course in thirty lines and deserves to be seen whole and commented. The rest you implement yourself, verifying each milestone against its 12-02 "demo" before moving to the next. You will also find here what no tutorial tells you: the typical integration traps and a protocol for when you get stuck (and you will get stuck — it is planned for, and it is not bad news).

Contents

  1. H1 — Domain: reuse with judgment
  2. H2 — Services: SalesService, the solved piece
  3. H3 — Persistence: repositories with atomic saves
  4. H4 — Interface: one complete example, the rest by contract
  5. H5 — Report: a skeleton with expected outputs
  6. H6 — Polish: logging, README, requirements
  7. Working in small steps (and an honest word about git)
  8. Typical integration traps
  9. The 5-step unstuck protocol

H1 — Domain: reuse with judgment

The first milestone is a move with a clean-up: bringing the M5-M7 pieces into the new package, reviewing them against the 12-02 contracts. A checklist of what is reused as is and what changes:

Piece Origin Does it change?
dataclass Book with final_price(member) and in_stock() M5 (05-06) The author field is added (FR1 demands it), plus full type hints
The PapyrusError hierarchy and its 4 children M7 (07-04) As is — it was already exactly what NFR3 asks for
COUPONS and apply_coupon M7 As is
Member New @dataclass(frozen=True) with code, name, joined — immutable because nobody should mutate a member mid-sale
BOOK_VAT, MEMBER_DISCOUNT M1 As is (0.04 and 0.05); they live in models.py next to what uses them

H1 checkpoint (in a REPL from the project root):

>>> from papyrus.models import Book
>>> Book("The Odyssey", "Homer", 12.50, 4).final_price(member=True)
12.35
>>> from papyrus.coupons import apply_coupon
>>> apply_coupon(100.0, "PAPYRUS10")
90.0
>>> apply_coupon(100.0, "NOSUCH")   # must raise InvalidCouponError

If the first number is not 12.35, stop and fix it now: the whole system will lean on that formula (price × 1.04 × 0.95, rounded to 2 decimals).

H2 — Services: the solved piece

SalesService.sell is the heart of Papyrus Online: it validates against the catalog (M4/M5), raises custom errors (M7), computes the price with member and coupon (M1/M3), persists (M6) and leaves a trail in the log (M7). That is why it is the only piece we hand over complete — read it line by line, because it is the pattern you will repeat in everything else:

# papyrus/services.py
from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date

from papyrus.coupons import apply_coupon
from papyrus.errors import (BookNotFoundError, InsufficientStockError,
                            InvalidMemberError)
from papyrus.models import Book, Member
from papyrus.repositories import (CatalogRepository, MembersRepository,
                                  SalesLog)

logger = logging.getLogger("papyrus")           # the M7 logger, configured once in the app


@dataclass(frozen=True)
class Sale:
    """Immutable result of a sale: what gets written to sales.csv."""
    date: str
    title: str
    units: int
    amount: float


class SalesService:
    """Orchestrates a complete sale: validate, compute, persist, log."""

    def __init__(self, catalog_repo: CatalogRepository,
                 members_repo: MembersRepository,
                 sales_log: SalesLog) -> None:
        # The service receives the repositories already built (the 12-04 tests
        # will hand it repositories over tmp_path: this is why the design is so).
        self._catalog_repo = catalog_repo
        self._sales_log = sales_log
        self._catalog: dict[str, Book] = catalog_repo.load()
        self._members: dict[str, Member] = members_repo.load()

    def sell(self, title: str, units: int,
             member_code: str | None = None,
             coupon: str | None = None) -> Sale:
        # ---- PHASE 1: validate EVERYTHING before touching ANYTHING (golden rule) ----
        if title not in self._catalog:
            logger.warning("Sale rejected: '%s' is not in the catalog", title)
            raise BookNotFoundError(title)
        book = self._catalog[title]

        if book.stock < units:
            logger.warning("Sale rejected: '%s' asked for %d, only %d in stock",
                           title, units, book.stock)
            raise InsufficientStockError(title, units, book.stock)

        is_member = member_code is not None
        if is_member and member_code not in self._members:
            logger.warning("Sale rejected: unknown member '%s'", member_code)
            raise InvalidMemberError(member_code)

        # ---- PHASE 2: compute (pure domain, no side effects) ----
        price = book.final_price(member=is_member)        # VAT + member discount (M5)
        if coupon is not None:
            price = apply_coupon(price, coupon)           # InvalidCouponError if unknown
        amount = round(price * units, 2)                  # one single final rounding

        # ---- PHASE 3: mutate and persist (only if EVERYTHING above passed) ----
        book.stock -= units
        self._catalog_repo.save(self._catalog)            # 12-02 decision: save per sale
        sale = Sale(date.today().isoformat(), title, units, amount)
        self._sales_log.record(sale)
        logger.info("Sale: %d x '%s' -> %.2f EUR (member=%s, coupon=%s)",
                    units, title, amount, member_code, coupon)
        return sale

Three things that make this code professional and that you must preserve in yours:

  • The three phases: validate → compute → mutate. If InvalidCouponError fires in phase 2, the stock has not been touched yet — FR3 (nothing changes if anything fails) holds by construction, not by luck. Notice that the coupon is validated in phase 2 but before phase 3: the exception from apply_coupon is still protecting us.
  • The log tells the story: every rejection is a WARNING with concrete data (FR7). In 12-04 the log will be your witness between layers.
  • It depends on repositories, not on paths: the service doesn't know where catalog.json lives. Whoever builds it decides — and in the tests, "whoever builds it" will use tmp_path.

H2 checkpoint (quick script): selling Faust ×1 with LUIS-001 and PAPYRUS10 must return a Sale with an amount of 18.67, and Faust's stock must end at 9. You complete restock() and close_till() yourself with the same phase pattern (they are your exercises at the end).

H3 — Persistence: atomic saves

The repositories translate between disk and objects, with not a drop of business in them. Skeleton of CatalogRepository — implement the # TODOs:

# papyrus/repositories.py
import csv, json, os
from dataclasses import asdict
from pathlib import Path
from papyrus.models import Book

class CatalogRepository:
    def __init__(self, path: Path) -> None:
        self._path = path

    def load(self) -> dict[str, Book]:
        # TODO: read the JSON (M6) and build {title: Book(**data)}.
        # Remember: encoding="utf-8" always, and let FileNotFoundError
        # propagate — a missing catalog is a technical error, not a business one.
        ...

    def save(self, catalog: dict[str, Book]) -> None:
        temp = self._path.with_suffix(".json.tmp")
        # TODO 1: dump [asdict(book) for book in catalog.values()]
        #         to the temp file with json.dump(..., indent=2, ensure_ascii=False).
        # TODO 2: os.replace(temp, self._path)
        #         os.replace is atomic: the real catalog is either the old one
        #         or the COMPLETE new one, never a half-written file (the idea
        #         behind M8's CatalogTransaction, in its minimal version).
        ...

MembersRepository is analogous (simpler: it only loads). SalesLog.record opens sales.csv in "a" mode (append, M6), writes the header only if the file did not exist, and adds one row per sale with csv.writer. H3 checkpoint: sell from a script, open catalog.json by hand and check the stock; run the script again and verify that the CSV has two rows and exactly one header.

H4 — Interface: one complete example, the rest by contract

The payoff of the design: the endpoints are tiny. Complete example of the richest one, POST /api/sales (track A), including the error translation with errorhandler (10-03):

# app.py (excerpt)
from flask import Flask, jsonify, request
from papyrus.errors import (BookNotFoundError, InsufficientStockError,
                            InvalidCouponError, InvalidMemberError)

app = Flask(__name__)
service = build_service()            # your function that wires repos + service

@app.errorhandler(BookNotFoundError)
def _not_found(err): return jsonify(error=str(err)), 404

@app.errorhandler(InsufficientStockError)
def _out_of_stock(err): return jsonify(error=str(err)), 409

@app.errorhandler(InvalidMemberError)
@app.errorhandler(InvalidCouponError)
def _bad_request(err): return jsonify(error=str(err)), 400

@app.post("/api/sales")
def create_sale():
    payload = request.get_json(silent=True)
    if not payload or "title" not in payload or "units" not in payload:
        return jsonify(error="Missing fields: title, units"), 400
    sale = service.sell(payload["title"], int(payload["units"]),
                        payload.get("member"), payload.get("coupon"))
    return jsonify(date=sale.date, title=sale.title,
                   units=sale.units, amount=sale.amount), 201

Look at what is not there: no prices, no stock, no files. The endpoint validates the form of the request (is it JSON? are the fields there?) and delegates the substance to the service; the errorhandlers translate each domain error to its HTTP code once, for the whole app. The remaining routes you implement yourself by contract (your milestone 0.5): GET /api/books (200), GET /api/books/<title> (200/404), POST /api/books (201/409 if duplicate), PUT (200/404), DELETE (204/404).

Track B: the equivalent is a sell view with a ModelForm (10-05) whose clean() calls the service inside try/except PapyrusError as err: and turns the error into form.add_error(None, str(err)). The catalog lives in the ORM, but the rule is identical: the view translates, the service solves. H4 checkpoint: the FR5 criteria from 12-01, tried with curl or the browser.

H5 — Report: a skeleton with expected outputs

generate_report is an M11 script packaged as a function. Skeleton:

# papyrus/report.py
def generate_report(csv_path: Path, png_path: Path) -> MonthSummary:
    sales = pd.read_csv(csv_path, parse_dates=["date"])
    # TODO 1: total units and amount (sales["units"].sum(), ...)
    # TODO 2: top 3 titles by units (groupby("title") + sum + nlargest)
    # TODO 3: units per day of the week (dt.day_name() + groupby, M11)
    # TODO 4: bar chart per month with plt.savefig(png_path) — and
    #         plt.close() afterwards, or the tests will pile up open figures.
    # TODO 5: return MonthSummary(...) with all of the above.
    ...

H5 checkpoint — run it over the sales_2026.csv from M11 (copy it into data/); your numbers must nail the canonical ones:

Metric Expected value
CSV rows 487
Total units (Jan-Jun) 520
Best day of the week Saturday (130 u)
Best month April (168 u — Sant Jordi, Catalonia's Book Day)
The PNG exists in data/ and opens

If a number is off, you have a bug that is reproducible with known data — the best kind of bug there is.

H6 — Polish

  • Logging: configure it once at startup (logging.basicConfig to data/papyrus.log, a format with date and level, as in 07-05) and verify FR7: trigger a stock rejection and look for its WARNING in the file.
  • requirements.txt: pip freeze > requirements.txt from your venv… and then edit it by hand, keeping only the direct dependencies (flask or django, pandas, matplotlib, pytest). A 40-line requirements file for 5 dependencies is noise.
  • README: 12-05 has the full template; for now, make it exist with installation and startup.

Working in small steps (and an honest word about git)

The discipline that most protects a project is moving in small, verified steps: one function, its test, its checkpoint — and only then the next one. Professionals materialize this with git, making a commit (a snapshot of the project) for each step: if something breaks, you compare against the last good snapshot. This course has not taught git and we are not going to pretend in three lines that it has; two honest paths:

  • If you don't know git: when you close each milestone, copy the project to versions/milestone-1/, versions/milestone-2/… It is rudimentary, but it serves the "last good snapshot" purpose. And put git down as your first post-course extension (12-05): it is, without argument, the next thing you should learn.
  • If you already use it: one commit per checkpoint, with a message saying what works now ("H3: catalog persists with atomic save").

Typical integration traps

Pieces that work alone fail together. This table will save you hours:

Symptom Probable cause Where it was explained
ModuleNotFoundError: papyrus You are running from the wrong folder; the package is not on the path M3 (03-04)
Amounts with drifting cents (18.68 vs 18.67) You round in every layer; there must be one final rounding per amount M1 (floats), H2
TypeError: '<' not supported between 'str' and 'int' The CSV hands over text and nobody converted it: persistence delivers types, that was its job M6, 12-02
FileNotFoundError: data/catalog.json when launching from another folder Paths relative to the working directory; use paths anchored to the file (Path(__file__).parent) M6 (06-04)
json.JSONDecodeError at startup An earlier save died halfway: your save was not truly atomic M8 (08-04), H3
Tests that pass alone and fail in the suite Shared state: all the tests write to the same data/; use tmp_path M9, 12-04
Broken accents in the JSON Missing ensure_ascii=False and encoding="utf-8" M6 (06-03)
Circular ImportError between services and repositories A lower layer imports from a higher one: the 12-02 design has been inverted M3, 12-02

The 5-step unstuck protocol

Getting stuck is part of the plan. What sets the professional apart is not never getting stuck: it is having a protocol (it is the 09-04/09-05 method, applied to the whole system):

  1. Reproduce small: isolate the failure in a 5-line script outside the web. If sell() fails, don't debug it through HTTP.
  2. Read the traceback bottom-up: the last line says what; the first line that is yours (not Flask's or pandas's) says where.
  3. Interrogate the log: papyrus.log tells what happened before the failure. If the log says nothing useful, that is an improvement to make right now (and it will already be in place for the next bug).
  4. pdb at the border: breakpoint() right where one layer calls the next, and inspect what goes in and what comes out (09-05). 80 % of integration bugs are "I thought you were passing me X and you were passing me Y".
  5. Explain it in writing: two sentences in DECISIONS.md: "I expect A, B happens". Half the time, writing the second sentence shows you the error. If not, rest and come back: the brain keeps working for free.

After 30-45 honest minutes of protocol it is legitimate to look for help outside (documentation, forums) — but you will arrive with the problem already narrowed down, which is how good questions are asked.

Common Mistakes and Tips

  • Copying the SalesService without reading it. It is the project's only free piece; its value is not saving you the typing, it is teaching you the validate-compute-mutate pattern you must replicate in restock, in the endpoints and in the report.
  • Moving two milestones ahead with checkpoints pending. "I'll check it all together later" multiplies the cost of every bug: if H4 fails and H2-H3 were unverified, the suspect can hide in three places instead of one.
  • Configuring logging in every module. It is configured once at startup; the modules only call getLogger("papyrus"). Two basicConfig calls = duplicated or missing lines in the log.
  • Perfecting HTML/CSS in H4. Track B doesn't ask for a pretty site; it asks for a correct one. Visual polish is a time sink with no requirement behind it.
  • Not using the canonical data. The four books and sales_2026.csv exist so that you know what to expect. With made-up data, a strange result could be a bug or could be the data — you can't tell.

Exercises

  1. Project milestone — restock. Implement SalesService.restock(title: str, units: int) -> Book with the three phases: validate that the book exists and that units >= 1 (you decide which error the second check raises, and document it), add stock, persist and record it in the log at INFO level. Verification: restocking 5 copies of The Odyssey leaves stock 9 in catalog.json after a restart.
  2. Project milestone — close_till. Implement close_till(date: str) -> float: the total sold on that date according to sales.csv. Hint: reuse your read_sales generator from M8 or csv.DictReader from M6. Verification: after selling 18.67 and 10.35 today, today's till close gives 29.02 and yesterday's gives 0.0.
  3. Project milestone — your complete H4. Implement the rest of your interface by contract (the 5 remaining routes in A; listing, detail and sale in B). Verification: the FR5 criteria from 12-01, one by one, with curl or the browser.

Solutions

  1. Reference — the body follows the exact pattern of sell: phase 1, if title not in self._catalog: raise BookNotFoundError(title) and if units < 1: raise ValueError(...) (here ValueError is defensible: negative units are not a business case but a programming error; if you preferred to create a InvalidRestockError, that is correct too — what matters is that you decided and noted it); phase 3, book.stock += units, save, logger.info(...), return book.
  2. Reference: filter rows with row["date"] == date, accumulate float(row["amount"]) and return round(total, 2). If the CSV doesn't exist yet, returning 0.0 is reasonable (an empty till) — another decision for DECISIONS.md. The expectable trap: forgetting the float() and concatenating strings.
  3. No single solution. Self-check for track A: curl -X POST /api/books with an already existing title must return 409, not 500 or 200 — if it returns 500, your endpoint is not catching/translating the domain error; review your errorhandlers.

Conclusion

Papyrus Online is no longer a plan: it is code that sells, persists, serves and reports. You have walked the six milestones with the pattern that matters — validate, compute, mutate — seen in detail in the one solved piece, the SalesService, and replicated by you in everything else. You also take away two tools that carry over to any future project: the integration traps table (symptom → cause) and the 5-step unstuck protocol. But "it works for me, today, on my machine" is not a finished system: it is an unverified one. The next lesson turns the 12-01 acceptance criteria into a test suite that proves — to you and to anyone — that Papyrus Online does what it promises, and teaches you to debug across the layers when some test says it doesn't.

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