Skip to content

Commit 71aa4d8

Browse files
get method and typing (#16)
Category: feature JIRA issue: https://jira.ihme.washington.edu/browse/MIC-5215 Changes and notes Add get method and update typing. Testing Wrote new tests. All tests pass.
1 parent 605581e commit 71aa4d8

File tree

6 files changed

+120
-45
lines changed

6 files changed

+120
-45
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
**2.1.0 - 10/31/2024**
2+
3+
- Add getter methods
4+
15
**2.0.2 - 08/01/2024**
26

37
- Create explicit iterator for LayeredConfigTree

docs/nitpick-exceptions

Whitespace-only changes.

src/layered_config_tree/exceptions.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
from typing import Optional
2-
3-
from layered_config_tree.types import NestedDictValue
1+
from typing import Any, Optional
42

53

64
class ConfigurationError(Exception):
@@ -37,7 +35,7 @@ def __init__(
3735
name: str,
3836
layer: Optional[str],
3937
source: Optional[str],
40-
value: NestedDictValue,
38+
value: Any,
4139
):
4240
self.layer = layer
4341
self.source = source

src/layered_config_tree/main.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
ConfigurationKeyError,
4141
DuplicatedConfigurationError,
4242
)
43-
from layered_config_tree.types import InputData, NestedDict, NestedDictValue, NodeValue
43+
from layered_config_tree.types import InputData
4444

4545

4646
class ConfigNode:
@@ -74,7 +74,7 @@ class ConfigNode:
7474
def __init__(self, layers: list[str], name: str):
7575
self._name = name
7676
self._layers = layers
77-
self._values: dict[str, tuple[Optional[str], NodeValue]] = {}
77+
self._values: dict[str, tuple[Optional[str], Any]] = {}
7878
self._frozen = False
7979
self._accessed = False
8080

@@ -89,7 +89,7 @@ def accessed(self) -> bool:
8989
return self._accessed
9090

9191
@property
92-
def metadata(self) -> list[dict[str, Union[Optional[str], NodeValue]]]:
92+
def metadata(self) -> list[dict[str, Union[Optional[str], Any]]]:
9393
"""Returns all values and associated metadata for this node."""
9494
result = []
9595
for layer in self._layers:
@@ -112,7 +112,7 @@ def freeze(self) -> None:
112112
"""
113113
self._frozen = True
114114

115-
def get_value(self, layer: Optional[str] = None) -> NodeValue:
115+
def get_value(self, layer: Optional[str] = None) -> Any:
116116
"""Returns the value at the specified layer.
117117
118118
If no layer is specified, the outermost (highest priority) layer
@@ -133,7 +133,7 @@ def get_value(self, layer: Optional[str] = None) -> NodeValue:
133133
self._accessed = True
134134
return value
135135

136-
def update(self, value: NodeValue, layer: Optional[str], source: Optional[str]) -> None:
136+
def update(self, value: Any, layer: Optional[str], source: Optional[str]) -> None:
137137
"""Set a value for a layer with optional metadata about source.
138138
139139
Parameters
@@ -180,7 +180,7 @@ def update(self, value: NodeValue, layer: Optional[str], source: Optional[str])
180180
else:
181181
self._values[layer] = (source, value)
182182

183-
def _get_value_with_source(self, layer: Optional[str]) -> tuple[Optional[str], NodeValue]:
183+
def _get_value_with_source(self, layer: Optional[str]) -> tuple[Optional[str], Any]:
184184
"""Returns a (source, value) tuple at the specified layer.
185185
186186
If no layer is specified, the outermost (highest priority) layer
@@ -232,10 +232,10 @@ class ConfigIterator:
232232
This iterator is used to iterate over the keys of a LayeredConfigTree object.
233233
"""
234234

235-
def __init__(self, config_tree: "LayeredConfigTree"):
235+
def __init__(self, config_tree: LayeredConfigTree):
236236
self._iterator = iter(config_tree._children)
237237

238-
def __iter__(self) -> "ConfigIterator":
238+
def __iter__(self) -> ConfigIterator:
239239
return self
240240

241241
def __next__(self) -> str:
@@ -252,7 +252,7 @@ class LayeredConfigTree:
252252

253253
# Define type annotations here since they're indirectly defined below
254254
_layers: list[str]
255-
_children: dict[str, Union["LayeredConfigTree", "ConfigNode"]]
255+
_children: dict[str, Union[LayeredConfigTree, ConfigNode]]
256256
_frozen: bool
257257
_name: str
258258

@@ -308,7 +308,7 @@ def freeze(self) -> None:
308308
for child in self.values():
309309
child.freeze()
310310

311-
def items(self) -> Iterable[tuple[str, Union["LayeredConfigTree", ConfigNode]]]:
311+
def items(self) -> Iterable[tuple[str, Union[LayeredConfigTree, ConfigNode]]]:
312312
"""Return an iterable of all (child_name, child) pairs."""
313313
return self._children.items()
314314

@@ -332,7 +332,7 @@ def unused_keys(self) -> list[str]:
332332
unused.append(f"{name}.{grandchild_name}")
333333
return unused
334334

335-
def to_dict(self) -> NestedDict:
335+
def to_dict(self) -> dict[str, Any]:
336336
"""Converts the LayeredConfigTree into a nested dictionary.
337337
338338
All metadata is lost in this conversion.
@@ -343,12 +343,38 @@ def to_dict(self) -> NestedDict:
343343
if isinstance(child, ConfigNode):
344344
result[name] = child.get_value(layer=None)
345345
else:
346-
result[name] = child.to_dict() # type: ignore[assignment]
346+
result[name] = child.to_dict()
347347
return result
348348

349-
def get_from_layer(
350-
self, name: str, layer: Optional[str] = None
351-
) -> Union[NodeValue, "LayeredConfigTree"]:
349+
def get(self, key: str, default_value: Any = None) -> Any:
350+
"""Return the LayeredConfigTree or value at the key in the outermost layer of the config tree.
351+
352+
Parameters
353+
----------
354+
key
355+
The string to look for in the outermost layer of the config tree.
356+
default_value
357+
The value to return if key is not found
358+
"""
359+
return self[key] if key in self._children else default_value
360+
361+
def get_tree(self, key: str) -> LayeredConfigTree:
362+
"""Return the LayeredConfigTree at the key in the outermost layer of the config tree.
363+
364+
Parameters
365+
----------
366+
key
367+
The str we look up in the outermost layer of the config tree.
368+
"""
369+
data = self[key]
370+
if isinstance(data, LayeredConfigTree):
371+
return data
372+
else:
373+
raise ConfigurationError(
374+
f"The data you accessed using {key} with get_tree was of type {type(data)}, but get_tree must return a LayeredConfigTree."
375+
)
376+
377+
def get_from_layer(self, name: str, layer: Optional[str] = None) -> Any:
352378
"""Get a configuration value from the provided layer.
353379
354380
If no layer is specified, the outermost (highest priority) layer
@@ -420,7 +446,7 @@ def update(
420446
for k, v in data.items():
421447
self._set_with_metadata(k, v, layer, source)
422448

423-
def metadata(self, name: str) -> list[NestedDict]:
449+
def metadata(self, name: str) -> list[dict[str, Any]]:
424450
if name in self:
425451
return self._children[name].metadata # type: ignore[return-value]
426452
name = f"{self._name}.{name}" if self._name else name
@@ -430,7 +456,7 @@ def metadata(self, name: str) -> list[NestedDict]:
430456
def _coerce(
431457
data: InputData,
432458
source: Optional[str],
433-
) -> tuple[NestedDict, Optional[str]]:
459+
) -> tuple[dict[str, Any], Optional[str]]:
434460
"""Coerces data into dictionary format."""
435461
if isinstance(data, dict):
436462
return data, source
@@ -465,7 +491,7 @@ def _coerce(
465491
def _set_with_metadata(
466492
self,
467493
name: str,
468-
value: Union[NestedDictValue, str, Path, "LayeredConfigTree"],
494+
value: Any,
469495
layer: Optional[str],
470496
source: Optional[str],
471497
) -> None:
@@ -519,9 +545,9 @@ def _set_with_metadata(
519545
f"Can't assign a value to a LayeredConfigTree.", name
520546
)
521547

522-
self._children[name].update(value, layer, source) # type: ignore[arg-type]
548+
self._children[name].update(value, layer, source)
523549

524-
def __setattr__(self, name: str, value: NestedDictValue) -> None:
550+
def __setattr__(self, name: str, value: Any) -> None:
525551
"""Set a value on the outermost layer."""
526552
if name not in self:
527553
raise ConfigurationKeyError(
@@ -530,7 +556,7 @@ def __setattr__(self, name: str, value: NestedDictValue) -> None:
530556
)
531557
self._set_with_metadata(name, value, layer=None, source=None)
532558

533-
def __setitem__(self, name: str, value: NestedDictValue) -> None:
559+
def __setitem__(self, name: str, value: Any) -> None:
534560
"""Set a value on the outermost layer."""
535561
if name not in self:
536562
raise ConfigurationKeyError(
@@ -554,14 +580,14 @@ def __getattr__(self, name: str) -> Any:
554580
# * Calling __getattr__ before we have set up the state doesn't work,
555581
# because it leads to an infinite loop looking for the module's
556582
# actual attributes (not config keys)
557-
def __getstate__(self) -> NestedDict:
583+
def __getstate__(self) -> dict[str, Any]:
558584
return self.__dict__
559585

560-
def __setstate__(self, state: NestedDict) -> None:
586+
def __setstate__(self, state: dict[str, Any]) -> None:
561587
for k, v in state.items():
562588
self.__dict__[k] = v
563589

564-
def __getitem__(self, name: str) -> Union[NodeValue, "LayeredConfigTree"]:
590+
def __getitem__(self, name: str) -> Any:
565591
"""Get a value from the outermost layer in which it appears."""
566592
return self.get_from_layer(name)
567593

src/layered_config_tree/types.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,10 @@
1+
from __future__ import annotations
2+
13
from pathlib import Path
2-
from typing import TYPE_CHECKING, Mapping, Union
4+
from typing import TYPE_CHECKING, Any, Union
35

46
if TYPE_CHECKING:
57
from layered_config_tree import LayeredConfigTree
68

7-
8-
# NOTE: py 3.9 does not support typing.TypeAlias
9-
10-
# Define a nested dictionary of unknown depth for type hinting
11-
NestedDict = Mapping[str, "NestedDictValue"]
12-
# Define the leaf (ConfigNode) values (which cannot be another dictionary)
13-
NodeValue = Union[str, int, float, list[Union[str, int, float]]]
14-
# Define a NestedDictionary value which can be either a ConfigNodeValue or another NestedDict
15-
# NOTE: Due to the forward reference to NestedDictValue above, static type checkers
16-
# (e.g. mypy) may not be able to determine the correct type of NestedDictValue;
17-
# use # type: ignore as required
18-
NestedDictValue = Union[NodeValue, NestedDict]
19-
209
# Data input types
21-
InputData = Union[NestedDict, str, Path, "LayeredConfigTree"]
10+
InputData = Union[dict[str, Any], str, Path, "LayeredConfigTree"]

tests/test_basic_functionality.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pickle
22
import textwrap
33
from pathlib import Path
4+
from typing import Any
45

56
import pytest
67
import yaml
@@ -12,7 +13,6 @@
1213
DuplicatedConfigurationError,
1314
LayeredConfigTree,
1415
)
15-
from layered_config_tree.types import NestedDict
1616

1717

1818
@pytest.fixture(params=list(range(1, 5)))
@@ -43,6 +43,14 @@ def empty_tree(layers: list[str]) -> LayeredConfigTree:
4343
return LayeredConfigTree(layers=layers)
4444

4545

46+
@pytest.fixture
47+
def getter_dict() -> dict[str, Any]:
48+
return {
49+
"outer_layer_1": "test_value",
50+
"outer_layer_2": {"inner_layer": "test_value2"},
51+
}
52+
53+
4654
def test_node_creation(empty_node: ConfigNode) -> None:
4755
assert not empty_node
4856
assert not empty_node.accessed
@@ -243,7 +251,7 @@ def test_tree_creation(empty_tree: LayeredConfigTree) -> None:
243251

244252

245253
def test_tree_coerce_dict() -> None:
246-
data: NestedDict
254+
data: dict[str, Any]
247255
data = {}
248256
src = "test"
249257
assert LayeredConfigTree._coerce(data, src) == (data, src)
@@ -458,6 +466,56 @@ def test_to_dict_yaml(test_spec: Path) -> None:
458466
assert yaml_config == lct.to_dict()
459467

460468

469+
@pytest.mark.parametrize(
470+
"key, default_value, expected_value",
471+
[
472+
("outer_layer_1", None, "test_value"),
473+
("outer_layer_1", "some_default", "test_value"),
474+
("fake_key", 0, 0),
475+
("fake_key", "some_default", "some_default"),
476+
],
477+
)
478+
def test_getter_single_values(
479+
key: str, default_value: str, expected_value: str, getter_dict: dict[str, Any]
480+
) -> None:
481+
lct = LayeredConfigTree(getter_dict)
482+
483+
if default_value is None:
484+
assert lct.get(key) == expected_value
485+
else:
486+
assert lct.get(key, default_value) == expected_value
487+
488+
489+
def test_getter_chained(getter_dict: dict[str, Any]) -> None:
490+
lct = LayeredConfigTree(getter_dict)
491+
492+
outer_layer_2 = lct.get("outer_layer_2")
493+
assert isinstance(outer_layer_2, LayeredConfigTree)
494+
assert outer_layer_2.to_dict() == getter_dict["outer_layer_2"]
495+
assert outer_layer_2.get("inner_layer") == "test_value2"
496+
497+
498+
def test_getter_default_values(getter_dict: dict[str, Any]) -> None:
499+
lct = LayeredConfigTree(getter_dict)
500+
501+
assert lct.get("fake_key") is None
502+
503+
default_value = lct.get("fake_key", {})
504+
# checking default_value equals {} is not enough for mypy to know it's a dict
505+
assert default_value == {} and isinstance(default_value, dict)
506+
assert default_value.get("another_fake_key") is None
507+
508+
509+
def test_tree_getter(getter_dict: dict[str, Any]) -> None:
510+
lct = LayeredConfigTree(getter_dict)
511+
512+
assert lct.get_tree("outer_layer_2").to_dict() == getter_dict["outer_layer_2"]
513+
with pytest.raises(ConfigurationError, match="must return a LayeredConfigTree"):
514+
lct.get_tree("outer_layer_1")
515+
with pytest.raises(ConfigurationError, match="No value at name"):
516+
lct.get_tree("fake_key")
517+
518+
461519
def test_equals() -> None:
462520
# TODO: Assert should succeed, instead of raising, once equality is
463521
# implemented for LayeredConfigTrees

0 commit comments

Comments
 (0)