Skip to content

Commit 58003ed

Browse files
authored
feat: ✨ add get_nested_attr() (#1039)
## Description This PR adds a utility function for making it easier to access nested properties on objects. Our Properties class structure has lots of nesting and all attributes default to None, so when I want to access a nested attribute (e.g. `resource_properties.dialect.header`) I always have to make sure it doesn't throw an error. With this function, we can instead say `get_nested_attr(resource_properties, "dialect.header", default=True)`, optionally providing a default value. Similar to the built-in `getattr`. <!-- Select quick/in-depth as necessary --> This PR needs a quick review. ## Checklist - [x] Added or updated tests - [x] Updated documentation - [x] Ran `just run-all`
1 parent e9404ac commit 58003ed

File tree

2 files changed

+145
-0
lines changed

2 files changed

+145
-0
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from typing import Any, TypeVar
2+
3+
T = TypeVar("T")
4+
5+
6+
def get_nested_attr(
7+
base_object: Any, attributes: str, default: T | None = None
8+
) -> T | None:
9+
"""Returns the attribute specified by `attributes`.
10+
11+
Tries to resolve the chain of attributes against `base_object`. Returns None
12+
or the specified default value if the attribute chain cannot be resolved.
13+
14+
Args:
15+
base_object: The object to start resolving the attributes from.
16+
attributes: The chain of attributes as a dot-separated string.
17+
default: The default value to return if the attributes cannot be resolved.
18+
Defaults to None.
19+
20+
Returns:
21+
The value at the end of the attribute chain.
22+
23+
Raises:
24+
ValueError: If the attribute chain contains an element that is not a valid
25+
identifier.
26+
27+
Examples:
28+
```{python}
29+
class Inner:
30+
pass
31+
class Middle:
32+
inner: Inner = Inner()
33+
class Outer:
34+
middle: Middle = Middle()
35+
36+
get_nested_attr(Outer(), "middle.inner")
37+
```
38+
"""
39+
attributes = attributes.split(".")
40+
if any(not attribute.isidentifier() for attribute in attributes):
41+
raise ValueError(
42+
"`attributes` should contain valid identifiers separated by `.`."
43+
)
44+
45+
try:
46+
for attribute in attributes:
47+
base_object = getattr(base_object, attribute)
48+
except AttributeError:
49+
return default
50+
51+
return default if base_object is None else base_object

tests/core/test_get_nested_attr.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from pytest import mark, raises
2+
3+
from seedcase_sprout.core.get_nested_attr import get_nested_attr
4+
5+
6+
class D:
7+
none: str | None = None
8+
Number_1: int = 123
9+
10+
11+
d = D()
12+
13+
14+
class C:
15+
d: D = d
16+
17+
18+
c = C()
19+
20+
21+
class B:
22+
c: C = c
23+
24+
25+
b = B()
26+
27+
28+
class A:
29+
b: B = b
30+
31+
32+
a = A()
33+
34+
35+
@mark.parametrize(
36+
"nested_object,attributes,expected",
37+
[
38+
(a, "b.c.d", d),
39+
(a, "b.c", c),
40+
(a, "b", b),
41+
(b, "c.d", d),
42+
(c, "d", d),
43+
(d, "Number_1", 123),
44+
(d, "none", None),
45+
(a, "a", None),
46+
(a, "e", None),
47+
(a, "b.c.d.e", None),
48+
(a, "b.e.f.g", None),
49+
(a, "b.c.d.e.f.g.h", None),
50+
],
51+
)
52+
def test_gets_nested_attribute(nested_object, attributes, expected):
53+
"""Should resolve an attribute chain and return the correct value."""
54+
assert get_nested_attr(nested_object, attributes) == expected
55+
56+
57+
def test_returns_default_if_attribute_not_found():
58+
"""Should return the default value when the attribute chain cannot be resolved."""
59+
assert get_nested_attr(a, "e", default="default") == "default"
60+
61+
62+
def test_returns_default_if_attribute_is_none():
63+
"""Should return the default value when the attribute exists and is None."""
64+
assert get_nested_attr(d, "none", default="default") == "default"
65+
66+
67+
def test_ignores_default_if_attribute_found():
68+
"""Should ignore the default value when the attribute chain can be resolved."""
69+
assert get_nested_attr(d, "Number_1", default=456) == 123
70+
71+
72+
@mark.parametrize(
73+
"attributes",
74+
[
75+
"",
76+
".",
77+
"...",
78+
".b",
79+
"...b",
80+
"b.",
81+
"b...",
82+
".b.",
83+
".b.c.d",
84+
"b..c",
85+
".b.c.d.",
86+
"b,c,d",
87+
"$",
88+
"1",
89+
],
90+
)
91+
def test_throws_error_if_attribute_not_identifier(attributes):
92+
"""Should throw `ValueError` if the input doesn't contain valid identifiers."""
93+
with raises(ValueError):
94+
get_nested_attr(a, attributes)

0 commit comments

Comments
 (0)