ooad1-9 Exercise 2: The Bike Shop's Components ===================================================== bikeshop.components.puml (PlantUML 1.2026.8): @startuml scale 1.3 left to right direction component "Booking" as Booking component "Invoicing" as Invoicing component "PartsStock" as Stock component "StandardPricing" as Std component "TradePricing" as Trade interface "Pricing" as Pricing interface "PartLookup" as Lookup Invoicing -( Pricing Std -- Pricing Trade -- Pricing Invoicing -( Lookup Stock -- Lookup Booking ..> Invoicing : asks for an invoice @enduml Render: rendered ex2\bikeshop.components.puml -> bikeshop.components.puml.svg (6 KB) ex2.py (Python 3.14): """Exercise 2: the bike shop's components as Python, with the interfaces owned by the component that REQUIRES them.""" import unittest from dataclasses import dataclass from typing import Protocol, runtime_checkable # --- interfaces required by Invoicing: declared beside Invoicing, not beside the providers --- @runtime_checkable class Pricing(Protocol): def price(self, hours: float, part_pence: int) -> int: ... @runtime_checkable class PartLookup(Protocol): def price_of(self, part_no: str) -> int: ... @dataclass class Invoicing: pricing: Pricing parts: PartLookup def invoice(self, hours: float, part_nos: list[str]) -> int: return self.pricing.price(hours, sum(self.parts.price_of(p) for p in part_nos)) # --- providers --- class StandardPricing: def price(self, hours: float, part_pence: int) -> int: return int(hours * 3000) + part_pence class TradePricing: def price(self, hours: float, part_pence: int) -> int: return int(hours * 2400) + part_pence * 90 // 100 class PartsStock: def __init__(self): self.prices = {"TUBE": 450, "CHAIN": 1299} def price_of(self, part_no: str) -> int: return self.prices[part_no] class Tests(unittest.TestCase): def test_providers_satisfy_the_required_interfaces(self): for provider, interface in ((StandardPricing(), Pricing), (TradePricing(), Pricing), (PartsStock(), PartLookup)): self.assertIsInstance(provider, interface) def test_a_non_provider_does_not(self): self.assertNotIsInstance(object(), Pricing) def test_either_pricing_component_plugs_in(self): stock = PartsStock() self.assertEqual(Invoicing(StandardPricing(), stock).invoice(1.5, ["TUBE", "TUBE"]), 5400) self.assertEqual(Invoicing(TradePricing(), stock).invoice(1.5, ["TUBE", "TUBE"]), 3600 + 810) if __name__ == "__main__": unittest.main(verbosity=2) Run with: py ex2.py Output: test_a_non_provider_does_not (__main__.Tests.test_a_non_provider_does_not) ... ok test_either_pricing_component_plugs_in (__main__.Tests.test_either_pricing_component_plugs_in) ... ok test_providers_satisfy_the_required_interfaces (__main__.Tests.test_providers_satisfy_the_required_interfaces) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.000s OK What you should see in the diagram: Invoicing with two sockets, one on each of the interfaces Pricing and PartLookup; StandardPricing and TradePricing each with a lollipop on Pricing; PartsStock with a lollipop on PartLookup; a dashed arrow from Booking to Invoicing. In the code: Pricing and PartLookup are declared at the top, beside Invoicing, the component that requires them; the providers (StandardPricing, TradePricing, PartsStock) do not import or mention them. The first test checks that each provider fits its interface (isinstance works because the protocols are marked runtime_checkable) and that an arbitrary object does not. The third test plugs either pricing component into the same Invoicing: 5400p with standard pricing, and 4410p with trade pricing (3600 for labour at the trade rate plus the parts with 10 percent off). WHY THIS WORKS AS AN ANSWER --------------------------- The interface sits with the component that needs it, so replacing the pricing component never touches Invoicing, and a new pricing scheme is a new class with a price method. One caution: isinstance on a runtime-checkable protocol only checks that the method names exist, not their signatures, so it is a smoke test, not a proof of fit.