Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ What breaks:
- **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`.
- **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.

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

## `SeamMultiWorkspace` is renamed to `SeamWithoutWorkspace`

Expand Down
23 changes: 14 additions & 9 deletions seam/deep_attr_dict.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# https://stackoverflow.com/a/3031270/559475
class DeepAttrDict(dict):
MARKER = object()
"""A dict whose keys are also readable as attributes, nested dicts included.

Reading a missing key raises like a plain dict: KeyError when indexing,
AttributeError for attribute access. Probe for optional keys with
``.get()``, ``in``, or ``hasattr``.
"""

def __init__(self, value=None):
if value is None:
Expand All @@ -16,11 +20,12 @@ def __setitem__(self, key, value):
value = DeepAttrDict(value)
super().__setitem__(key, value)

def __getitem__(self, key):
found = self.get(key, DeepAttrDict.MARKER)
if found is DeepAttrDict.MARKER:
found = DeepAttrDict()
super().__setitem__(key, found)
return found
__setattr__ = __setitem__

__setattr__, __getattr__ = __setitem__, __getitem__
def __getattr__(self, key):
try:
return self[key]
except KeyError:
# Raise AttributeError so hasattr, getattr defaults, and
# copy/pickle protocol probes behave like any other object.
raise AttributeError(key) from None
76 changes: 75 additions & 1 deletion test/deep_attr_dict_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,81 @@
import pytest

from seam import Seam
from seam.deep_attr_dict import DeepAttrDict
from seam.resources import seam_event_from_dict


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

assert attrdict.a.b.c == 5
assert attrdict.a.b.c == 5 # pylint: disable=no-member


def test_nested_dicts_keep_attribute_access():
attrdict = DeepAttrDict()
attrdict.a = {"b": {"c": 5}}

assert attrdict.a.b.c == 5 # pylint: disable=no-member
assert attrdict["a"]["b"]["c"] == 5


def test_reading_a_missing_key_raises_key_error():
attrdict = DeepAttrDict({"reservation_id": "abc"})

with pytest.raises(KeyError):
attrdict["reservaton_id"] # pylint: disable=pointless-statement


def test_reading_a_missing_attribute_raises_attribute_error():
attrdict = DeepAttrDict({"reservation_id": "abc"})

with pytest.raises(AttributeError):
attrdict.reservaton_id # pylint: disable=pointless-statement


def test_reading_a_missing_key_does_not_insert_it():
attrdict = DeepAttrDict({"reservation_id": "abc"})

with pytest.raises(AttributeError):
attrdict.reservaton_id # pylint: disable=pointless-statement

assert "reservaton_id" not in attrdict
assert len(attrdict) == 1
assert dict(attrdict) == {"reservation_id": "abc"}


def test_missing_keys_work_with_standard_probes():
attrdict = DeepAttrDict({"reservation_id": "abc"})

assert not hasattr(attrdict, "reservaton_id")
assert getattr(attrdict, "reservaton_id", None) is None
assert attrdict.get("reservaton_id") is None
assert "reservaton_id" not in attrdict


def test_custom_metadata_reads_do_not_mutate_the_device(recording_server):
device_payload = {
"device": {
"device_id": "44444444-4444-4444-4444-444444444444",
"custom_metadata": {"reservation_id": "abc"},
}
}

with recording_server([(200, device_payload)]) as (endpoint, _):
seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint)
device = seam.devices.get(device_id="44444444-4444-4444-4444-444444444444")

with pytest.raises(AttributeError):
device.custom_metadata.reservaton_id # pylint: disable=pointless-statement

# The typo'd read leaves no key behind to re-serialize to the API.
assert dict(device.custom_metadata) == {"reservation_id": "abc"}


def test_unknown_event_fallback_fields_stay_readable():
event = seam_event_from_dict(
{"event_id": "e", "event_type": "unknown.event", "foo": {"bar": 1}}
)

assert event.event_id == "e"
assert event.foo.bar == 1
Loading