Skip to content

Commit 5898683

Browse files
Support dunder keys (#21)
1 parent d394d75 commit 5898683

File tree

5 files changed

+96
-6
lines changed

5 files changed

+96
-6
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
**3.0.0 - 02/18/2025**
2+
3+
- Better handle dunder-style keys
4+
15
**2.2.1 - 12/27/2024**
26

37
- Bugfix: failing mypy

src/layered_config_tree/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@
1212
ConfigurationError,
1313
ConfigurationKeyError,
1414
DuplicatedConfigurationError,
15+
ImproperAccessError,
1516
)
1617
from layered_config_tree.main import ConfigNode, LayeredConfigTree

src/layered_config_tree/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ class ConfigurationKeyError(ConfigurationError, KeyError):
1515
pass
1616

1717

18+
class ImproperAccessError(ConfigurationError):
19+
"""Error raised when a configuration value is accessed improperly."""
20+
21+
pass
22+
23+
1824
class DuplicatedConfigurationError(ConfigurationError):
1925
"""Error raised when a configuration value is set more than once.
2026

src/layered_config_tree/main.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
ConfigurationError,
4040
ConfigurationKeyError,
4141
DuplicatedConfigurationError,
42+
ImproperAccessError,
4243
)
4344
from layered_config_tree.types import InputData
4445

@@ -548,12 +549,26 @@ def _set_with_metadata(
548549
self._children[name].update(value, layer, source)
549550

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

559574
def __setitem__(self, name: str, value: Any) -> None:
@@ -566,10 +581,35 @@ def __setitem__(self, name: str, value: Any) -> None:
566581
self._set_with_metadata(name, value, layer=None, source=None)
567582

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

575615
# We need custom definitions of __getstate__ and __setstate__

tests/test_basic_functionality.py

Lines changed: 41 additions & 2 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
@@ -11,6 +12,7 @@
1112
ConfigurationError,
1213
ConfigurationKeyError,
1314
DuplicatedConfigurationError,
15+
ImproperAccessError,
1416
LayeredConfigTree,
1517
)
1618

@@ -343,6 +345,40 @@ def test_dictionary_style_access() -> None:
343345
assert lct["test_key"] == "test_value"
344346

345347

348+
def test_dunder_key_attr_style_access() -> None:
349+
lct = LayeredConfigTree({"__dunder_key__": "val"}, layers=["layer1", "layer2"])
350+
# lct.update({"__dunder_key__": "val"})
351+
assert lct["__dunder_key__"] == "val"
352+
353+
with pytest.raises(
354+
ImproperAccessError,
355+
match=re.escape(
356+
"Cannot get an attribute starting and ending with '__' via attribute "
357+
"access (i.e. dot notation). Use dictionary access instead "
358+
"(i.e. bracket notation)."
359+
),
360+
):
361+
lct.__dunder_key__
362+
363+
with pytest.raises(
364+
ImproperAccessError,
365+
match=re.escape(
366+
"Cannot set an attribute starting and ending with '__' via attribute "
367+
"access (i.e. dot notation). Use dictionary access instead "
368+
"(i.e. bracket notation)."
369+
),
370+
):
371+
lct.__dunder_key__ = "val2"
372+
assert lct["__dunder_key__"] == "val"
373+
374+
# check that we can modify the value in a new layer
375+
lct["__dunder_key__"] = "val2"
376+
assert lct["__dunder_key__"] == "val2"
377+
378+
with pytest.raises(AttributeError):
379+
lct.__non_existent_dunder_key__
380+
381+
346382
def test_get_missing_key() -> None:
347383
lct = LayeredConfigTree()
348384
with pytest.raises(ConfigurationKeyError):
@@ -351,9 +387,12 @@ def test_get_missing_key() -> None:
351387

352388
def test_set_missing_key() -> None:
353389
lct = LayeredConfigTree()
354-
with pytest.raises(ConfigurationKeyError):
390+
error_msg = re.escape(
391+
"New configuration keys can only be created with the update method."
392+
)
393+
with pytest.raises(ConfigurationKeyError, match=error_msg):
355394
lct.missing_key = "test_value"
356-
with pytest.raises(ConfigurationKeyError):
395+
with pytest.raises(ConfigurationKeyError, match=error_msg):
357396
lct["missing_key"] = "test_value"
358397

359398

0 commit comments

Comments
 (0)