Skip to content

Commit fac7faa

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 7d89157 commit fac7faa

3 files changed

Lines changed: 94 additions & 10 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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,83 @@
1+
import pytest
2+
13
from seam.deep_attr_dict import DeepAttrDict
24

35

46
def test_deep_attr_dict():
57
attrdict = DeepAttrDict({"a": {"b": {"c": 5}}})
68

79
assert attrdict.a.b.c == 5
10+
11+
12+
def test_nested_dicts_keep_attribute_access():
13+
attrdict = DeepAttrDict()
14+
attrdict.a = {"b": {"c": 5}}
15+
16+
assert attrdict.a.b.c == 5
17+
assert attrdict["a"]["b"]["c"] == 5
18+
19+
20+
def test_reading_a_missing_key_raises_key_error():
21+
attrdict = DeepAttrDict({"reservation_id": "abc"})
22+
23+
with pytest.raises(KeyError):
24+
attrdict["reservaton_id"] # pylint: disable=pointless-statement
25+
26+
27+
def test_reading_a_missing_attribute_raises_attribute_error():
28+
attrdict = DeepAttrDict({"reservation_id": "abc"})
29+
30+
with pytest.raises(AttributeError):
31+
attrdict.reservaton_id # pylint: disable=pointless-statement
32+
33+
34+
def test_reading_a_missing_key_does_not_insert_it():
35+
attrdict = DeepAttrDict({"reservation_id": "abc"})
36+
37+
with pytest.raises(AttributeError):
38+
attrdict.reservaton_id # pylint: disable=pointless-statement
39+
40+
assert "reservaton_id" not in attrdict
41+
assert len(attrdict) == 1
42+
assert dict(attrdict) == {"reservation_id": "abc"}
43+
44+
45+
def test_missing_keys_work_with_standard_probes():
46+
attrdict = DeepAttrDict({"reservation_id": "abc"})
47+
48+
assert not hasattr(attrdict, "reservaton_id")
49+
assert getattr(attrdict, "reservaton_id", None) is None
50+
assert attrdict.get("reservaton_id") is None
51+
assert "reservaton_id" not in attrdict
52+
53+
54+
def test_custom_metadata_reads_do_not_mutate_the_device(recording_server):
55+
from seam import Seam
56+
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+
from seam.resources import seam_event_from_dict
77+
78+
event = seam_event_from_dict(
79+
{"event_id": "e", "event_type": "unknown.event", "foo": {"bar": 1}}
80+
)
81+
82+
assert event.event_id == "e"
83+
assert event.foo.bar == 1

0 commit comments

Comments
 (0)