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
- H1 — Domain: reuse with judgment
- H2 — Services:
SalesService, the solved piece - H3 — Persistence: repositories with atomic saves
- H4 — Interface: one complete example, the rest by contract
- H5 — Report: a skeleton with expected outputs
- H6 — Polish: logging, README, requirements
- Working in small steps (and an honest word about git)
- Typical integration traps
- 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 InvalidCouponErrorIf 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 saleThree things that make this code professional and that you must preserve in yours:
- The three phases: validate → compute → mutate. If
InvalidCouponErrorfires 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 fromapply_couponis still protecting us. - The log tells the story: every rejection is a
WARNINGwith 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.jsonlives. Whoever builds it decides — and in the tests, "whoever builds it" will usetmp_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), 201Look 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.basicConfigtodata/papyrus.log, a format with date and level, as in 07-05) and verify FR7: trigger a stock rejection and look for itsWARNINGin the file. requirements.txt:pip freeze > requirements.txtfrom 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):
- Reproduce small: isolate the failure in a 5-line script outside the web. If
sell()fails, don't debug it through HTTP. - Read the traceback bottom-up: the last line says what; the first line that is yours (not Flask's or pandas's) says where.
- Interrogate the log:
papyrus.logtells 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). pdbat 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".- 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
SalesServicewithout 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 inrestock, 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
loggingin every module. It is configured once at startup; the modules only callgetLogger("papyrus"). TwobasicConfigcalls = 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.csvexist 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
- Project milestone —
restock. ImplementSalesService.restock(title: str, units: int) -> Bookwith the three phases: validate that the book exists and thatunits >= 1(you decide which error the second check raises, and document it), add stock, persist and record it in the log atINFOlevel. Verification: restocking 5 copies of The Odyssey leaves stock 9 incatalog.jsonafter a restart. - Project milestone —
close_till. Implementclose_till(date: str) -> float: the total sold on that date according tosales.csv. Hint: reuse yourread_salesgenerator from M8 orcsv.DictReaderfrom M6. Verification: after selling 18.67 and 10.35 today, today's till close gives 29.02 and yesterday's gives 0.0. - 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
curlor the browser.
Solutions
- Reference — the body follows the exact pattern of
sell: phase 1,if title not in self._catalog: raise BookNotFoundError(title)andif units < 1: raise ValueError(...)(hereValueErroris defensible: negative units are not a business case but a programming error; if you preferred to create aInvalidRestockError, that is correct too — what matters is that you decided and noted it); phase 3,book.stock += units,save,logger.info(...),return book. - Reference: filter rows with
row["date"] == date, accumulatefloat(row["amount"])and returnround(total, 2). If the CSV doesn't exist yet, returning0.0is reasonable (an empty till) — another decision forDECISIONS.md. The expectable trap: forgetting thefloat()and concatenating strings. - No single solution. Self-check for track A:
curl -X POST /api/bookswith 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 yourerrorhandlers.
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
- 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
