Skip to content

← Precise Type Hints with Literals and Generics step 15 of 26

Medium Primitives

Accept Read-Only Inputs and Return Concrete Results

Write two helpers. group_by splits items into groups by a key, and merge_counts adds up several tables of counts. An earlier exercise in this track showed that read-only types such as Sequence and Mapping accept more callers than list and dict. Here you use read-only types for the inputs, and return a plain dict that callers can change.

group_by(["pear", "plum", "apple"], lambda word: word[:1])
# {"p": ["pear", "plum"], "a": ["apple"]}
merge_counts(({"a": 2, "b": -1}, {"a": -2}))
# {"a": 0, "b": -1}

Pick each input type by what the function does with it

Iterable[T] says only that you can loop over the value. That is all both helpers need, and it accepts a list, a tuple or a generator. Mapping[K, V] lets you read keys and values, and it accepts a dict or a read-only mapping. Return dict and list, because you build them and the caller may want to change them.

What to write

from collections.abc import Callable, Iterable, Mapping

def group_by[T, K](items: Iterable[T], key: Callable[[T], K]) -> dict[K, list[T]]:
    ...

def merge_counts(sources: Iterable[Mapping[str, int]]) -> dict[str, int]:
    ...

group_by:

  • Loop over items once, and call key once per item.
  • Put each item in the group for its key. Equal keys share a group.
  • List the groups in the order their keys first appear. Inside each group, keep the items in input order, duplicates included. Do not sort.
  • Return a new dictionary of new lists that hold the original items. Empty input gives {}.
  • Do not catch exceptions from key or from the loop.

merge_counts:

  • Read each source once, and add its values into a new dictionary.
  • Keep keys whose total is zero or negative.
  • Leave the sources unchanged. Empty input gives {}.
  • Do not add HOUSE_COUNTS here. solve adds it.

The starter defines HOUSE_COUNTS as a read-only mapping:

HOUSE_COUNTS: Final[Mapping[str, int]] = MappingProxyType({"z": 1})

Iterable[dict[str, int]] would reject it, because a Mapping is not a dict. Iterable[Mapping[str, int]] accepts it, along with a generator of mappings.

Keep both type parameters on group_by. Then mypy knows that grouping strings by len gives dict[int, list[str]].

What solve returns

solve(words, counts, trap) returns a tuple of four values:

  1. The group keys from group_by(words, lambda word: word[:1]), in first-seen order. word[:1] gives "" for an empty word, where word[0] would raise IndexError.
  2. The matching lists of words, in input order with duplicates.
  3. merge_counts([*counts, HOUSE_COUNTS]). This adds HOUSE_COUNTS once, even when counts is empty, so the total for "z" goes up by 1.
  4. The distinct characters of trap in first-seen order, from group_by(trap, lambda char: char). A string is iterable, so group_by("banana", ...) visits one character at a time.

Leave words, counts and HOUSE_COUNTS unchanged.

solve(["", "pear", "plum"], [{"z": -1}], "banana")
# (["", "p"], [[""], ["pear", "plum"]], {"z": 0}, ["b", "a", "n"])
solve([], [], "")
# ([], [], {"z": 1}, [])

The tests check the order of values 1, 2 and 4. They compare the merged dictionary by its keys and values only.