Skip to content

Commit 2fd10c6

Browse files
authored
New omegaconf_absolute_to_relative_paths parsing setting to enable backward compatibility of omegaconf+ parser mode (#774)
1 parent 4073644 commit 2fd10c6

File tree

7 files changed

+137
-9
lines changed

7 files changed

+137
-9
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ Added
2727
- Argument to print help for dataclasses nested in types, e.g.
2828
``Optional[Data]``, ``Union[Data1, Data2]`` (`#783
2929
<https://github.com/omni-us/jsonargparse/pull/783>`__).
30+
- ``set_parsing_settings`` now supports ``omegaconf_absolute_to_relative_paths``
31+
to enable backward compatibility of ``omegaconf+`` parser mode by converting
32+
absolute paths to relative in interpolations (`#774
33+
<https://github.com/omni-us/jsonargparse/pull/774>`__).
3034

3135
Fixed
3236
^^^^^

DOCUMENTATION.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2475,7 +2475,10 @@ limitations of the ``omegaconf`` mode mentioned earlier. Instead of applying
24752475
OmegaConf resolvers to each YAML config individually, the resolving is performed
24762476
once at the end of the parsing process. As a result, in nested sub-configs,
24772477
references to nodes must be either relative or parser-level absolute to function
2478-
correctly.
2478+
correctly. Alternatively, you can
2479+
``set_parsing_settings(omegaconf_absolute_to_relative_paths=True)`` to enable
2480+
automatic conversion of absolute paths to relative ones during parsing. Be aware
2481+
that this automatic conversion does not work for every possible case.
24792482

24802483
Based on community feedback, this mode may become the default ``omegaconf`` mode
24812484
in version 5.0.0. This change would introduce a breaking modification, as

jsonargparse/_common.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def parser_context(**kwargs):
100100
"validate_defaults": False,
101101
"parse_optionals_as_positionals": False,
102102
"stubs_resolver_allow_py_files": False,
103+
"omegaconf_absolute_to_relative_paths": False,
103104
}
104105

105106

@@ -112,6 +113,7 @@ def set_parsing_settings(
112113
docstring_parse_attribute_docstrings: Optional[bool] = None,
113114
parse_optionals_as_positionals: Optional[bool] = None,
114115
stubs_resolver_allow_py_files: Optional[bool] = None,
116+
omegaconf_absolute_to_relative_paths: Optional[bool] = None,
115117
) -> None:
116118
"""
117119
Modify settings that affect the parsing behavior.
@@ -136,6 +138,10 @@ def set_parsing_settings(
136138
parser. By default, this is False.
137139
stubs_resolver_allow_py_files: Whether the stubs resolver should search
138140
in ``.py`` files in addition to ``.pyi`` files.
141+
omegaconf_absolute_to_relative_paths: If True, when loading configs
142+
with ``omegaconf+`` parser mode, absolute interpolation paths are
143+
converted to relative. This is only intended for backward
144+
compatibility with ``omegaconf`` parser mode.
139145
"""
140146
# validate_defaults
141147
if isinstance(validate_defaults, bool):
@@ -162,6 +168,13 @@ def set_parsing_settings(
162168
parsing_settings["stubs_resolver_allow_py_files"] = stubs_resolver_allow_py_files
163169
elif stubs_resolver_allow_py_files is not None:
164170
raise ValueError(f"stubs_resolver_allow_py_files must be a boolean, but got {stubs_resolver_allow_py_files}.")
171+
# omegaconf_absolute_to_relative_paths
172+
if isinstance(omegaconf_absolute_to_relative_paths, bool):
173+
parsing_settings["omegaconf_absolute_to_relative_paths"] = omegaconf_absolute_to_relative_paths
174+
elif omegaconf_absolute_to_relative_paths is not None:
175+
raise ValueError(
176+
f"omegaconf_absolute_to_relative_paths must be a boolean, but got {omegaconf_absolute_to_relative_paths}."
177+
)
165178

166179

167180
def get_parsing_setting(name: str):

jsonargparse/_loaders_dumpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ def set_omegaconf_loader(mode="omegaconf"):
339339
if omegaconf_support and mode not in loaders:
340340
from ._optionals import get_omegaconf_loader
341341

342-
loader = yaml_load if mode == "omegaconf+" else get_omegaconf_loader()
342+
loader = get_omegaconf_loader(mode)
343343
set_loader(mode, loader, get_loader_exceptions("yaml"))
344344

345345

jsonargparse/_optionals.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import inspect
44
import os
5+
import re
56
from contextlib import contextmanager
7+
from copy import deepcopy
68
from importlib.metadata import version
79
from importlib.util import find_spec
8-
from typing import Optional, Union
10+
from typing import List, Optional, Union
911

1012
__all__ = [
1113
"_get_config_read_mode",
@@ -266,7 +268,7 @@ def get_doc_short_description(function_or_class, method_name=None, logger=None):
266268
return None
267269

268270

269-
def get_omegaconf_loader():
271+
def get_omegaconf_loader(mode):
270272
"""Returns a yaml loader function based on OmegaConf which supports variable interpolation."""
271273
import io
272274

@@ -275,6 +277,22 @@ def get_omegaconf_loader():
275277
with missing_package_raise("omegaconf", "get_omegaconf_loader"):
276278
from omegaconf import OmegaConf
277279

280+
assert mode in {"omegaconf", "omegaconf+"}
281+
282+
if mode == "omegaconf+":
283+
from ._common import get_parsing_setting
284+
285+
if not get_parsing_setting("omegaconf_absolute_to_relative_paths"):
286+
return yaml_load
287+
288+
def omegaconf_plus_load(value):
289+
value = yaml_load(value)
290+
if isinstance(value, dict):
291+
value = omegaconf_absolute_to_relative_paths(value)
292+
return value
293+
294+
return omegaconf_plus_load
295+
278296
def omegaconf_load(value):
279297
value_pyyaml = yaml_load(value)
280298
if isinstance(value_pyyaml, (str, int, float, bool)) or value_pyyaml is None:
@@ -302,6 +320,57 @@ def omegaconf_apply(parser, cfg):
302320
return parser._apply_actions(cfg_dict)
303321

304322

323+
def omegaconf_tokenize(path: str) -> List[str]:
324+
"""Very small tokenizer: 'a.b[0].c' -> ['a','b','0','c']."""
325+
return [t for t in path.replace("]", "").replace("[", ".").split(".") if t]
326+
327+
328+
def omegaconf_tokens_to_path(tokens: List[str]) -> str:
329+
"""Render tokens back to a normalized path: ['a','0','b'] -> 'a[0].b'."""
330+
s = ""
331+
for t in tokens:
332+
if t.isdigit():
333+
s += f"[{t}]"
334+
else:
335+
s += ("" if s == "" else ".") + t
336+
return s
337+
338+
339+
def omegaconf_absolute_to_relative_paths(data: dict) -> dict:
340+
"""
341+
Return a new nested dict/list where absolute ${...} interpolations
342+
are rewritten to relative form from the node where they appear.
343+
"""
344+
data = deepcopy(data)
345+
346+
regex_absolute_path = re.compile(r"\$\{([a-zA-Z][a-zA-Z0-9[\]_.]*)\}")
347+
348+
def _walk(node, current_path: List[Union[str, int]]):
349+
if isinstance(node, dict):
350+
return {k: _walk(v, current_path + [k]) for k, v in node.items()}
351+
if isinstance(node, list):
352+
return [_walk(v, current_path + [i]) for i, v in enumerate(node)]
353+
354+
if isinstance(node, str):
355+
356+
def _replace(m: re.Match) -> str:
357+
dst_tokens = omegaconf_tokenize(m.group(1))
358+
# compute common prefix length
359+
i = 0
360+
while i < len(current_path) and i < len(dst_tokens) and str(current_path[i]) == dst_tokens[i]:
361+
i += 1
362+
up = max(1, len(current_path) - i)
363+
dots = "." * up
364+
down = omegaconf_tokens_to_path(dst_tokens[i:])
365+
return "${" + dots + down + "}"
366+
367+
return regex_absolute_path.sub(_replace, node)
368+
369+
return node
370+
371+
return _walk(data, [])
372+
373+
305374
annotated_alias = typing_extensions_import("_AnnotatedAlias")
306375

307376

jsonargparse_tests/test_omegaconf.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
import pytest
1111

1212
from jsonargparse import ArgumentParser, Namespace
13-
from jsonargparse._common import parser_context
13+
from jsonargparse._common import parser_context, set_parsing_settings
1414
from jsonargparse._loaders_dumpers import loaders, yaml_dump
15-
from jsonargparse._optionals import omegaconf_support
15+
from jsonargparse._optionals import omegaconf_absolute_to_relative_paths, omegaconf_support
1616
from jsonargparse.typing import Path_fr
1717
from jsonargparse_tests.conftest import get_parser_help
1818

@@ -25,6 +25,12 @@
2525
)
2626

2727

28+
@pytest.fixture(autouse=True)
29+
def patch_loaders():
30+
with patch.dict("jsonargparse._loaders_dumpers.loaders"):
31+
yield
32+
33+
2834
@pytest.mark.skipif(
2935
not (omegaconf_support and "JSONARGPARSE_OMEGACONF_FULL_TEST" in os.environ),
3036
reason="only for omegaconf as the yaml loader",
@@ -57,19 +63,23 @@ def test_omegaconf_interpolation(mode):
5763

5864

5965
@skip_if_omegaconf_unavailable
60-
@pytest.mark.parametrize("mode", ["omegaconf", "omegaconf+"])
66+
@pytest.mark.parametrize("mode", ["omegaconf", "omegaconf+", "omegaconf+absolute"])
67+
@patch.dict("jsonargparse._common.parsing_settings")
6168
def test_omegaconf_interpolation_in_subcommands(mode, parser, subparser):
6269
subparser.add_argument("--config", action="config")
6370
subparser.add_argument("--source", type=str)
6471
subparser.add_argument("--target", type=str)
6572

66-
parser.parser_mode = mode
73+
if mode == "omegaconf+absolute":
74+
set_parsing_settings(omegaconf_absolute_to_relative_paths=True)
75+
76+
parser.parser_mode = mode.replace("absolute", "")
6777
subcommands = parser.add_subcommands()
6878
subcommands.add_subcommand("sub", subparser)
6979

7080
config = {
7181
"source": "hello",
72-
"target": "${source}" if mode == "omegaconf" else "${.source}",
82+
"target": "${.source}" if mode == "omegaconf+" else "${source}",
7383
}
7484
cfg = parser.parse_args(["sub", f"--config={yaml_dump(config)}"])
7585
assert cfg.sub.target == "hello"
@@ -193,3 +203,24 @@ def test_omegaconf_inf_nan(parser):
193203
assert math.isnan(cfg.c)
194204
assert cfg.d == float("inf")
195205
assert cfg.e == float("-inf")
206+
207+
208+
@skip_if_omegaconf_unavailable
209+
def test_omegaconf_absolute_to_relative_paths():
210+
data = {
211+
"a": "x",
212+
"b": "prefix ${a} suffix",
213+
"c": {"d": "${b}", "e": "${c.d}"},
214+
"f": [10, "${c.e}", "${..b}"],
215+
"g": "${env:USER}",
216+
"h": "${f[0]}",
217+
}
218+
expected = {
219+
"a": "x",
220+
"b": "prefix ${.a} suffix",
221+
"c": {"d": "${..b}", "e": "${.d}"},
222+
"f": [10, "${..c.e}", "${..b}"],
223+
"g": "${env:USER}",
224+
"h": "${.f[0]}",
225+
}
226+
assert omegaconf_absolute_to_relative_paths(data) == expected

jsonargparse_tests/test_parsing_settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,11 @@ def test_optionals_as_positionals_unsupported_arguments(parser):
195195
def test_set_stubs_resolver_allow_py_files_failure():
196196
with pytest.raises(ValueError, match="stubs_resolver_allow_py_files must be a boolean"):
197197
set_parsing_settings(stubs_resolver_allow_py_files="invalid")
198+
199+
200+
# omegaconf_absolute_to_relative_paths
201+
202+
203+
def test_set_omegaconf_absolute_to_relative_paths_failure():
204+
with pytest.raises(ValueError, match="omegaconf_absolute_to_relative_paths must be a boolean"):
205+
set_parsing_settings(omegaconf_absolute_to_relative_paths="invalid")

0 commit comments

Comments
 (0)