diff --git a/namespace_proxy.py b/namespace_proxy.py new file mode 100644 index 0000000..26475e0 --- /dev/null +++ b/namespace_proxy.py @@ -0,0 +1,48 @@ +from types import MappingProxyType + + +class NamespaceProxy(): + """ + Read-only proxy of a mapping (like a dict) allowing item access via attributes. Mapping keys + that are not strings will be ignored, and attribute access to any names starting with "__" + will still be passed to the actual object attributes. + + Being a proxy, attributes available and their values will change as the underlying backing + dict is changed. + + Intended for sitatuations where a dynamic mapping needs to be passed out to client code but + you'd like to heavily suggest that it not be modified. + + Note that only the top-level mapping is read only - if the attribute values themselves are + mutable, they may still be modified via the NamespaceProxy. + """ + + def __init__(self, backing_dict): + """ + Create a new NamespaceProxy, with attribute access to the underlying backing dict passed + in. + """ + object.__setattr__(self, "_dict_proxy", MappingProxyType(backing_dict)) + + def __getattribute__(self, name): + if name.startswith("__"): + return object.__getattribute__(self, name) + return object.__getattribute__(self, "_dict_proxy")[name] + + def __setattr__(self, *args): + raise TypeError("NamespaceProxy does not allow attributes to be modified") + + def __delattr__(self, *args): + raise TypeError("NamespaceProxy does not allow attributes to be modified") + + def __repr__(self): + keys = sorted(object.__getattribute__(self, "_dict_proxy")) + items = ("{}={!r}".format(k, object.__getattribute__( + self, "_dict_proxy")[k]) for k in keys) + return "{}({})".format(type(self).__name__, ", ".join(items)) + + def __eq__(self, other): + if isinstance(other, self.__class__): + return (object.__getattribute__(self, "_dict_proxy") == + object.__getattribute__(other, "_dict_proxy")) + return False diff --git a/test_namespace_proxy.py b/test_namespace_proxy.py new file mode 100644 index 0000000..610da24 --- /dev/null +++ b/test_namespace_proxy.py @@ -0,0 +1,65 @@ +# pylint: disable=comparison-with-callable, pointless-statement +import pytest +from namespace_proxy import NamespaceProxy + + +def test_namespace_access(): + def dummy_func(val_a): + return F"ret {val_a}" + + back_dict = {'int': 1, 'str': 'string', 'func': dummy_func} + proxy = NamespaceProxy(back_dict) + assert proxy.int == 1 + assert proxy.str == 'string' + assert proxy.func == dummy_func + assert proxy.func(5) == 'ret 5' + + +def test_namespace_errors(): + back_dict = {'b': 2} + proxy = NamespaceProxy(back_dict) + + with pytest.raises(KeyError): + proxy.a + + with pytest.raises(TypeError, match="does not allow attributes to be modified"): + proxy.b = 3 + + with pytest.raises(TypeError, match="does not allow attributes to be modified"): + del proxy.b + + +def test_namespace_update(): + back_dict = {'b': 2} + proxy = NamespaceProxy(back_dict) + with pytest.raises(KeyError): + proxy.a + back_dict['a'] = 3 + assert proxy.a == 3 + + +def test_dunder_passthrough(): + back_dict = {'a': 1} + proxy = NamespaceProxy(back_dict) + d = dir(proxy) + print(d) + assert proxy.__class__ == NamespaceProxy + + +def test_equality(): + back_dict = {'a': 1} + proxy = NamespaceProxy(back_dict) + proxy_b = NamespaceProxy(back_dict) + proxy_c = NamespaceProxy({'c': 3}) + assert proxy == proxy_b + assert proxy != proxy_c + assert proxy != back_dict + assert proxy is not False + assert proxy + + +def test_repr(): + back_dict = {'a': 1, 'b': 2} + proxy = NamespaceProxy(back_dict) + assert str(proxy) == "NamespaceProxy(a=1, b=2)" + assert repr(proxy) == "NamespaceProxy(a=1, b=2)"