Skip to content

← Seams, Modules, Packaging and Tooling step 24 of 36

Medium Primitives

Flattening a dependency group

Implement the resolution step every PEP 735 aware installer performs before it can install anything.

GroupTable = Mapping[str, Sequence[str | Mapping[str, str]]]

class CyclicGroupError(Exception):
    path: tuple[str, ...]

class UnknownGroupError(KeyError):
    name: str

def resolve_group(groups: GroupTable, name: str) -> list[str]:

Each entry in a group is either a requirement string ("pytest>=8") or an include ({"include-group": "test"}). Flatten the named group into a flat list of requirement strings:

  • First-seen order is preserved. An include contributes its requirements at the position the include appears.
  • Duplicates collapse. A diamond — dev includes test and lint, both of which include base — yields base‘s requirements exactly once, at their first position.
  • An include of a group that does not exist raises UnknownGroupError naming the missing group, which is not necessarily the one you were asked to resolve.
  • A cycle raises CyclicGroupError whose path is the cycle itself, starting and ending at the repeated group: ("a", "b", "a"). A group that includes itself gives ("a", "a").

Because the harness compares return values rather than exceptions, the graded entrypoint is a thin driver that reports what happened:

def solve(groups: GroupTable, name: str) -> tuple[Literal["ok", "cycle", "unknown"], list[str]]:
  • ("ok", requirements) on success,
  • ("cycle", list(err.path)) for CyclicGroupError,
  • ("unknown", [err.name]) for UnknownGroupError.

Write the exceptions properly anyway: resolve_group must raise, and solve must catch. An error type that carries structured data — the cycle path, the missing name — rather than only a formatted message is the difference between a caller that can print something useful and one that has to parse your string.

Why the cycle path, not just “there is a cycle”. dev includes test includes integration includes dev. Told only that a cycle exists, a maintainer stares at a twelve-group table. Handed ("dev", "test", "integration", "dev"), they delete one line. Note also that the reported path must be the cycle, not the whole traversal stack: resolving top, which includes a cycle it is not part of, reports the cycle without top.

Your submission must pass mypy --strict. Entries are str | Mapping[str, str], so narrow with isinstance before calling .get, and remember that Mapping.get returns str | None.

Loading visualization…