Skip to content

Commit 6f2a951

Browse files
authored
Non-public experimental mixin class to override init defaults and instantiate from config (#800)
1 parent 25c9bde commit 6f2a951

7 files changed

Lines changed: 379 additions & 10 deletions

File tree

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ paths are considered internals and can change in minor and patch releases.
1515
v4.43.0 (unreleased)
1616
--------------------
1717

18+
Added
19+
^^^^^
20+
- Non-public experimental mixin class to override init defaults and instantiate
21+
from config (`#800 <https://github.com/omni-us/jsonargparse/pull/800>`__).
22+
1823
Fixed
1924
^^^^^
2025
- Prevent extra environment variables in helptext when default_env=True, for

jsonargparse/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ._core import * # noqa: F403
1515
from ._deprecated import * # noqa: F403
1616
from ._formatters import * # noqa: F403
17+
from ._from_config import * # noqa: F403
1718
from ._jsonnet import * # noqa: F403
1819
from ._jsonschema import * # noqa: F403
1920
from ._link_arguments import * # noqa: F403

jsonargparse/_from_config.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import inspect
2+
from functools import wraps
3+
from os import PathLike
4+
from pathlib import Path
5+
from typing import Optional, Type, TypeVar, Union
6+
7+
from ._core import ArgumentParser
8+
9+
__all__ = ["FromConfigMixin"]
10+
11+
T = TypeVar("T")
12+
13+
14+
class FromConfigMixin:
15+
"""Mixin class that adds from config support to classes.
16+
17+
This mixin does two things:
18+
19+
1. Adds support for overriding ``__init__`` defaults by defining a
20+
``__from_config_init_defaults__`` class attribute pointing to a
21+
config file path. The overriding of defaults happens on subclass
22+
creation time. Inspecting the signature will give the overridden
23+
defaults.
24+
25+
2. Adds a ``from_config`` ``@classmethod``, that instantiates the class
26+
based on a config file or dict.
27+
28+
Attributes:
29+
``__from_config_init_defaults__``: Optional path to a config file for
30+
overriding ``__init__`` defaults.
31+
``__from_config_parser_kwargs__``: Additional kwargs to pass to the
32+
ArgumentParser used for parsing configs.
33+
"""
34+
35+
__from_config_init_defaults__: Optional[Union[str, PathLike]] = None
36+
__from_config_parser_kwargs__: dict = {}
37+
38+
def __init_subclass__(cls, **kwargs) -> None:
39+
"""Override ``__init__`` defaults for the subclass based on a config file."""
40+
super().__init_subclass__(**kwargs)
41+
_override_init_defaults(cls, cls.__from_config_parser_kwargs__)
42+
43+
@classmethod
44+
def from_config(cls: Type[T], config: Union[str, PathLike, dict]) -> T:
45+
"""Instantiate current class based on a config file or dict.
46+
47+
Args:
48+
config: Path to a config file or a dict with config values.
49+
"""
50+
kwargs = _parse_class_kwargs_from_config(cls, config, **cls.__from_config_parser_kwargs__) # type: ignore[attr-defined]
51+
return cls(**kwargs)
52+
53+
54+
def _parse_class_kwargs_from_config(cls: Type[T], config: Union[str, PathLike, dict], **kwargs) -> dict:
55+
"""Parse the init kwargs for `cls` from a config file or dict."""
56+
parser = ArgumentParser(exit_on_error=False, **kwargs)
57+
parser.add_class_arguments(cls)
58+
if isinstance(config, dict):
59+
cfg = parser.parse_object(config, defaults=False)
60+
else:
61+
cfg = parser.parse_path(config, defaults=False)
62+
return parser.instantiate_classes(cfg).as_dict()
63+
64+
65+
def _override_init_defaults(cls: Type[T], parser_kwargs: dict) -> None:
66+
"""Override ``__init__`` defaults for ``cls`` based on ``__from_config_init_defaults__``."""
67+
config = getattr(cls, "__from_config_init_defaults__", None)
68+
if not isinstance(config, (str, PathLike, type(None))):
69+
raise TypeError("__from_config_init_defaults__ must be str, PathLike, or None")
70+
if not (isinstance(config, (str, PathLike)) and Path(config).is_file()):
71+
return
72+
73+
defaults = _parse_class_kwargs_from_config(cls, config, **parser_kwargs)
74+
_override_init_defaults_this_class(cls, defaults)
75+
_override_init_defaults_parent_classes(cls, defaults)
76+
77+
78+
def _override_init_defaults_this_class(cls: Type[T], defaults: dict) -> None:
79+
params = inspect.signature(cls.__init__).parameters
80+
for name, default in defaults.copy().items():
81+
param = params.get(name)
82+
if param and param.kind in {inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD}:
83+
defaults.pop(name)
84+
if param.kind == inspect.Parameter.KEYWORD_ONLY:
85+
cls.__init__.__kwdefaults__[name] = default # type: ignore[index]
86+
else:
87+
index = list(params).index(name) - 1
88+
aux = cls.__init__.__defaults__ or ()
89+
cls.__init__.__defaults__ = aux[:index] + (default,) + aux[index + 1 :]
90+
91+
92+
def _override_init_defaults_parent_classes(cls: Type[T], defaults: dict) -> None:
93+
# Gather defaults for parameters in parent classes' __init__
94+
override_parent_params = []
95+
for base in inspect.getmro(cls)[1:]:
96+
if not defaults:
97+
break
98+
99+
params = inspect.signature(base.__init__).parameters # type: ignore[misc]
100+
names = [name for name in defaults if name in params]
101+
for name in names:
102+
new_param = inspect.Parameter(
103+
name=name,
104+
kind=inspect.Parameter.KEYWORD_ONLY,
105+
default=defaults.pop(name),
106+
annotation=params[name].annotation,
107+
)
108+
override_parent_params.append(new_param)
109+
110+
if not override_parent_params:
111+
return
112+
113+
# Override defaults for parameters in parent classes' __init__ via a wrapper
114+
original_init = cls.__init__
115+
original_sig = inspect.signature(cls.__init__)
116+
parameters = list(original_sig.parameters.values())
117+
118+
# Find and pop the **kwargs parameter, if it exists
119+
kwargs_param = None
120+
if parameters and parameters[-1].kind == inspect.Parameter.VAR_KEYWORD:
121+
kwargs_param = parameters.pop()
122+
123+
# Add new parameters
124+
for param in reversed(override_parent_params):
125+
parameters.append(param)
126+
127+
# Add **kwargs back at the end
128+
if kwargs_param:
129+
parameters.append(kwargs_param)
130+
131+
# Create and set __init__ wrapper with new signature
132+
parent_defaults = {p.name: p.default for p in override_parent_params}
133+
134+
@wraps(original_init)
135+
def wrapper(*args, **kwargs):
136+
for name, default in parent_defaults.items():
137+
if name not in kwargs:
138+
kwargs[name] = default
139+
return original_init(*args, **kwargs)
140+
141+
wrapper.__signature__ = original_sig.replace(parameters=parameters) # type: ignore[attr-defined]
142+
cls.__init__ = wrapper # type: ignore[method-assign]

jsonargparse/_optionals.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,12 +282,9 @@ def get_omegaconf_loader(mode):
282282
if mode == "omegaconf+":
283283
from ._common import get_parsing_setting
284284

285-
if not get_parsing_setting("omegaconf_absolute_to_relative_paths"):
286-
return yaml_load
287-
288285
def omegaconf_plus_load(value):
289286
value = yaml_load(value)
290-
if isinstance(value, dict):
287+
if isinstance(value, dict) and get_parsing_setting("omegaconf_absolute_to_relative_paths"):
291288
value = omegaconf_absolute_to_relative_paths(value)
292289
return value
293290

jsonargparse_tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@
8383
reason="responses package is required",
8484
)
8585

86+
skip_if_omegaconf_unavailable = pytest.mark.skipif(
87+
not omegaconf_support,
88+
reason="omegaconf package is required",
89+
)
90+
91+
8692
skip_if_running_as_root = pytest.mark.skipif(
8793
is_posix and os.geteuid() == 0,
8894
reason="User is root, permission tests will not work",

0 commit comments

Comments
 (0)