From c712fde8c55dee538eac12525a789c990ddee2b3 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 13 Feb 2025 17:33:34 +1100 Subject: [PATCH] chg: handle obj.get(k, default) call pattern for backward compatibility - not used internally but for external libs that assume Mapping == dict - test case notes other differences from default dict behaviour --- pyxform/survey_element.py | 18 ++++++++++ tests/test_survey_element.py | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/test_survey_element.py diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index b6c851b1..3312e229 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -4,6 +4,7 @@ import json import re +import warnings from collections.abc import Callable, Generator, Iterable, Mapping from itertools import chain from typing import TYPE_CHECKING, Optional @@ -35,6 +36,7 @@ ) SURVEY_ELEMENT_EXTRA_FIELDS = ("_survey_element_xpath",) SURVEY_ELEMENT_SLOTS = (*SURVEY_ELEMENT_FIELDS, *SURVEY_ELEMENT_EXTRA_FIELDS) +_GET_SENTINEL = object() class SurveyElement(Mapping): @@ -53,6 +55,22 @@ def __hash__(self): def __getitem__(self, key): return self.__getattribute__(key) + def get(self, key, default=_GET_SENTINEL): + try: + return self.__getattribute__(key) + except AttributeError: + # Sentinel used rather than None since caller may write `default=None`. + if default is _GET_SENTINEL: + raise + warnings.warn( + "The `obj.get(key, default)` usage pattern will be removed in a future " + "version of pyxform. Please check the object type to ensure the " + "attribute will exist, or use `hasattr(obj, key, default)` instead.", + DeprecationWarning, + stacklevel=2, # level 1 = here, 2 = caller. + ) + return default + @staticmethod def get_slot_names() -> tuple[str, ...]: """Each subclass must provide a list of slots from itself and all parents.""" diff --git a/tests/test_survey_element.py b/tests/test_survey_element.py new file mode 100644 index 00000000..6eb88570 --- /dev/null +++ b/tests/test_survey_element.py @@ -0,0 +1,67 @@ +import warnings +from unittest import TestCase + +from pyxform.survey_element import SurveyElement + + +class TestSurveyElementMappingBehaviour(TestCase): + def tearDown(self): + # Undo the warnings filter set in the below test. + warnings.resetwarnings() + + def test_get_call_patterns_equivalent_to_base_dict(self): + """Should find that, except for deprecated usage, SurveyElement is dict-like.""" + # To demonstrate how dict normally works using same test cases. + _dict = {"name": "test", "label": None} + # getattr + self.assertEqual("default", getattr(_dict, "foo", "default")) + # defined key, no default + self.assertEqual("test", _dict.get("name")) + # defined key, with default + self.assertEqual("test", _dict.get("name", "default")) + # defined key, with None value + self.assertEqual(None, _dict.get("label")) + # defined key, with None value, with default + self.assertEqual(None, _dict.get("label", "default")) + # undefined key, with default + self.assertEqual("default", _dict.get("foo", "default")) + # undefined key, with default None + self.assertEqual(None, _dict.get("foo", None)) + # other access patterns for undefined key + self.assertEqual(None, _dict.get("foo")) + with self.assertRaises(AttributeError): + _ = _dict.foo + with self.assertRaises(KeyError): + _ = _dict["foo"] + + elem = SurveyElement(name="test") + # getattr + self.assertEqual("default", getattr(elem, "foo", "default")) + # defined key, no default + self.assertEqual("test", elem.get("name")) + # defined key, with default + self.assertEqual("test", elem.get("name", "default")) + # defined key, with None value + self.assertEqual(None, elem.get("label")) + # defined key, with None value, with default + self.assertEqual(None, elem.get("label", "default")) + # undefined key, with default + with self.assertWarns(DeprecationWarning) as warned: + self.assertEqual("default", elem.get("foo", "default")) + # Warning points to caller, rather than survey_element or collections.abc. + self.assertEqual(__file__, warned.filename) + # undefined key, with default None + with self.assertWarns(DeprecationWarning) as warned: + self.assertEqual(None, elem.get("foo", None)) + # Callers can disable warnings at module-level. + warnings.simplefilter("ignore", DeprecationWarning) + with warnings.catch_warnings(record=True) as warned: + elem.get("foo", "default") + self.assertEqual(0, len(warned)) + # other access patterns for undefined key + with self.assertRaises(AttributeError): + _ = elem.get("foo") + with self.assertRaises(AttributeError): + _ = elem.foo + with self.assertRaises(AttributeError): + _ = elem["foo"]