Skip to content

Structure missing optional attributes without defaults as None - #833

Closed
DresdenGman wants to merge 1 commit into
python-attrs:mainfrom
DresdenGman:fix-249-optional-missing-key
Closed

DresdenGman wants to merge 1 commit into
python-attrs:mainfrom
DresdenGman:fix-249-optional-missing-key

Conversation

@DresdenGman

Copy link
Copy Markdown

Fixes #249.

Summary

Structuring a class with an optional attribute that has no default used to fail when the key was missing from the input:

from typing import Optional

from attrs import define
from cattrs import Converter


@define
class Dog:
    name: str
    nickname: Optional[str]


Converter().structure({"name": "Rex"}, Dog)
# Before: ClassValidationError, caused by KeyError: 'nickname'
# After:  Dog(name='Rex', nickname=None)

Adding = None to the attribute is not always an option: in a frozen class, an optional attribute sitting between attributes without defaults cannot gain a default without reordering the fields, which is a breaking change (see the example by @kulych in #249).

Behavior

  • An attribute is affected only if it has no default and its type is a union including None (Optional[X], Union[X, Y, None], X | None, also under Annotated).
  • For such attributes, a missing key is treated exactly like an explicit None: the value still goes through the attribute's structure hook.
  • Attributes with defaults are unchanged — a missing key still yields the default (including for optional attributes, where the default wins over None).
  • Required attributes are unchanged — a missing key still raises the same error as before, and with detailed validation a missing optional attribute no longer adds an error of its own to the collected errors.

Implementation

  • cattrs.gen: in make_dict_structure_fn_from_attrs, the affected attributes read their value with o.get(key) instead of o[key], in both the detailed and non-detailed code paths. (Accesses for attributes with defaults were already guarded by key in o checks and are untouched.)
  • cattrs.converters: the simple BaseConverter path (structure_attrs_fromdict) used to skip missing keys and then fail at instantiation; it now structures None for affected attributes, so both implementation paths behave the same.
  • cattrs._compat: new includes_none_type helper. The existing is_optional only recognizes two-argument unions, which does not cover cases like Union[bool, str, None] from the issue discussion.

Tests

New tests in tests/test_optionals.py cover: the example from the issue, an optional attribute between required ones in a frozen class, defaults taking precedence, required attributes still failing, multi-member unions including None, PEP 604 unions, kw-only and underscore attributes (with and without use_alias), rename overrides, init=False attributes, annotated optionals, dataclasses, and the detailed-validation error collection. They run against both converter implementations and both validation modes where applicable.

Full test suite: 1100 passed, 2 failed — the 2 failures (test_preconf.py::test_tomllib_converter and test_tomllib_converter_dumps) fail identically on unmodified main in the same environment and are unrelated to this change.

@Tinche

Tinche commented Nov 4, 2026

Copy link
Copy Markdown
Member

Thanks for the contribution but I don't think this is a good change; it's injecting non-obvious behavior into the default path. The type of an attribute shouldn't change how missing keys get handled.

What we could do is introduce a recipe into the recipes doc showing how this can be configured on a particular converter; that I would accept gladly.

@Tinche Tinche closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

structure raises a KeyError when optional attribute is missing

2 participants