Skip to content

Commit 1e56f51

Browse files
committed
fix: Raise for missing DeepAttrDict keys instead of inserting them
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY
1 parent 8b17d94 commit 1e56f51

3 files changed

Lines changed: 93 additions & 11 deletions

File tree

MIGRATION.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,10 @@ What breaks:
227227
- **Typoed attributes raise `AttributeError`.** In v1, reading an unknown attribute silently returned (and inserted) an empty mapping, so typos went unnoticed and were truthy-checked as empty dicts. In v2 they fail loudly — code that probed for optional fields via bare attribute access should use `.get("field")` or `hasattr`.
228228
- **Undocumented nested fields are stripped.** API fields not (yet) in the SDK's generated types are dropped during hydration instead of being passed through. If you depend on a field the SDK does not model, upgrade the SDK to a version that includes it.
229229

230-
Free-form record properties, such as `custom_metadata`, remain plain mappings and are not affected.
230+
Free-form record properties, such as `custom_metadata`, remain mappings with
231+
attribute access, and reading a missing key from them fails loudly the same
232+
way: indexing raises `KeyError` and attribute access raises `AttributeError`.
233+
Probe for optional keys with `.get("key")` or `"key" in mapping`.
231234

232235
## `SeamMultiWorkspace` is renamed to `SeamWithoutWorkspace`
233236

seam/deep_attr_dict.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
# https://stackoverflow.com/a/3031270/559475
21
class DeepAttrDict(dict):
3-
MARKER = object()
2+
"""A dict whose keys are also readable as attributes, nested dicts included.
3+
4+
Reading a missing key raises like a plain dict: KeyError when indexing,
5+
AttributeError for attribute access. Probe for optional keys with
6+
``.get()``, ``in``, or ``hasattr``.
7+
"""
48

59
def __init__(self, value=None):
610
if value is None:
@@ -16,11 +20,12 @@ def __setitem__(self, key, value):
1620
value = DeepAttrDict(value)
1721
super().__setitem__(key, value)
1822

19-
def __getitem__(self, key):
20-
found = self.get(key, DeepAttrDict.MARKER)
21-
if found is DeepAttrDict.MARKER:
22-
found = DeepAttrDict()
23-
super().__setitem__(key, found)
24-
return found
23+
__setattr__ = __setitem__
2524

26-
__setattr__, __getattr__ = __setitem__, __getitem__
25+
def __getattr__(self, key):
26+
try:
27+
return self[key]
28+
except KeyError:
29+
# Raise AttributeError so hasattr, getattr defaults, and
30+
# copy/pickle protocol probes behave like any other object.
31+
raise AttributeError(key) from None

test/deep_attr_dict_test.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,81 @@
1+
import pytest
2+
3+
from seam import Seam
14
from seam.deep_attr_dict import DeepAttrDict
5+
from seam.resources import seam_event_from_dict
26

37

48
def test_deep_attr_dict():
59
attrdict = DeepAttrDict({"a": {"b": {"c": 5}}})
610

7-
assert attrdict.a.b.c == 5
11+
assert attrdict.a.b.c == 5 # pylint: disable=no-member
12+
13+
14+
def test_nested_dicts_keep_attribute_access():
15+
attrdict = DeepAttrDict()
16+
attrdict.a = {"b": {"c": 5}}
17+
18+
assert attrdict.a.b.c == 5 # pylint: disable=no-member
19+
assert attrdict["a"]["b"]["c"] == 5
20+
21+
22+
def test_reading_a_missing_key_raises_key_error():
23+
attrdict = DeepAttrDict({"reservation_id": "abc"})
24+
25+
with pytest.raises(KeyError):
26+
attrdict["reservaton_id"] # pylint: disable=pointless-statement
27+
28+
29+
def test_reading_a_missing_attribute_raises_attribute_error():
30+
attrdict = DeepAttrDict({"reservation_id": "abc"})
31+
32+
with pytest.raises(AttributeError):
33+
attrdict.reservaton_id # pylint: disable=pointless-statement
34+
35+
36+
def test_reading_a_missing_key_does_not_insert_it():
37+
attrdict = DeepAttrDict({"reservation_id": "abc"})
38+
39+
with pytest.raises(AttributeError):
40+
attrdict.reservaton_id # pylint: disable=pointless-statement
41+
42+
assert "reservaton_id" not in attrdict
43+
assert len(attrdict) == 1
44+
assert dict(attrdict) == {"reservation_id": "abc"}
45+
46+
47+
def test_missing_keys_work_with_standard_probes():
48+
attrdict = DeepAttrDict({"reservation_id": "abc"})
49+
50+
assert not hasattr(attrdict, "reservaton_id")
51+
assert getattr(attrdict, "reservaton_id", None) is None
52+
assert attrdict.get("reservaton_id") is None
53+
assert "reservaton_id" not in attrdict
54+
55+
56+
def test_custom_metadata_reads_do_not_mutate_the_device(recording_server):
57+
device_payload = {
58+
"device": {
59+
"device_id": "44444444-4444-4444-4444-444444444444",
60+
"custom_metadata": {"reservation_id": "abc"},
61+
}
62+
}
63+
64+
with recording_server([(200, device_payload)]) as (endpoint, _):
65+
seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint)
66+
device = seam.devices.get(device_id="44444444-4444-4444-4444-444444444444")
67+
68+
with pytest.raises(AttributeError):
69+
device.custom_metadata.reservaton_id # pylint: disable=pointless-statement
70+
71+
# The typo'd read leaves no key behind to re-serialize to the API.
72+
assert dict(device.custom_metadata) == {"reservation_id": "abc"}
73+
74+
75+
def test_unknown_event_fallback_fields_stay_readable():
76+
event = seam_event_from_dict(
77+
{"event_id": "e", "event_type": "unknown.event", "foo": {"bar": 1}}
78+
)
79+
80+
assert event.event_id == "e"
81+
assert event.foo.bar == 1

0 commit comments

Comments
 (0)