ooad1-11 Exercise 3: Breaking a Package Cycle by Inverting a Dependency ============================================================================== ex3.py (needs deps.py from the chapter folder; run from there): """Exercise 3: a package cycle, and the way out by inverting one dependency. Run from the chapter folder: py ex3/ex3.py""" import contextlib import io import pathlib import sys import tempfile HERE = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(HERE)) import deps # noqa: E402 def write(root: pathlib.Path, files: dict[str, str]) -> None: for rel, text in files.items(): p = root / "library" / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(text, encoding="utf8") def check(root: pathlib.Path) -> str: buf = io.StringIO() with contextlib.redirect_stdout(buf): deps.check(root, show_edges=False) return "\n".join(" " + l for l in buf.getvalue().splitlines() if "VIOLATION" in l or l.startswith("result")) CYCLE = { "__init__.py": '"""x"""\n', "billing/__init__.py": "from library.members import Member\n\n\ndef invoice(m: Member) -> int:\n return 100 if m.premium else 0\n", "members/__init__.py": "from library.billing import invoice\n\n\nclass Member:\n premium = False\n\n def owes(self) -> int:\n return invoice(self)\n", } FIXED = { "__init__.py": '"""x"""\n', # members declares the interface IT needs; billing no longer imports members at all (it works on any object with .premium) "members/__init__.py": ("from typing import Protocol\n\n\nclass Biller(Protocol):\n def invoice(self, member: \"Member\") -> int: ...\n\n\n" "class Member:\n premium = False\n\n def owes(self, biller: Biller) -> int:\n return biller.invoice(self)\n"), "billing/__init__.py": "class Billing:\n def invoice(self, member) -> int:\n return 100 if member.premium else 0\n", } for title, files in (("1. billing and members import each other", CYCLE), ("2. members owns the Biller interface; billing imports nothing", FIXED)): with tempfile.TemporaryDirectory() as d: root = pathlib.Path(d) write(root, files) print(title) print(check(root) or " (no output)") # and the fixed version still works: a Billing satisfies Biller, and a Member can ask it with tempfile.TemporaryDirectory() as d: root = pathlib.Path(d) write(root, FIXED) sys.path.insert(0, d) from library.billing import Billing from library.members import Member m = Member() m.premium = True print("3. the fixed code runs: a premium member owes", m.owes(Billing())) Run with: py ex3/ex3.py Output: 1. billing and members import each other VIOLATION cycle: billing -> members -> billing result: 1 violation(s) 2. members owns the Biller interface; billing imports nothing result: clean 3. the fixed code runs: a premium member owes 100 WHAT CHANGED Before: members imported billing's invoice function, and billing imported Member for a type hint. Each needed the other to be importable. After: members declares Biller, the interface it needs (a Protocol), and receives a biller as an argument. billing imports nothing from members: it works on any object with a premium attribute, so it still works with the real Member. No import remains between the two packages, and the checker says so and reports clean. The fixed code runs: a premium member owes 100 when handed a Billing. NOTE ON THE CHECKER Step 2's output, 'clean', was 'NOTHING WAS ANALYSED' in my first version of deps.py, because there were no imports between packages and the guard confused that with an empty path. See the chapter's 'Mistakes' box; chapter 9's script and text were corrected. WHY THIS WORKS AS AN ANSWER --------------------------- Inverting the dependency means the lower-level package (billing, here) conforms to an interface owned by the higher-level one (members), so no arrow needs to point from billing back to members. It is the same move as Catalogue in chapter 9: the consumer owns the interface.