diff --git a/MIGRATION.md b/MIGRATION.md index edbe6d99..3fa864c3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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` diff --git a/seam/deep_attr_dict.py b/seam/deep_attr_dict.py index 62aef3c8..5d8318fb 100644 --- a/seam/deep_attr_dict.py +++ b/seam/deep_attr_dict.py @@ -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: @@ -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 diff --git a/test/deep_attr_dict_test.py b/test/deep_attr_dict_test.py index ab067800..4702ffbb 100644 --- a/test/deep_attr_dict_test.py +++ b/test/deep_attr_dict_test.py @@ -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