Skip to content

Commit d8c88a2

Browse files
committed
get_by_full_name: Support reading from objects
1 parent 2a92de0 commit d8c88a2

File tree

3 files changed

+75
-21
lines changed

3 files changed

+75
-21
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Changed
1717
- Update `basic_config.basic_config()`: Allow and handle `level=None` to avoid logging from root.
18+
- The `misc.get_by_full_name()` function now supports reading member attributes. Uses
19+
[entrypoint](https://packaging.python.org/en/latest/specifications/entry-points/) syntax, e.g.
20+
`pandas:DataFrame.sum`.
1821

1922
### Fixed
2023
- Calling `MultiCaseTimer.run(number=<int>)` no longer crashes.

src/rics/misc.py

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def interpolate_environment_variables(
6161
@_t.overload
6262
def get_by_full_name(
6363
name: str,
64-
default_module: str | _ModuleType = ...,
64+
default_module: str | _ModuleType | object = ...,
6565
*,
6666
instance_of: _t.Literal[None] = None,
6767
subclass_of: _t.Literal[None] = None,
@@ -72,7 +72,7 @@ def get_by_full_name(
7272
@_t.overload
7373
def get_by_full_name(
7474
name: str,
75-
default_module: str | _ModuleType = ...,
75+
default_module: str | _ModuleType | object = ...,
7676
*,
7777
instance_of: type[GBFNReturnType],
7878
subclass_of: _t.Literal[None] = None,
@@ -83,7 +83,7 @@ def get_by_full_name(
8383
@_t.overload
8484
def get_by_full_name(
8585
name: str,
86-
default_module: str | _ModuleType = ...,
86+
default_module: str | _ModuleType | object = ...,
8787
*,
8888
instance_of: _t.Literal[None] = None,
8989
subclass_of: type[GBFNReturnType],
@@ -93,21 +93,26 @@ def get_by_full_name(
9393

9494
def get_by_full_name(
9595
name: str,
96-
default_module: str | _ModuleType | None = None,
96+
default_module: str | _ModuleType | object | None = None,
9797
*,
9898
instance_of: type[GBFNReturnType] | None = None,
9999
subclass_of: type[GBFNReturnType] | None = None,
100100
) -> _t.Any:
101-
"""Combine :py:func:`~importlib.import_module` and :py:func:`getattr` to retrieve items by name.
101+
"""Combine :py:func:`importlib.import_module` and :py:func:`getattr` to retrieve items by name.
102+
103+
You may retrieve top-level module members such as classes by adding the class name after the module path (e.g.
104+
``logging.Logger``). The member may also be specifying using the ``':'``-syntax, e.g. ``logging:Logger``. The
105+
``':'``-syntax also supports retrieving object members e.g. ``logging:Logger.info``. This is not possible using the
106+
dot-based syntax since ``logging.Logger`` cannot be imported by the ``importlib.import_module`` function.
102107
103108
Args:
104109
name: A name or fully qualified name.
105-
default_module: A namespace to search if `name` is not fully qualified (contains no ``'.'``-characters).
110+
default_module: A namespace to search if `name` does not contain any ``'.'`` or ``':'`` characters.
106111
instance_of: If given, perform :py:func:`isinstance` check on `name`.
107112
subclass_of: If given, perform :py:func:`issubclass` check on `name`.
108113
109114
Returns:
110-
An object with the fully qualified name `name`.
115+
The object specified by `name`.
111116
112117
Raises:
113118
ValueError: If `name` does not contain any dots and ``default_module=None``.
@@ -129,12 +134,29 @@ def get_by_full_name(
129134
>>> get_by_full_name("logging.Logger", subclass_of=logging.Filterer)
130135
<class 'logging.Logger'>
131136
132-
Falling back to builtins.
137+
Default namespaces may be specified using the `default_module` keyword argument.
138+
139+
>>> get_by_full_name("Logger", default_module="logging")
140+
<class 'logging.Logger'>
141+
142+
The default namespace doesn't have to be a module.
133143
134-
>>> get_by_full_name("int", default_module="builtins")
135-
<class 'int'>
144+
>>> get_by_full_name("info", default_module="logging.Logger").__qualname__
145+
'Logger.info'
136146
147+
To retrieve an attribute of anything other than a module (e.g. a class member), you must use the ``:``-syntax to
148+
separate the module path from the attribute path.
149+
150+
>>> get_by_full_name("logging:Logger.info").__qualname__
151+
'Logger.info'
152+
153+
When using this syntax, the path to the left of the separator must be a valid module path.
137154
"""
155+
name = name.strip()
156+
if name == "":
157+
msg = "Name must not be empty."
158+
raise ValueError(msg)
159+
138160
if not (instance_of is None or subclass_of is None):
139161
msg = f"At least one of ({instance_of=}, {subclass_of=}) must be None."
140162
raise ValueError(msg)
@@ -165,18 +187,43 @@ def get_by_full_name(
165187
return obj
166188

167189

168-
def _get_by_full_name(name: str, *, default_module: str | _ModuleType | None = None) -> _t.Any:
169-
if "." in name:
190+
def _get_by_full_name(name: str, *, default_module: str | object | None = None) -> _t.Any:
191+
path: list[str]
192+
193+
force_default_module = name[0] == ":"
194+
195+
if force_default_module and not default_module:
196+
msg = f"Cannot use {name=} (with leading ':') without a default module."
197+
raise ValueError(msg)
198+
199+
root: object
200+
if ":" in name and not force_default_module:
201+
module_name, _, member = name.rpartition(":")
202+
root = _import_module(module_name)
203+
path = member.split(".")
204+
elif "." in name and not force_default_module:
170205
module_name, _, member = name.rpartition(".")
171-
module = _import_module(module_name)
206+
root = _import_module(module_name)
207+
path = [member]
172208
else:
173209
if not default_module:
174210
msg = "Name must be fully qualified when no default module is given."
175211
raise ValueError(msg)
176-
module = _import_module(default_module) if isinstance(default_module, str) else default_module
177-
member = name
178212

179-
return getattr(module, member)
213+
if isinstance(default_module, str):
214+
if ":" in default_module or "." in default_module:
215+
root = _get_by_full_name(default_module)
216+
else:
217+
root = _import_module(default_module)
218+
else:
219+
root = default_module
220+
221+
path = name.removeprefix(":").split(".")
222+
223+
obj = root
224+
for attr in path:
225+
obj = getattr(obj, attr)
226+
return obj
180227

181228

182229
def get_public_module(obj: _t.Any, resolve_reexport: bool = False, include_name: bool = False) -> str:

tests/test_misc/test_get_by_full_name.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ def test_untyped(name):
2121
assert actual is Foo
2222

2323

24+
def test_nested_with_default_module():
25+
actual = get_by_full_name(":Foo.Bar", default_module=MODULE)
26+
assert actual is Foo.Bar
27+
28+
with pytest.raises(ValueError, match=r"Cannot use name=':Foo.Bar' \(with leading ':'\) without a default module."):
29+
get_by_full_name(":Foo.Bar")
30+
31+
2432
class TestInstance:
2533
def test_plain(self):
2634
actual = get_by_full_name("my_foo", default_module=MODULE, instance_of=Foo)
@@ -91,10 +99,6 @@ def test_parameterized_generics(self):
9199
with pytest.raises(TypeError, match="cannot be a parameterized generic"):
92100
get_by_full_name("my_foo", default_module=MODULE, subclass_of=list[int])
93101

94-
@pytest.mark.xfail(reason="Nested classes are not supported.")
95-
def test_nested(self):
96-
get_by_full_name("Foo.Bar", default_module=MODULE)
97-
98102
def test_no_default(self):
99-
with pytest.raises(ValueError, match="fully qualified when no default module"):
103+
with pytest.raises(ValueError, match="Name must not be empty."):
100104
get_by_full_name("")

0 commit comments

Comments
 (0)