Skip to content

← Modelling Data with Dataclasses step 6 of 28

Medium Primitives

Fix a Dataclass Hierarchy with Keyword-Only Fields

You model storage resources as frozen dataclasses. Every resource has an id, a created_at time with a default, and an owner. The module fails as soon as Python reaches the first class:

from dataclasses import dataclass


@dataclass(frozen=True)
class Resource:
    id: str
    created_at: str = "1970-01-01T00:00:00Z"
    owner: str
# TypeError: non-default argument 'owner' follows default argument

The generated __init__ takes the fields in order, so it would be __init__(self, id, created_at="1970-...", owner). Python rejects that for any function, because a parameter without a default cannot follow one with a default.

Make the fields keyword-only

A keyword-only parameter is one the caller must pass as name=value. Keyword-only parameters can come in any order, with or without defaults. @dataclass(kw_only=True) makes every field that the class declares keyword-only. With it on Resource:

Resource(id="r1", owner="team-a")  # works
Resource("r1", "team-a")           # TypeError

kw_only=True covers only the fields that its own class declares. A subclass without it adds ordinary positional fields. So put it on every class in the hierarchy.

To make only some fields keyword-only, use KW_ONLY. It is a marker that makes the fields after it keyword-only, and the marker itself is not a field:

from dataclasses import KW_ONLY, dataclass


@dataclass
class Job:
    name: str
    _: KW_ONLY
    retries: int = 3
    owner: str


print(Job("backup", owner="ops"))  # Job(name='backup', retries=3, owner='ops')

For this task, put kw_only=True on each decorator instead.

What to write

The starter defines three frozen dataclasses:

  • Resource declares id, created_at and owner, in that order. Only created_at has a default, "1970-01-01T00:00:00Z".
  • Bucket inherits from Resource and adds a required name.
  • VersionedBucket inherits from Bucket and adds a required version.

Fix them:

  • Add kw_only=True to all three decorators, next to frozen=True.
  • Keep the field types, the field order and the default as they are.
  • Do not give owner, name or version a default.

Then fill in solve. Build the object that target names with keyword arguments, and leave created_at at its default:

target Construction
"Resource" Resource(id=resource_id, owner=owner)
"Bucket" Bucket(id=resource_id, owner=owner, name=name)
"VersionedBucket" VersionedBucket(id=resource_id, owner=owner, name=name, version=version)

Raise ValueError for any other target. solve gives name and version defaults only so that one signature serves all three targets. The class fields stay required.

Return a dictionary with these keys:

  • "field_order": the names from fields(obj), in order.
  • "match_args": the class's __match_args__, converted to a list.
  • "repr": repr(obj).
  • "positional_error": what the supplied _positional_error helper returns. It tries to build the class with one positional argument and returns "TypeError" when that fails.
solve("Bucket", "r1", "team-a", name="logs")
# {
#     "field_order": ["id", "created_at", "owner", "name"],
#     "match_args": [],
#     "repr": "Bucket(id='r1', created_at='1970-01-01T00:00:00Z', owner='team-a', name='logs')",
#     "positional_error": "TypeError",
# }

Keyword-only fields keep their declared order in fields() and in the repr. __match_args__ lists only the fields you can pass by position, so here it is empty. A class pattern such as Bucket(name=n) still works, and Bucket(n) raises TypeError.

Your code must pass mypy --strict. The helper stores the class in a Callable[..., object] variable, so that mypy allows a call that the class's signature forbids. Keep the helper as it is.