"""The design-system cascade, as pure functions over already-loaded rows. Deliberately free of every database and service import — including `services/access.py`, which needs `ancestry` to answer "can this caller read this system?" and would otherwise form an import cycle with the service that needs `access` back. A module that imports nothing can be imported by both. Being pure is also what makes the cascade rule testable without a database: these take a plain `{id: parent_id}` map, so a test states the shape of a hierarchy in one literal instead of building one. """ from collections.abc import Mapping, Sequence from dataclasses import dataclass # The mode key that applies when no more specific one does. Emitted CSS puts it # on the base selector and every other key on a mode selector, mirroring how a # stylesheet is actually written: light on `:root`, dark layered over it. BASE_MODE = "base" def ancestry(system_id: int, parents: Mapping[int, int | None]) -> list[int]: """The inheritance chain from `system_id` up to its root, nearest first. Includes `system_id` itself at index 0, because resolution wants deepest-to-shallowest and the system being resolved is the deepest link. A system missing from `parents` terminates the chain rather than raising: a parent whose row was soft-deleted or filtered out is a truncated chain, not a failed request. The visited-set is defensive, not the primary guard — writes already refuse to create a cycle (`would_cycle`). It is here because a loop introduced by a direct DB edit or a future bug must degrade to a truncated chain instead of spinning forever. Truncation shows up in the result; a hang shows up as an outage. """ chain: list[int] = [] seen: set[int] = set() current: int | None = system_id while current is not None and current not in seen: seen.add(current) chain.append(current) current = parents.get(current) return chain def would_cycle( system_id: int, proposed_parent_id: int | None, parents: Mapping[int, int | None], ) -> bool: """Would making `proposed_parent_id` the parent of `system_id` close a loop? True when the proposed parent IS the system, or already inherits from it. The walk goes UP from the proposed parent, which is the cheap direction — each system has at most one parent, so the chain is a line. Asking the equivalent downward question ("is the proposed parent among my descendants?") would mean searching a whole forest for the same answer. `proposed_parent_id=None` clears the parent and can never cycle. """ if proposed_parent_id is None: return False if proposed_parent_id == system_id: return True return system_id in ancestry(proposed_parent_id, parents) # --------------------------------------------------------------------------- # Resolution — flattening a chain into an effective token set # --------------------------------------------------------------------------- @dataclass(frozen=True) class Contribution: """One system's offer for one token in one mode.""" system_id: int value: str @dataclass(frozen=True) class ResolvedToken: """A token after the cascade, carrying the whole argument rather than the verdict. `contributions` holds every system that supplied a value, per mode, DEEPEST FIRST — so `[0]` is the winner and `[1:]` are what it shadowed. Storing the contest rather than a winner plus a separate provenance field means there is nothing to keep in sync: "which system supplied this?" and "what did it override?" are both reads of the same list, and they cannot disagree. Provenance is per MODE, not per token, because overriding is. A system that deepens one accent for light backgrounds while leaving dark alone owns the base value and inherits the dark one, and a token-level "overridden here" flag would have to lie about one of them. """ name: str contributions: dict[str, tuple[Contribution, ...]] group_name: str | None purpose: str | None rationale: str | None supersedes: tuple[str, ...] order_index: int @property def value_by_mode(self) -> dict[str, str]: """The effective value for each mode — the winner of each contest.""" return {mode: entries[0].value for mode, entries in self.contributions.items()} @property def origin_by_mode(self) -> dict[str, int]: """Which system supplied each mode's effective value.""" return {mode: entries[0].system_id for mode, entries in self.contributions.items()} def value_for(self, mode: str) -> str | None: """The value to render in `mode`, falling back to the base mode. This is the read rule the storage shape implies: a token that is not mode-dependent carries only `base`, and asking it for "dark" must yield the base value rather than nothing. """ entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE) return entries[0].value if entries else None def to_dict(self) -> dict: """Payload shape for both surfaces — and it carries the SHADOWED entries. Serialising only the winner would throw away the provenance at the last step, which is the one thing this type exists to preserve. `contributions` is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so a client renders without re-deriving anything, and cannot derive it differently. """ return { "name": self.name, "group_name": self.group_name, "purpose": self.purpose, "rationale": self.rationale, "supersedes": list(self.supersedes), "order_index": self.order_index, "value_by_mode": self.value_by_mode, "origin_by_mode": self.origin_by_mode, "contributions": { mode: [ {"system_id": c.system_id, "value": c.value} for c in entries ] for mode, entries in self.contributions.items() }, } def is_overridden_in(self, system_id: int) -> bool: """Does `system_id` win any mode of this token AND shadow something? The distinction the UI needs: a token this system introduced is not an override, and a token it merely inherits is not either. """ return any( len(entries) > 1 and entries[0].system_id == system_id for entries in self.contributions.values() ) def _sort_key(token: ResolvedToken) -> tuple: # Ungrouped tokens sort last rather than first: a design system that has # started grouping should read as its groups, with the not-yet-filed # remainder at the end. return (token.group_name is None, token.group_name or "", token.order_index, token.name) def resolve_tokens( system_id: int, parents: Mapping[int, int | None], tokens_by_system: Mapping[int, Sequence], ) -> list[ResolvedToken]: """Flatten a system's inheritance chain into its effective token set. Walks from `system_id` up to the root and applies tokens by name, deepest winning. That is the CSS cascade — precedence by name along a parent chain — rather than an analogy to it, which is why the storage model and the stylesheet model came out the same shape. `tokens_by_system` maps a system id to its own token rows. Any object with `.name`, `.value_by_mode`, `.group_name`, `.purpose` and `.order_index` will do, so a test can state a hierarchy in literals and the service can pass ORM rows to the same function. The result includes tokens the system never mentions — inheriting one is what puts it in the effective set. A system with no tokens of its own resolves to its parent's set entire, which is the correct answer for an app that has not departed from the family yet. Merging is per (name, MODE): a child that supplies only a dark value overrides only dark and keeps inheriting base. Metadata (`group_name`, `purpose`, `order_index`) cascades separately by the same deepest-wins rule, since a child overriding a value routinely leaves the family's description of what the token is FOR untouched — and inheriting it beats blanking it. """ chain = ancestry(system_id, parents) contributions: dict[str, dict[str, list[Contribution]]] = {} metadata: dict[str, dict[str, object]] = {} # Deepest first, so the first contribution seen for a (name, mode) wins and # every later one is a shadowed ancestor appended behind it. for depth_system_id in chain: for token in tokens_by_system.get(depth_system_id) or (): per_mode = contributions.setdefault(token.name, {}) for mode, value in (token.value_by_mode or {}).items(): per_mode.setdefault(mode, []).append( Contribution(system_id=depth_system_id, value=value) ) meta = metadata.setdefault( token.name, { "group_name": None, "purpose": None, "rationale": None, "supersedes": None, "order_index": None, }, ) for field in ("group_name", "purpose", "rationale"): if meta[field] is None: meta[field] = getattr(token, field, None) # order_index alone treats 0 as UNSTATED rather than "first", # because 0 is the column default. Reading it as a real value would # let any child override drag its token to the top of the group and # lose the family's ordering — a visible reshuffle in return for a # change that only touched a colour. if not meta["order_index"]: meta["order_index"] = getattr(token, "order_index", 0) or None # `supersedes` cascades on EMPTINESS, not on None: a child that # overrides a colour and says nothing about which literals it # replaces should keep the family's declaration, and an empty list # is what "said nothing" looks like once the column is NOT NULL. # A child that states its own list replaces the whole thing. if not meta["supersedes"]: meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None resolved = [ ResolvedToken( name=name, contributions={ mode: tuple(entries) for mode, entries in per_mode.items() }, group_name=metadata[name]["group_name"], purpose=metadata[name]["purpose"], rationale=metadata[name]["rationale"], supersedes=metadata[name]["supersedes"] or (), order_index=metadata[name]["order_index"] or 0, ) for name, per_mode in contributions.items() ] return sorted(resolved, key=_sort_key)