Configuration and wiring
A function that reads os.environ deep inside is configured by a global: tests have to patch it, and nobody can see which settings exist. Read configuration once, at start-up, into a frozen object, and pass it to whatever needs it.
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
db_path: str = ":memory:"
page_size: int = 20
@classmethod
def from_env(cls, env):
path = env.get("DB", ":memory:")
size = env.get("PAGE_SIZE", "20")
return cls(path, int(size))
print(Settings.from_env({"PAGE_SIZE": "5"}))from_env takes any mapping, so a test passes a plain dict and the program passes os.environ.
The composition root
One function near main builds the whole object graph: it creates the adapters and services and connects them. It is the only place that names the concrete classes, and its keyword arguments let a test swap any piece for a fake. This is dependency injection without a framework.
Your task
Write a frozen Settings(db_path, page_size) with from_env(env) reading SHOP_DB (default ":memory:") and SHOP_PAGE_SIZE (default 20), raising a ValueError that names the variable when the size is not a positive whole number. Write build_app(settings, repo=None) returning an App whose repo is the one given or a new SqliteRepo(settings.db_path), and whose catalog is a Catalog sharing that repo with the configured page size.
The explanation opens here once every check passes.