Skip to content

Commit ea04435

Browse files
Support dunder keys
1 parent d394d75 commit ea04435

File tree

2 files changed

+74
-4
lines changed

2 files changed

+74
-4
lines changed

src/layered_config_tree/main.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -548,12 +548,26 @@ def _set_with_metadata(
548548
self._children[name].update(value, layer, source)
549549

550550
def __setattr__(self, name: str, value: Any) -> None:
551-
"""Set a value on the outermost layer."""
551+
"""Set a value on the outermost layer.
552+
553+
Notes
554+
-----
555+
We allow keys that look like dunder attributes, i.e. start and end with
556+
"__". However, to avoid conflict with actual dunder methods and attributes,
557+
we do not allow setting them via this method and instead require dictionary
558+
access (i.e. bracket notation).
559+
"""
552560
if name not in self:
553561
raise ConfigurationKeyError(
554562
"New configuration keys can only be created with the update method.",
555563
self._name,
556564
)
565+
if name.startswith("__") and name.endswith("__"):
566+
raise RuntimeError(
567+
"Cannot set an attribute starting and ending with '__' via attribute "
568+
"access (i.e. dot notation). Use dicationary access instead "
569+
"(i.e. bracket notation)."
570+
)
557571
self._set_with_metadata(name, value, layer=None, source=None)
558572

559573
def __setitem__(self, name: str, value: Any) -> None:
@@ -566,10 +580,35 @@ def __setitem__(self, name: str, value: Any) -> None:
566580
self._set_with_metadata(name, value, layer=None, source=None)
567581

568582
# FIXME: We expect the return to be a ConfigNode or LayeredConfigTree but
569-
# the type checker doesn't know what you're getting back in chained
570-
# attribute calls. We return Any as a workaround.
583+
# static type checkers don't know what you're getting back in chained
584+
# attribute calls. We type hint returning Any as a workaround.
571585
def __getattr__(self, name: str) -> Any:
572-
"""Get a value from the outermost layer in which it appears."""
586+
"""Get a value from the outermost layer in which it appears.
587+
588+
Notes
589+
-----
590+
We allow keys that look like dunder attributes, i.e. start and end with
591+
"__". However, to avoid conflict with actual dunder methods and attributes,
592+
we do not allow getting them via this method and instead require dictionary
593+
access (i.e. bracket notation).
594+
595+
If the requested attribute starts and ends with "__" but *does not* actually
596+
exist, it is critical that we raise an AttributeError since some functions
597+
specifically handle it, e.g. ``pickle`` and ``copy.deepcopy``.
598+
See https://stackoverflow.com/a/50888571/
599+
600+
One the other hand, if the requested attribute starts and ends with "__"
601+
and *does* exist, it is critical that we raise a non-AttributeError exception
602+
so as not to conflict with dunder methods and attributes.
603+
"""
604+
if name.startswith("__") and name.endswith("__"):
605+
if name not in self:
606+
raise AttributeError # Do not change from AttributeError
607+
raise RuntimeError(
608+
"Cannot get an attribute starting and ending with '__' via attribute "
609+
"access (i.e. dot notation). Use dictionary access instead "
610+
"(i.e. bracket notation)."
611+
)
573612
return self.get_from_layer(name)
574613

575614
# We need custom definitions of __getstate__ and __setstate__

tests/test_basic_functionality.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pickle
2+
import re
23
import textwrap
34
from pathlib import Path
45
from typing import Any
@@ -343,6 +344,36 @@ def test_dictionary_style_access() -> None:
343344
assert lct["test_key"] == "test_value"
344345

345346

347+
def test_dunder_key_attr_style_access() -> None:
348+
lct = LayeredConfigTree()
349+
lct.update({"__dunder_key__": "val"})
350+
assert lct["__dunder_key__"] == "val"
351+
352+
with pytest.raises(
353+
RuntimeError,
354+
match=re.escape(
355+
"Cannot get an attribute starting and ending with '__' via attribute "
356+
"access (i.e. dot notation). Use dictionary access instead "
357+
"(i.e. bracket notation)."
358+
),
359+
):
360+
lct.__dunder_key__
361+
362+
with pytest.raises(
363+
RuntimeError,
364+
match=re.escape(
365+
"Cannot set an attribute starting and ending with '__' via attribute "
366+
"access (i.e. dot notation). Use dicationary access instead "
367+
"(i.e. bracket notation)."
368+
),
369+
):
370+
lct.__dunder_key__ = "val2"
371+
assert lct["__dunder_key__"] == "val"
372+
373+
with pytest.raises(AttributeError):
374+
lct.__non_existent_dunder_key__
375+
376+
346377
def test_get_missing_key() -> None:
347378
lct = LayeredConfigTree()
348379
with pytest.raises(ConfigurationKeyError):

0 commit comments

Comments
 (0)