Skip to content

Commit 6cd2f26

Browse files
authored
Add support for TOML config files (#660)
1 parent 59c5a7d commit 6cd2f26

19 files changed

+334
-136
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ repos:
5656
[
5757
types-PyYAML,
5858
types-requests,
59+
types-toml,
5960
]
6061
verbose: true
6162

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Added
2020
- Support without ``pyyaml``, though only an internal refactor prior to eventual
2121
removal of ``pyyaml`` as a required dependency in v5.0.0 (`#652
2222
<https://github.com/omni-us/jsonargparse/pull/652>`__).
23+
- Support for ``toml`` as config file format (`#660
24+
<https://github.com/omni-us/jsonargparse/pull/660>`__).
2325

2426
Changed
2527
^^^^^^^

DOCUMENTATION.rst

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,9 @@ All tools implemented with the :func:`.auto_cli` function have the ``--config``
190190
option to provide settings in a config file (more details in
191191
:ref:`configuration-files`). This becomes very useful when the number of
192192
configurable parameters is large. To ease the writing of config files, there is
193-
also the option ``--print_config`` which prints to standard output a yaml with
194-
all settings that the tool supports with their default values. Users of the tool
195-
can be advised to follow the following steps:
193+
also the option ``--print_config`` which prints to standard output all settings
194+
that the tool supports with their default values. Users of the tool can be
195+
advised to follow the following steps:
196196

197197
.. code-block:: bash
198198
@@ -1246,27 +1246,30 @@ is useful for example for class instantiation.
12461246
Configuration files
12471247
===================
12481248

1249-
An important feature of jsonargparse is the parsing of yaml/json files. The dot
1250-
notation hierarchy of the arguments (see :ref:`nested-namespaces`) are used for
1251-
the expected structure in the config files.
1249+
An important feature of jsonargparse is its ability to parse configuration
1250+
files. The dot notation hierarchy of the arguments (see
1251+
:ref:`nested-namespaces`) defines the expected structure in these files. By
1252+
default, the configuration format is ``yaml``. To change the format, use the
1253+
``parser_mode`` parameter when instantiating the parser, e.g.,
1254+
``ArgumentParser(parser_mode="toml")``.
12521255

12531256
The :py:attr:`.ArgumentParser.default_config_files` property can be set when
1254-
creating a parser to specify patterns to search for configuration files. For
1255-
example if a parser is created as
1257+
creating a parser to specify patterns for searching configuration files. For
1258+
example, if a parser is created as
12561259
``ArgumentParser(default_config_files=['~/.myapp.yaml', '/etc/myapp.yaml'])``,
1257-
when parsing if any of those two config files exist it will be parsed and used
1258-
to override the defaults. All matched config files are parsed and applied in the
1259-
given order. The default config files are always parsed first, this means that
1260-
any command line argument will override its values.
1261-
1262-
It is also possible to add an argument to explicitly provide a configuration
1263-
file path. Providing a config file as an argument does not disable the parsing
1264-
of ``default_config_files``. The config argument would be parsed in the specific
1265-
position among the command line arguments. Therefore the arguments found after
1266-
would override the values from that config file. The config argument can be
1267-
given multiple times, each overriding the values of the previous. Using the
1268-
example parser from the :ref:`nested-namespaces` section above, we could have
1269-
the following config file in yaml format:
1260+
it will search for and parse any of these files if they exist, using them to
1261+
override the defaults. All matched configuration files are parsed and applied in
1262+
the given order. The default configuration files are always parsed first,
1263+
meaning any command line argument will override their values.
1264+
1265+
You can also add an argument to explicitly provide a configuration file path.
1266+
Providing a configuration file as an argument does not disable the parsing of
1267+
``default_config_files``. The configuration argument will be parsed in the
1268+
specific position among the command line arguments, so arguments found afterward
1269+
will override the values from that configuration file. The configuration
1270+
argument can be given multiple times, each instance overriding the values of the
1271+
previous one. Using the example parser from the :ref:`nested-namespaces` section
1272+
above, we could have the following configuration file in yaml format:
12701273

12711274
.. code-block:: yaml
12721275
@@ -1336,10 +1339,10 @@ yaml comments by using ``--print_config=comments``. Another option is
13361339

13371340
From within python it is also possible to serialize a config object by using
13381341
either the :py:meth:`.ArgumentParser.dump` or :py:meth:`.ArgumentParser.save`
1339-
methods. Three formats with a particular style are supported: ``yaml``, ``json``
1340-
and ``json_indented``. It is possible to add more dumping formats by using the
1341-
:func:`.set_dumper` function. For example to allow dumping using PyYAML's
1342-
``default_flow_style`` do the following:
1342+
methods. Several formats with a particular style are supported: ``yaml``,
1343+
``toml``, ``json_compact`` and ``json_indented``. It is possible to add more
1344+
dumping formats by using the :func:`.set_dumper` function. For example to allow
1345+
dumping using PyYAML's ``default_flow_style`` do the following:
13431346

13441347
.. testcode::
13451348

README.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ Other notable features include:
8080
hierarchies.
8181

8282
- **Config file formats:** `json <https://www.json.org/>`__, `yaml
83-
<https://yaml.org/>`__, `jsonnet <https://jsonnet.org/>`__ and extendable to
84-
more formats.
83+
<https://yaml.org/>`__, `toml <https://toml.io/>`__, `jsonnet
84+
<https://jsonnet.org/>`__ and extendable to more formats.
8585

8686
- **Relative paths:** within config files and parsing of config paths referenced
8787
inside other configs.
@@ -134,10 +134,10 @@ You can install using `pip <https://pypi.org/project/jsonargparse/>`__ as:
134134
By default the only dependency that jsonargparse installs is `PyYAML
135135
<https://pypi.org/project/PyYAML/>`__. However, several optional features can be
136136
enabled by specifying any of the following extras requires: ``signatures``,
137-
``jsonschema``, ``jsonnet``, ``urls``, ``fsspec``, ``ruyaml``, ``omegaconf``,
138-
``shtab`` and ``argcomplete``. There is also the ``all`` extras require to
139-
enable all optional features (excluding tab completion ones). Installing
140-
jsonargparse with extras require is as follows:
137+
``jsonschema``, ``jsonnet``, ``urls``, ``fsspec``, ``toml``, ``ruyaml``,
138+
``omegaconf``, ``shtab`` and ``argcomplete``. There is also the ``all`` extras
139+
require to enable all optional features (excluding tab completion ones).
140+
Installing jsonargparse with extras require is as follows:
141141

142142
.. code-block:: bash
143143

jsonargparse/_core.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,8 +682,6 @@ def _load_config_parser_mode(
682682
cfg_dict = load_value(cfg_str, path=cfg_path, ext_vars=ext_vars)
683683
except get_loader_exceptions() as ex:
684684
raise TypeError(f"Problems parsing config: {ex}") from ex
685-
if cfg_dict is None:
686-
return Namespace()
687685
if key and isinstance(cfg_dict, dict):
688686
cfg_dict = cfg_dict.get(key, {})
689687
if not isinstance(cfg_dict, dict):
@@ -994,6 +992,9 @@ def get_defaults(self, skip_validation: bool = False, **kwargs) -> Namespace:
994992

995993
default_config_files = self._get_default_config_files()
996994
for key, default_config_file in default_config_files:
995+
default_config_file_content = default_config_file.get_content()
996+
if not default_config_file_content.strip():
997+
continue
997998
with change_to_path_dir(default_config_file), parser_context(parent_parser=self):
998999
cfg_file = self._load_config_parser_mode(default_config_file.get_content(), key=key)
9991000
cfg = self.merge_config(cfg_file, cfg)

jsonargparse/_loaders_dumpers.py

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import inspect
44
import re
5-
from typing import Any, Callable, Dict, Optional, Tuple, Type
5+
from contextlib import suppress
6+
from typing import Any, Callable, Dict, Optional, Set, Tuple, Type
67

78
from ._common import load_value_mode, parent_parser
8-
from ._optionals import import_jsonnet, omegaconf_support, pyyaml_available
9+
from ._optionals import import_jsonnet, import_toml_dumps, import_toml_loads, omegaconf_support, pyyaml_available
910
from ._type_checking import ArgumentParser
1011

1112
__all__ = [
@@ -15,9 +16,25 @@
1516
]
1617

1718

19+
not_loaded = object()
1820
yaml_default_loader = None
1921

2022

23+
def load_basic(value):
24+
value = value.strip()
25+
if value == "true":
26+
return True
27+
if value == "false":
28+
return False
29+
if value == "null":
30+
return None
31+
if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
32+
return int(value)
33+
if value.replace(".", "", 1).replace("e", "", 1).replace("-", "", 2).isdigit() and ("e" in value or "." in value):
34+
return float(value)
35+
return not_loaded
36+
37+
2138
def get_yaml_default_loader():
2239
global yaml_default_loader
2340
if yaml_default_loader:
@@ -57,7 +74,7 @@ def remove_implicit_resolver(cls, tag_to_remove):
5774
)
5875

5976
yaml_default_loader = DefaultLoader
60-
return DefaultLoader
77+
return yaml_default_loader
6178

6279

6380
def yaml_load(stream):
@@ -74,13 +91,15 @@ def yaml_load(stream):
7491
return value
7592

7693

77-
def json_load(stream):
94+
def json_load(value):
7895
import json
7996

80-
try:
81-
return json.loads(stream)
82-
except json.JSONDecodeError:
83-
return stream
97+
return json.loads(value)
98+
99+
100+
def toml_load(value):
101+
toml_loads, _ = import_toml_loads("toml_load")
102+
return toml_loads(value)
84103

85104

86105
def jsonnet_load(stream, path="", ext_vars=None):
@@ -101,10 +120,15 @@ def jsonnet_load(stream, path="", ext_vars=None):
101120
loaders: Dict[str, Callable] = {
102121
"yaml": yaml_load,
103122
"json": json_load,
104-
"jsonnet": jsonnet_load,
123+
"toml": toml_load,
105124
}
106-
107125
loader_exceptions: Dict[str, Tuple[Type[Exception], ...]] = {}
126+
loader_json_superset: Dict[str, bool] = {
127+
"yaml": True,
128+
"json": True,
129+
"toml": False,
130+
}
131+
loader_params: Dict[str, Set[str]] = {}
108132

109133

110134
def get_load_value_mode() -> str:
@@ -123,28 +147,56 @@ def get_loader_exceptions(mode: Optional[str] = None) -> Tuple[Type[Exception],
123147
if mode == "yaml":
124148
loader_exceptions[mode] = (__import__("yaml").YAMLError,)
125149
elif mode == "json":
126-
loader_exceptions[mode] = tuple()
150+
loader_exceptions[mode] = (__import__("json").JSONDecodeError,)
151+
elif mode == "toml":
152+
loader_exceptions[mode] = (import_toml_loads("get_loader_exceptions")[1],)
127153
elif mode == "jsonnet":
128-
return get_loader_exceptions("yaml" if pyyaml_available else "json")
154+
return get_loader_exceptions("yaml" if pyyaml_available else "json") + (ValueError,)
129155
return loader_exceptions[mode]
130156

131157

132-
json_or_yaml_load = yaml_load if pyyaml_available else json_load
158+
def json_or_yaml_load(value):
159+
if pyyaml_available:
160+
if isinstance(value, str) and value.strip() == "":
161+
return value
162+
return yaml_load(value)
163+
return json_load(value)
164+
165+
133166
json_or_yaml_loader_exceptions = get_loader_exceptions("yaml" if pyyaml_available else "json")
134167

135168

169+
def load_list_or_dict(value: str):
170+
strip = value.strip()
171+
if (strip.startswith("[") and strip.endswith("]")) or (strip.startswith("{") and strip.endswith("}")):
172+
import json
173+
174+
with suppress(json.JSONDecodeError):
175+
return json.loads(strip)
176+
return not_loaded
177+
178+
136179
def load_value(value: str, simple_types: bool = False, **kwargs):
137-
if not value:
138-
return None
139-
elif value.strip() == "-":
180+
if value.strip() == "-":
140181
return value
141-
loader = loaders[get_load_value_mode()]
142-
if kwargs:
143-
params = set(list(inspect.signature(loader).parameters)[1:])
144-
kwargs = {k: v for k, v in kwargs.items() if k in params}
145-
loaded_value = loader(value, **kwargs)
182+
183+
loaded_value = load_basic(value)
184+
185+
mode = get_load_value_mode()
186+
if loaded_value is not_loaded and not loader_json_superset[mode]:
187+
loaded_value = load_list_or_dict(value)
188+
189+
if loaded_value is not_loaded:
190+
loader = loaders[mode]
191+
load_kwargs = {}
192+
if kwargs and mode in loader_params:
193+
params = loader_params[mode]
194+
load_kwargs = {k: v for k, v in kwargs.items() if k in params}
195+
loaded_value = loader(value, **load_kwargs)
196+
146197
if not simple_types and isinstance(loaded_value, (int, float, bool, str)):
147198
loaded_value = value
199+
148200
return loaded_value
149201

150202

@@ -172,7 +224,7 @@ def yaml_comments_dump(data, parser):
172224
return formatter.add_yaml_comments(dump)
173225

174226

175-
def json_dump(data):
227+
def json_compact_dump(data):
176228
import json
177229

178230
return json.dumps(data, separators=(",", ":"), **dump_json_kwargs)
@@ -184,18 +236,26 @@ def json_indented_dump(data):
184236
return json.dumps(data, indent=2, **dump_json_kwargs) + "\n"
185237

186238

239+
def toml_dump(data):
240+
toml_dumps = import_toml_dumps("toml_dump")
241+
return toml_dumps(data)
242+
243+
187244
dumpers: Dict[str, Callable] = {
188245
"yaml": yaml_dump,
189246
"yaml_comments": yaml_comments_dump,
190-
"json": json_dump,
247+
"json": json_compact_dump,
248+
"json_compact": json_compact_dump,
191249
"json_indented": json_indented_dump,
250+
"toml": toml_dump,
192251
"jsonnet": json_indented_dump,
193252
}
194253

195254
comment_prefix: Dict[str, str] = {
196255
"yaml": "# ",
197256
"yaml_comments": "# ",
198257
"jsonnet": "// ",
258+
"toml": "# ",
199259
}
200260

201261

@@ -220,6 +280,7 @@ def set_loader(
220280
mode: str,
221281
loader_fn: Callable[[str], Any],
222282
exceptions: Tuple[Type[Exception], ...] = tuple(),
283+
json_superset: bool = True,
223284
):
224285
"""Sets the value loader function to be used when parsing with a certain mode.
225286
@@ -234,9 +295,14 @@ def set_loader(
234295
loader_fn: The loader function to set. Example: ``yaml.safe_load``.
235296
exceptions: Exceptions that the loader can raise when load fails.
236297
Example: (yaml.YAMLError,).
298+
json_superset: Whether the loader can load JSON data.
237299
"""
238300
loaders[mode] = loader_fn
239301
loader_exceptions[mode] = exceptions
302+
loader_json_superset[mode] = json_superset
303+
params = set(list(inspect.signature(loader_fn).parameters)[1:])
304+
if params:
305+
loader_params[mode] = params
240306

241307

242308
def get_loader(mode: str):
@@ -259,3 +325,6 @@ def set_omegaconf_loader():
259325
from ._optionals import get_omegaconf_loader
260326

261327
set_loader("omegaconf", get_omegaconf_loader())
328+
329+
330+
set_loader("jsonnet", jsonnet_load, get_loader_exceptions("jsonnet"))

jsonargparse/_optionals.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616

1717
pyyaml_available = bool(find_spec("yaml"))
18+
toml_load_available = bool(find_spec("toml") or find_spec("tomllib"))
19+
toml_dump_available = bool(find_spec("toml"))
1820
typing_extensions_support = find_spec("typing_extensions") is not None
1921
typeshed_client_support = find_spec("typeshed_client") is not None
2022
jsonschema_support = find_spec("jsonschema") is not None
@@ -103,6 +105,24 @@ def missing_package_raise(package, importer):
103105
raise ImportError(f"{package} package is required by {importer} :: {ex}") from ex
104106

105107

108+
def import_toml_loads(importer):
109+
if find_spec("tomllib"):
110+
import tomllib
111+
112+
return tomllib.loads, tomllib.TOMLDecodeError
113+
else:
114+
with missing_package_raise("toml", importer):
115+
import toml
116+
117+
return toml.loads, toml.TomlDecodeError
118+
119+
120+
def import_toml_dumps(importer):
121+
with missing_package_raise("toml", importer):
122+
import toml
123+
return toml.dumps
124+
125+
106126
def import_jsonschema(importer):
107127
with missing_package_raise("jsonschema", importer):
108128
import jsonschema

0 commit comments

Comments
 (0)