Repository: omni-us/jsonargparse (python)

Issue:
Support TOML configuration file format
## 🚀 Feature request

Hi, Thanks for this tool. 

The Python language steering council has accepted PEP 680, which adds a TOML parser to the Python standard library. I was wondering if you want to add the support of the configuration file in TOML format.

### Motivation

TOML (Tom's Obvious Minimal Language) is a configuration file format that is easy to read and write. I have used configuration files in TOML format in my deep learning projects for a long time. It looks like TOML has a stricter definition than YAML and is more flexible than JSON. I knew that some official projects of the Python community use configuration in TOML format, e.g., `pyproject.toml` in pip.

Pull request description:
Add support for TOML config files
## What does this PR do?

Fixes #226.

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/main/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a7b1008..f45b4b1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -56,6 +56,7 @@ repos:
       [
         types-PyYAML,
         types-requests,
+        types-toml,
       ]
     verbose: true
 
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0c3153f..d19d403 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -20,6 +20,8 @@ Added
 - Support without ``pyyaml``, though only an internal refactor prior to eventual
   removal of ``pyyaml`` as a required dependency in v5.0.0 (`#652
   <https://github.com/omni-us/jsonargparse/pull/652>`__).
+- Support for ``toml`` as config file format (`#660
+  <https://github.com/omni-us/jsonargparse/pull/660>`__).
 
 Changed
 ^^^^^^^
diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst
index 61c7ea0..db56715 100644
--- a/DOCUMENTATION.rst
+++ b/DOCUMENTATION.rst
@@ -190,9 +190,9 @@ All tools implemented with the :func:`.auto_cli` function have the ``--config``
 option to provide settings in a config file (more details in
 :ref:`configuration-files`). This becomes very useful when the number of
 configurable parameters is large. To ease the writing of config files, there is
-also the option ``--print_config`` which prints to standard output a yaml with
-all settings that the tool supports with their default values. Users of the tool
-can be advised to follow the following steps:
+also the option ``--print_config`` which prints to standard output all settings
+that the tool supports with their default values. Users of the tool can be
+advised to follow the following steps:
 
 .. code-block:: bash
 
@@ -1246,27 +1246,30 @@ is useful for example for class instantiation.
 Configuration files
 ===================
 
-An important feature of jsonargparse is the parsing of yaml/json files. The dot
-notation hierarchy of the arguments (see :ref:`nested-namespaces`) are used for
-the expected structure in the config files.
+An important feature of jsonargparse is its ability to parse configuration
+files. The dot notation hierarchy of the arguments (see
+:ref:`nested-namespaces`) defines the expected structure in these files. By
+default, the configuration format is ``yaml``. To change the format, use the
+``parser_mode`` parameter when instantiating the parser, e.g.,
+``ArgumentParser(parser_mode="toml")``.
 
 The :py:attr:`.ArgumentParser.default_config_files` property can be set when
-creating a parser to specify patterns to search for configuration files. For
-example if a parser is created as
+creating a parser to specify patterns for searching configuration files. For
+example, if a parser is created as
 ``ArgumentParser(default_config_files=['~/.myapp.yaml', '/etc/myapp.yaml'])``,
-when parsing if any of those two config files exist it will be parsed and used
-to override the defaults. All matched config files are parsed and applied in the
-given order. The default config files are always parsed first, this means that
-any command line argument will override its values.
-
-It is also possible to add an argument to explicitly provide a configuration
-file path. Providing a config file as an argument does not disable the parsing
-of ``default_config_files``. The config argument would be parsed in the specific
-position among the command line arguments. Therefore the arguments found after
-would override the values from that config file. The config argument can be
-given multiple times, each overriding the values of the previous. Using the
-example parser from the :ref:`nested-namespaces` section above, we could have
-the following config file in yaml format:
+it will search for and parse any of these files if they exist, using them to
+override the defaults. All matched configuration files are parsed and applied in
+the given order. The default configuration files are always parsed first,
+meaning any command line argument will override their values.
+
+You can also add an argument to explicitly provide a configuration file path.
+Providing a configuration file as an argument does not disable the parsing of
+``default_config_files``. The configuration argument will be parsed in the
+specific position among the command line arguments, so arguments found afterward
+will override the values from that configuration file. The configuration
+argument can be given multiple times, each instance overriding the values of the
+previous one. Using the example parser from the :ref:`nested-namespaces` section
+above, we could have the following configuration file in yaml format:
 
 .. code-block:: yaml
 
@@ -1336,10 +1339,10 @@ yaml comments by using ``--print_config=comments``. Another option is
 
 From within python it is also possible to serialize a config object by using
 either the :py:meth:`.ArgumentParser.dump` or :py:meth:`.ArgumentParser.save`
-methods. Three formats with a particular style are supported: ``yaml``, ``json``
-and ``json_indented``. It is possible to add more dumping formats by using the
-:func:`.set_dumper` function. For example to allow dumping using PyYAML's
-``default_flow_style`` do the following:
+methods. Several formats with a particular style are supported: ``yaml``,
+``toml``, ``json_compact`` and ``json_indented``. It is possible to add more
+dumping formats by using the :func:`.set_dumper` function. For example to allow
+dumping using PyYAML's ``default_flow_style`` do the following:
 
 .. testcode::
 
diff --git a/README.rst b/README.rst
index 9a65059..e3f1d69 100644
--- a/README.rst
+++ b/README.rst
@@ -80,8 +80,8 @@ Other notable features include:
   hierarchies.
 
 - **Config file formats:** `json <https://www.json.org/>`__, `yaml
-  <https://yaml.org/>`__, `jsonnet <https://jsonnet.org/>`__ and extendable to
-  more formats.
+  <https://yaml.org/>`__, `toml <https://toml.io/>`__, `jsonnet
+  <https://jsonnet.org/>`__ and extendable to more formats.
 
 - **Relative paths:** within config files and parsing of config paths referenced
   inside other configs.
@@ -134,10 +134,10 @@ You can install using `pip <https://pypi.org/project/jsonargparse/>`__ as:
 By default the only dependency that jsonargparse installs is `PyYAML
 <https://pypi.org/project/PyYAML/>`__. However, several optional features can be
 enabled by specifying any of the following extras requires: ``signatures``,
-``jsonschema``, ``jsonnet``, ``urls``, ``fsspec``, ``ruyaml``, ``omegaconf``,
-``shtab`` and ``argcomplete``. There is also the ``all`` extras require to
-enable all optional features (excluding tab completion ones). Installing
-jsonargparse with extras require is as follows:
+``jsonschema``, ``jsonnet``, ``urls``, ``fsspec``, ``toml``, ``ruyaml``,
+``omegaconf``, ``shtab`` and ``argcomplete``. There is also the ``all`` extras
+require to enable all optional features (excluding tab completion ones).
+Installing jsonargparse with extras require is as follows:
 
 .. code-block:: bash
 
diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py
index f9f1009..cdbc569 100644
--- a/jsonargparse/_core.py
+++ b/jsonargparse/_core.py
@@ -682,8 +682,6 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
             cfg_dict = load_value(cfg_str, path=cfg_path, ext_vars=ext_vars)
         except get_loader_exceptions() as ex:
             raise TypeError(f"Problems parsing config: {ex}") from ex
-        if cfg_dict is None:
-            return Namespace()
         if key and isinstance(cfg_dict, dict):
             cfg_dict = cfg_dict.get(key, {})
         if not isinstance(cfg_dict, dict):
@@ -994,6 +992,9 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
 
         default_config_files = self._get_default_config_files()
         for key, default_config_file in default_config_files:
+            default_config_file_content = default_config_file.get_content()
+            if not default_config_file_content.strip():
+                continue
             with change_to_path_dir(default_config_file), parser_context(parent_parser=self):
                 cfg_file = self._load_config_parser_mode(default_config_file.get_content(), key=key)
                 cfg = self.merge_config(cfg_file, cfg)
diff --git a/jsonargparse/_loaders_dumpers.py b/jsonargparse/_loaders_dumpers.py
index 9d76d24..6358e9a 100644
--- a/jsonargparse/_loaders_dumpers.py
+++ b/jsonargparse/_loaders_dumpers.py
@@ -2,10 +2,11 @@
 
 import inspect
 import re
-from typing import Any, Callable, Dict, Optional, Tuple, Type
+from contextlib import suppress
+from typing import Any, Callable, Dict, Optional, Set, Tuple, Type
 
 from ._common import load_value_mode, parent_parser
-from ._optionals import import_jsonnet, omegaconf_support, pyyaml_available
+from ._optionals import import_jsonnet, import_toml_dumps, import_toml_loads, omegaconf_support, pyyaml_available
 from ._type_checking import ArgumentParser
 
 __all__ = [
@@ -15,9 +16,25 @@ __all__ = [
 ]
 
 
+not_loaded = object()
 yaml_default_loader = None
 
 
+def load_basic(value):
+    value = value.strip()
+    if value == "true":
+        return True
+    if value == "false":
+        return False
+    if value == "null":
+        return None
+    if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
+        return int(value)
+    if value.replace(".", "", 1).replace("e", "", 1).replace("-", "", 2).isdigit() and ("e" in value or "." in value):
+        return float(value)
+    return not_loaded
+
+
 def get_yaml_default_loader():
     global yaml_default_loader
     if yaml_default_loader:
@@ -57,7 +74,7 @@ def get_yaml_default_loader():
     )
 
     yaml_default_loader = DefaultLoader
-    return DefaultLoader
+    return yaml_default_loader
 
 
 def yaml_load(stream):
@@ -74,13 +91,15 @@ def yaml_load(stream):
     return value
 
 
-def json_load(stream):
+def json_load(value):
     import json
 
-    try:
-        return json.loads(stream)
-    except json.JSONDecodeError:
-        return stream
+    return json.loads(value)
+
+
+def toml_load(value):
+    toml_loads, _ = import_toml_loads("toml_load")
+    return toml_loads(value)
 
 
 def jsonnet_load(stream, path="", ext_vars=None):
@@ -101,10 +120,15 @@ def jsonnet_load(stream, path="", ext_vars=None):
 loaders: Dict[str, Callable] = {
     "yaml": yaml_load,
     "json": json_load,
-    "jsonnet": jsonnet_load,
+    "toml": toml_load,
 }
-
 loader_exceptions: Dict[str, Tuple[Type[Exception], ...]] = {}
+loader_json_superset: Dict[str, bool] = {
+    "yaml": True,
+    "json": True,
+    "toml": False,
+}
+loader_params: Dict[str, Set[str]] = {}
 
 
 def get_load_value_mode() -> str:
@@ -123,28 +147,56 @@ def get_loader_exceptions(mode: Optional[str] = None) -> Tuple[Type[Exception],
         if mode == "yaml":
             loader_exceptions[mode] = (__import__("yaml").YAMLError,)
         elif mode == "json":
-            loader_exceptions[mode] = tuple()
+            loader_exceptions[mode] = (__import__("json").JSONDecodeError,)
+        elif mode == "toml":
+            loader_exceptions[mode] = (import_toml_loads("get_loader_exceptions")[1],)
         elif mode == "jsonnet":
-            return get_loader_exceptions("yaml" if pyyaml_available else "json")
+            return get_loader_exceptions("yaml" if pyyaml_available else "json") + (ValueError,)
     return loader_exceptions[mode]
 
 
-json_or_yaml_load = yaml_load if pyyaml_available else json_load
+def json_or_yaml_load(value):
+    if pyyaml_available:
+        if isinstance(value, str) and value.strip() == "":
+            return value
+        return yaml_load(value)
+    return json_load(value)
+
+
 json_or_yaml_loader_exceptions = get_loader_exceptions("yaml" if pyyaml_available else "json")
 
 
+def load_list_or_dict(value: str):
+    strip = value.strip()
+    if (strip.startswith("[") and strip.endswith("]")) or (strip.startswith("{") and strip.endswith("}")):
+        import json
+
+        with suppress(json.JSONDecodeError):
+            return json.loads(strip)
+    return not_loaded
+
+
 def load_value(value: str, simple_types: bool = False, **kwargs):
-    if not value:
-        return None
-    elif value.strip() == "-":
+    if value.strip() == "-":
         return value
-    loader = loaders[get_load_value_mode()]
-    if kwargs:
-        params = set(list(inspect.signature(loader).parameters)[1:])
-        kwargs = {k: v for k, v in kwargs.items() if k in params}
-    loaded_value = loader(value, **kwargs)
+
+    loaded_value = load_basic(value)
+
+    mode = get_load_value_mode()
+    if loaded_value is not_loaded and not loader_json_superset[mode]:
+        loaded_value = load_list_or_dict(value)
+
+    if loaded_value is not_loaded:
+        loader = loaders[mode]
+        load_kwargs = {}
+        if kwargs and mode in loader_params:
+            params = loader_params[mode]
+            load_kwargs = {k: v for k, v in kwargs.items() if k in params}
+        loaded_value = loader(value, **load_kwargs)
+
     if not simple_types and isinstance(loaded_value, (int, float, bool, str)):
         loaded_value = value
+
     return loaded_value
 
 
@@ -172,7 +224,7 @@ def yaml_comments_dump(data, parser):
     return formatter.add_yaml_comments(dump)
 
 
-def json_dump(data):
+def json_compact_dump(data):
     import json
 
     return json.dumps(data, separators=(",", ":"), **dump_json_kwargs)
@@ -184,11 +236,18 @@ def json_indented_dump(data):
     return json.dumps(data, indent=2, **dump_json_kwargs) + "\n"
 
 
+def toml_dump(data):
+    toml_dumps = import_toml_dumps("toml_dump")
+    return toml_dumps(data)
+
+
 dumpers: Dict[str, Callable] = {
     "yaml": yaml_dump,
     "yaml_comments": yaml_comments_dump,
-    "json": json_dump,
+    "json": json_compact_dump,
+    "json_compact": json_compact_dump,
     "json_indented": json_indented_dump,
+    "toml": toml_dump,
     "jsonnet": json_indented_dump,
 }
 
@@ -196,6 +255,7 @@ comment_prefix: Dict[str, str] = {
     "yaml": "# ",
     "yaml_comments": "# ",
     "jsonnet": "// ",
+    "toml": "# ",
 }
 
 
@@ -220,6 +280,7 @@ def set_loader(
     mode: str,
     loader_fn: Callable[[str], Any],
     exceptions: Tuple[Type[Exception], ...] = tuple(),
+    json_superset: bool = True,
 ):
     """Sets the value loader function to be used when parsing with a certain mode.
 
@@ -234,9 +295,14 @@ def set_loader(
         loader_fn: The loader function to set. Example: ``yaml.safe_load``.
         exceptions: Exceptions that the loader can raise when load fails.
             Example: (yaml.YAMLError,).
+        json_superset: Whether the loader can load JSON data.
     """
     loaders[mode] = loader_fn
     loader_exceptions[mode] = exceptions
+    loader_json_superset[mode] = json_superset
+    params = set(list(inspect.signature(loader_fn).parameters)[1:])
+    if params:
+        loader_params[mode] = params
 
 
 def get_loader(mode: str):
@@ -259,3 +325,6 @@ def set_omegaconf_loader():
         from ._optionals import get_omegaconf_loader
 
         set_loader("omegaconf", get_omegaconf_loader())
+
+
+set_loader("jsonnet", jsonnet_load, get_loader_exceptions("jsonnet"))
diff --git a/jsonargparse/_optionals.py b/jsonargparse/_optionals.py
index fb0dacc..6a02143 100644
--- a/jsonargparse/_optionals.py
+++ b/jsonargparse/_optionals.py
@@ -15,6 +15,8 @@ __all__ = [
 
 
 pyyaml_available = bool(find_spec("yaml"))
+toml_load_available = bool(find_spec("toml") or find_spec("tomllib"))
+toml_dump_available = bool(find_spec("toml"))
 typing_extensions_support = find_spec("typing_extensions") is not None
 typeshed_client_support = find_spec("typeshed_client") is not None
 jsonschema_support = find_spec("jsonschema") is not None
@@ -103,6 +105,24 @@ def missing_package_raise(package, importer):
         raise ImportError(f"{package} package is required by {importer} :: {ex}") from ex
 
 
+def import_toml_loads(importer):
+    if find_spec("tomllib"):
+        import tomllib
+
+        return tomllib.loads, tomllib.TOMLDecodeError
+    else:
+        with missing_package_raise("toml", importer):
+            import toml
+
+        return toml.loads, toml.TomlDecodeError
+
+
+def import_toml_dumps(importer):
+    with missing_package_raise("toml", importer):
+        import toml
+    return toml.dumps
+
+
 def import_jsonschema(importer):
     with missing_package_raise("jsonschema", importer):
         import jsonschema
diff --git a/jsonargparse/_parameter_resolvers.py b/jsonargparse/_parameter_resolvers.py
index 6cf4ee2..5fb98bd 100644
--- a/jsonargparse/_parameter_resolvers.py
+++ b/jsonargparse/_parameter_resolvers.py
@@ -29,7 +29,6 @@ from ._stubs_resolver import get_stub_types
 from ._util import (
     ClassFromFunctionBase,
     get_import_path,
-    get_typehint_args,
     get_typehint_origin,
     iter_to_set_str,
     unique,
@@ -325,7 +324,7 @@ def unpack_typed_dict_kwargs(params: ParamList, kwargs_idx: int) -> int:
     annotation = kwargs.annotation
     if is_unpack_typehint(annotation):
         params.pop(kwargs_idx)
-        annotation_args = get_typehint_args(annotation)
+        annotation_args: tuple = getattr(annotation, "__args__", tuple())
         assert len(annotation_args) == 1, "Unpack requires a single type argument"
         dict_annotations = annotation_args[0].__annotations__
         new_params = []
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index bd6b2f7..b87b42c 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -176,6 +176,7 @@ mapping_origin_types = {
     abc.MutableMapping,
     OrderedDict,
 }
+sequence_or_mapping_origin_types = sequence_origin_types.union(mapping_origin_types)
 callable_origin_types = {Callable, abc.Callable}
 
 literal_types = {Literal}
@@ -571,6 +572,7 @@ class ActionTypeHint(Action):
                 kwargs = {
                     "sub_add_kwargs": getattr(self, "sub_add_kwargs", {}),
                     "prev_val": prev_val,
+                    "orig_val": orig_val,
                     "append": append,
                     "enable_path": enable_path,
                     "logger": self.logger,
@@ -705,11 +707,10 @@ def raise_unexpected_value(message: str, val: Any = inspect._empty, exception: O
     raise ValueError(message) from exception
 
 
-def raise_union_unexpected_value(uniontype, val: Any, exceptions: List[Exception]) -> NoReturn:
+def raise_union_unexpected_value(subtypes, val: Any, exceptions: List[Exception]) -> NoReturn:
     str_exceptions = [indent_text(str(e), first_line=False) for e in exceptions]
     errors = indent_text("- " + "\n- ".join(str_exceptions))
     errors = errors.replace(f". Got value: {val}", "").replace(f" {val} ", " ")
-    subtypes = uniontype.__args__
     raise ValueError(
         f"Does not validate against any of the Union subtypes\nSubtypes: {subtypes}"
         f"\nErrors:\n{errors}\nGiven value type: {type(val)}\nGiven value: {val}"
@@ -722,6 +723,7 @@ def adapt_typehints(
     serialize=False,
     instantiate_classes=False,
     prev_val=None,
+    orig_val=None,
     append=False,
     list_item=False,
     enable_path=False,
@@ -736,6 +738,7 @@ def adapt_typehints(
         "serialize": serialize,
         "instantiate_classes": instantiate_classes,
         "prev_val": prev_val,
+        "orig_val": orig_val,
         "append": append,
         "enable_path": enable_path,
         "sub_add_kwargs": sub_add_kwargs or {},
@@ -818,16 +821,19 @@ def adapt_typehints(
     # Union
     elif typehint_origin == Union:
         vals = []
-        subtypehints = sort_subtypes_for_append(subtypehints) if append else subtypehints
-        for subtypehint in subtypehints:
+        sorted_subtypes = sort_subtypes_for_union(subtypehints, val, append)
+        for subtype in sorted_subtypes:
             try:
-                vals.append(adapt_typehints(val, subtypehint, **adapt_kwargs))
+                vals.append(adapt_typehints(val, subtype, **adapt_kwargs))
                 break
             except Exception as ex:
+                if subtype is str and not isinstance(val, str) and isinstance(orig_val, str):
+                    vals.append(orig_val)
+                    continue
                 vals.append(ex)
         if all(isinstance(v, Exception) for v in vals):
-            raise_union_unexpected_value(typehint, val, vals)
-        val = [v for v in vals if not isinstance(v, Exception)][0]
+            raise_union_unexpected_value(sorted_subtypes, val, vals)
+        val = vals[-1]
 
     # Tuple or Set
     elif typehint_origin in tuple_set_origin_types:
@@ -859,6 +865,7 @@ def adapt_typehints(
             val_is_list = isinstance(val, list)
             val = prev_val + (val if val_is_list else [val])
             prev_val = prev_val + [None] * (len(val) - len(prev_val) if val_is_list else 1)
+        list_path = None
         if enable_path and type(val) is str:
             with suppress(TypeError):
                 from ._optionals import get_config_read_mode
@@ -877,7 +884,8 @@ def adapt_typehints(
                     adapt_kwargs_n = {**deepcopy(adapt_kwargs), "prev_val": prev_val[n]}
                 else:
                     adapt_kwargs_n = deepcopy(adapt_kwargs)
-                val[n] = adapt_typehints(v, subtypehints[0], list_item=True, **adapt_kwargs_n)
+                with change_to_path_dir(list_path):
+                    val[n] = adapt_typehints(v, subtypehints[0], list_item=True, **adapt_kwargs_n)
 
     # Dict, Mapping
     elif typehint_origin in mapping_origin_types:
@@ -1421,9 +1429,12 @@ def adapt_class_type(
     if dict_kwargs:
         if prev_val and prev_val.get("class_path") == value["class_path"] and prev_val.get("dict_kwargs"):
             dict_kwargs = {**prev_val.get("dict_kwargs"), **dict_kwargs}
-        value["dict_kwargs"] = {
-            k: load_value(v, simple_types=True) if isinstance(v, str) else v for k, v in dict_kwargs.items()
-        }
+        value["dict_kwargs"] = {}
+        for key, val in dict_kwargs.items():
+            if isinstance(val, str):
+                with suppress(get_loader_exceptions()):
+                    val = load_value(val, simple_types=True)
+            value["dict_kwargs"][key] = val
     return value
 
 
@@ -1449,9 +1460,18 @@ def adapt_classes_any(val, serialize, instantiate_classes, sub_add_kwargs):
     return val
 
 
-def sort_subtypes_for_append(subtypes):
-    if subtypes and len(subtypes) > 1:
-        subtypes = sorted(subtypes, key=lambda x: get_typehint_origin(x) not in sequence_origin_types)
+def sort_subtypes_for_union(subtypes, val, append):
+    if len(subtypes) > 1:
+        if isinstance(val, str):
+            key_fn = lambda x: (
+                x != NoneType,
+                get_typehint_origin(x) not in sequence_or_mapping_origin_types,
+            )
+        else:
+            key_fn = lambda x: x != NoneType
+        subtypes = sorted(subtypes, key=key_fn)
+        if append:
+            subtypes = sorted(subtypes, key=lambda x: get_typehint_origin(x) not in sequence_origin_types)
     return subtypes
 
 
diff --git a/jsonargparse/_util.py b/jsonargparse/_util.py
index 4d3c010..6d1c031 100644
--- a/jsonargparse/_util.py
+++ b/jsonargparse/_util.py
@@ -37,7 +37,7 @@ from ._common import (
     parser_context,
 )
 from ._deprecated import PathDeprecations
-from ._loaders_dumpers import json_dump, load_value
+from ._loaders_dumpers import json_compact_dump, load_value
 from ._optionals import (
     fsspec_support,
     get_config_read_mode,
@@ -268,10 +268,6 @@ def object_path_serializer(value):
         raise ValueError(f"Only possible to serialize an importable object, given {value}: {ex}") from ex
 
 
-def get_typehint_args(typehint):
-    return getattr(typehint, "__args__", tuple())
-
-
 def get_typehint_origin(typehint):
     if not hasattr(typehint, "__origin__"):
         typehint_class = get_import_path(typehint.__class__)
@@ -319,7 +315,7 @@ def change_to_path_dir(path: Optional["Path"]) -> Iterator[Optional[str]]:
 def hash_item(item):
     try:
         if isinstance(item, (dict, list)):
-            item_hash = hash(json_dump(item))
+            item_hash = hash(json_compact_dump(item))
         else:
             item_hash = hash(item)
     except Exception:
diff --git a/pyproject.toml b/pyproject.toml
index 1eb0bda..2f7767f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,7 @@ all = [
     "jsonargparse[signatures]",
     "jsonargparse[jsonschema]",
     "jsonargparse[jsonnet]",
+    "jsonargparse[toml]",
     "jsonargparse[urls]",
     "jsonargparse[fsspec]",
     "jsonargparse[ruyaml]",
@@ -61,6 +62,9 @@ jsonnet = [
     "jsonnet-binary>=0.17.0; os_name != 'posix' and python_version < '3.13'",
     "jsonnet-sdist==2024.6.23; python_version == '3.13'",
 ]
+toml = [
+    "toml>=0.10.2",
+]
 urls = [
     "requests>=2.18.4",
 ]


Test patch:
diff --git a/jsonargparse_tests/conftest.py b/jsonargparse_tests/conftest.py
index b40ea10..76bdfd2 100644
--- a/jsonargparse_tests/conftest.py
+++ b/jsonargparse_tests/conftest.py
@@ -13,13 +13,15 @@ from unittest.mock import MagicMock, patch
 import pytest
 
 from jsonargparse import ArgumentParser
-from jsonargparse._loaders_dumpers import json_dump, json_load, yaml_dump, yaml_load
+from jsonargparse._loaders_dumpers import json_compact_dump, json_load, yaml_dump, yaml_load
 from jsonargparse._optionals import (
     docstring_parser_support,
     fsspec_support,
+    jsonnet_support,
     jsonschema_support,
     pyyaml_available,
     set_docstring_parse_options,
+    toml_load_available,
     url_support,
 )
 
@@ -34,7 +36,7 @@ columns_env = {"COLUMNS": "200"}
 is_cpython = platform.python_implementation() == "CPython"
 is_posix = os.name == "posix"
 
-json_or_yaml_dump = yaml_dump if pyyaml_available else json_dump
+json_or_yaml_dump = yaml_dump if pyyaml_available else json_compact_dump
 json_or_yaml_load = yaml_load if pyyaml_available else json_load
 
 skip_if_no_pyyaml = pytest.mark.skipif(
@@ -97,9 +99,26 @@ else:
     responses_activate = nothing_decorator
 
 
+def parser_modes(test_function):
+    if "JSONARGPARSE_OMEGACONF_FULL_TEST" in os.environ:
+        parser_modes = ["yaml"]
+    else:
+        parser_modes = ["json"]
+        if toml_load_available:
+            parser_modes += ["toml"]
+        if pyyaml_available:
+            parser_modes += ["yaml"]
+        if jsonnet_support:
+            parser_modes += ["jsonnet"]
+    return pytest.mark.parametrize("parser", parser_modes, indirect=True)(test_function)
+
+
 @pytest.fixture
-def parser() -> ArgumentParser:
-    return ArgumentParser(exit_on_error=False)
+def parser(request) -> ArgumentParser:
+    kwargs = {}
+    if getattr(request, "param", None):
+        kwargs["parser_mode"] = request.param
+    return ArgumentParser(exit_on_error=False, **kwargs)
 
 
 @pytest.fixture
diff --git a/jsonargparse_tests/test_jsonnet.py b/jsonargparse_tests/test_jsonnet.py
index a3c1449..3f6384c 100644
--- a/jsonargparse_tests/test_jsonnet.py
+++ b/jsonargparse_tests/test_jsonnet.py
@@ -270,4 +270,4 @@ def test_action_jsonnet_init_failures():
     from jsonschema.exceptions import SchemaError
 
     pytest.raises((ValueError, SchemaError), lambda: ActionJsonnet(schema="." + json.dumps(example_schema)))
-    pytest.raises(SchemaError, lambda: ActionJsonnet(schema="."))
+    pytest.raises((ValueError, SchemaError), lambda: ActionJsonnet(schema="."))
diff --git a/jsonargparse_tests/test_jsonschema.py b/jsonargparse_tests/test_jsonschema.py
index 2e2862e..01dc393 100644
--- a/jsonargparse_tests/test_jsonschema.py
+++ b/jsonargparse_tests/test_jsonschema.py
@@ -159,4 +159,4 @@ def test_action_jsonschema_init_failures():
     from jsonschema.exceptions import SchemaError
 
     pytest.raises((ValueError, SchemaError), lambda: ActionJsonSchema(schema=":"))
-    pytest.raises(SchemaError, lambda: ActionJsonSchema(schema="."))
+    pytest.raises((ValueError, SchemaError), lambda: ActionJsonSchema(schema="."))
diff --git a/jsonargparse_tests/test_loaders_dumpers.py b/jsonargparse_tests/test_loaders_dumpers.py
index e207500..56c9e6b 100644
--- a/jsonargparse_tests/test_loaders_dumpers.py
+++ b/jsonargparse_tests/test_loaders_dumpers.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import json
 import os
 from dataclasses import dataclass
+from pathlib import Path
 from typing import List
 from unittest.mock import patch
 
@@ -11,8 +12,8 @@ import pytest
 from jsonargparse import ArgumentParser, get_loader, set_dumper, set_loader
 from jsonargparse._common import parser_context
 from jsonargparse._loaders_dumpers import load_value, loaders, yaml_dump
-from jsonargparse._optionals import omegaconf_support, pyyaml_available
-from jsonargparse_tests.conftest import json_or_yaml_dump, json_or_yaml_load, skip_if_no_pyyaml
+from jsonargparse._optionals import omegaconf_support, pyyaml_available, toml_dump_available, toml_load_available
+from jsonargparse_tests.conftest import get_parse_args_stdout, json_or_yaml_dump, json_or_yaml_load, skip_if_no_pyyaml
 
 if pyyaml_available:
     import yaml
@@ -49,46 +50,6 @@ def test_yaml_implicit_null_disabled(parser):
     assert "Foo:" == cfg.bar.init_args.x
 
 
-@pytest.mark.skipif(not omegaconf_support, reason="omegaconf package is required")
-def test_parser_mode_omegaconf_interpolation():
-    parser = ArgumentParser(parser_mode="omegaconf")
-    parser.add_argument("--server.host", type=str)
-    parser.add_argument("--server.port", type=int)
-    parser.add_argument("--client.url", type=str)
-    parser.add_argument("--config", action="config")
-
-    config = {
-        "server": {
-            "host": "localhost",
-            "port": 80,
-        },
-        "client": {
-            "url": "http://${server.host}:${server.port}/",
-        },
-    }
-    cfg = parser.parse_args([f"--config={yaml_dump(config)}"])
-    assert cfg.client.url == "http://localhost:80/"
-    assert "url: http://localhost:80/" in parser.dump(cfg)
-
-
-@pytest.mark.skipif(not omegaconf_support, reason="omegaconf package is required")
-def test_parser_mode_omegaconf_interpolation_in_subcommands(parser, subparser):
-    subparser.add_argument("--config", action="config")
-    subparser.add_argument("--source", type=str)
-    subparser.add_argument("--target", type=str)
-
-    parser.parser_mode = "omegaconf"
-    subcommands = parser.add_subcommands()
-    subcommands.add_subcommand("sub", subparser)
-
-    config = {
-        "source": "hello",
-        "target": "${source}",
-    }
-    cfg = parser.parse_args(["sub", f"--config={yaml_dump(config)}"])
-    assert cfg.sub.target == "hello"
-
-
 def test_invalid_parser_mode():
     pytest.raises(ValueError, lambda: ArgumentParser(parser_mode="invalid"))
 
@@ -130,6 +91,7 @@ def test_dump_header_invalid(parser):
         parser.dump_header = True
 
 
+@skip_if_no_pyyaml
 def test_load_value_dash():
     with parser_context(load_value_mode="yaml"):
         assert "-" == load_value("-")
@@ -173,6 +135,86 @@ def test_nested_parser_mode(parser):
     assert dump["custom"]["init_args"]["data"] == {"fn": {"key": "dumped"}}
 
 
+# toml tests
+
+
+toml_config = """
+root = "-"
+
+[group]
+child1 = 1.2
+child2 = [ 3.0, 4.5,]
+"""
+
+
+@pytest.mark.skipif(not toml_load_available, reason="tomllib or toml package is required")
+def test_toml_parse_args_config(parser, tmp_cwd):
+    parser.parser_mode = "toml"
+    config_path = Path("config.toml")
+    config_path.write_text(toml_config)
+    parser.add_argument("--cfg", action="config")
+    parser.add_argument("--root", type=str)
+    parser.add_argument("--group.child1", type=float)
+    parser.add_argument("--group.child2", type=List[float])
+    cfg = parser.parse_args([f"--cfg={config_path}"])
+    assert cfg.root == "-"
+    assert cfg.group.as_dict() == {"child1": 1.2, "child2": [3.0, 4.5]}
+
+
+@pytest.mark.skipif(not toml_dump_available, reason="toml package is required")
+def test_toml_print_config(parser):
+    parser.parser_mode = "toml"
+    parser.add_argument("--config", action="config")
+    parser.add_argument("--root", type=str, default="-")
+    parser.add_argument("--group.child1", type=float, default=1.2)
+    parser.add_argument("--group.child2", type=List[float], default=[3.0, 4.5])
+    out = get_parse_args_stdout(parser, ["--print_config"])
+    assert out.strip() == toml_config.strip()
+
+
+# omegaconf tests
+
+
+@pytest.mark.skipif(not omegaconf_support, reason="omegaconf package is required")
+def test_parser_mode_omegaconf_interpolation():
+    parser = ArgumentParser(parser_mode="omegaconf")
+    parser.add_argument("--server.host", type=str)
+    parser.add_argument("--server.port", type=int)
+    parser.add_argument("--client.url", type=str)
+    parser.add_argument("--config", action="config")
+
+    config = {
+        "server": {
+            "host": "localhost",
+            "port": 80,
+        },
+        "client": {
+            "url": "http://${server.host}:${server.port}/",
+        },
+    }
+    cfg = parser.parse_args([f"--config={yaml_dump(config)}"])
+    assert cfg.client.url == "http://localhost:80/"
+    assert "url: http://localhost:80/" in parser.dump(cfg)
+
+
+@pytest.mark.skipif(not omegaconf_support, reason="omegaconf package is required")
+def test_parser_mode_omegaconf_interpolation_in_subcommands(parser, subparser):
+    subparser.add_argument("--config", action="config")
+    subparser.add_argument("--source", type=str)
+    subparser.add_argument("--target", type=str)
+
+    parser.parser_mode = "omegaconf"
+    subcommands = parser.add_subcommands()
+    subcommands.add_subcommand("sub", subparser)
+
+    config = {
+        "source": "hello",
+        "target": "${source}",
+    }
+    cfg = parser.parse_args(["sub", f"--config={yaml_dump(config)}"])
+    assert cfg.sub.target == "hello"
+
+
 @pytest.mark.skipif(
     not (omegaconf_support and "JSONARGPARSE_OMEGACONF_FULL_TEST" in os.environ),
     reason="only for omegaconf as the yaml loader",
diff --git a/jsonargparse_tests/test_parameter_resolvers.py b/jsonargparse_tests/test_parameter_resolvers.py
index f894eae..368e58f 100644
--- a/jsonargparse_tests/test_parameter_resolvers.py
+++ b/jsonargparse_tests/test_parameter_resolvers.py
@@ -612,7 +612,7 @@ def test_get_params_class_with_kwargs_in_dict_attribute():
 )
 def test_get_params_local_import_with_kwargs_in_dict_attribute(cls):
     params = get_params(cls)
-    assert ["mode", "loader_fn", "exceptions"] == [p.name for p in params]
+    assert ["mode", "loader_fn", "exceptions", "json_superset"] == [p.name for p in params]
     with source_unavailable():
         assert get_params(cls) == []
 
@@ -840,7 +840,7 @@ def test_get_params_function_module_class():
 
 def test_get_params_function_local_import():
     params = get_params(function_local_import)
-    assert ["mode", "loader_fn", "exceptions"] == [p.name for p in params]
+    assert ["mode", "loader_fn", "exceptions", "json_superset"] == [p.name for p in params]
 
 
 def test_get_params_function_constant_boolean():
diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py
index 47fe856..aa39a8d 100644
--- a/jsonargparse_tests/test_signatures.py
+++ b/jsonargparse_tests/test_signatures.py
@@ -350,9 +350,8 @@ def test_add_class_group_config(parser, tmp_cwd):
 
 def test_add_class_group_config_not_found(parser, tmp_cwd):
     parser.add_class_arguments(Class0, "a")
-    with pytest.raises(ArgumentError) as ctx:
+    with pytest.raises(ArgumentError, match="Unable to load config .does_not_exist.yaml"):
         parser.parse_args(["--a=does_not_exist.yaml"])
-    ctx.match('Unable to load config "does_not_exist.yaml"')
 
 
 class WithDocstring:
diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py
index 771d5de..1bdf5e4 100644
--- a/jsonargparse_tests/test_subclasses.py
+++ b/jsonargparse_tests/test_subclasses.py
@@ -1819,11 +1819,11 @@ def test_subclass_error_indentation_invalid_init_arg(parser):
         """
     Parser key "val":
       Does not validate against any of the Union subtypes
-      Subtypes: (<class 'int'>, <class 'dict'>, <class 'NoneType'>)
+      Subtypes: [<class 'NoneType'>, <class 'int'>, <class 'dict'>]
       Errors:
+        - Expected a <class 'NoneType'>
         - Expected a <class 'int'>
         - Expected a <class 'dict'>
-        - Expected a <class 'NoneType'>
       Given value type: <class 'str'>
       Given value: abc
     """
diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py
index 16ae809..539ee48 100644
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -58,6 +58,7 @@ from jsonargparse_tests.conftest import (
     get_parser_help,
     json_or_yaml_dump,
     json_or_yaml_load,
+    parser_modes,
 )
 
 
@@ -76,15 +77,17 @@ def test_namespace_unsupported_as_type(parser, typehint):
 # basic types tests
 
 
+@parser_modes
 def test_str_no_strip(parser):
     parser.add_argument("--op", type=Optional[str])
-    parser.add_argument("--cfg", action="config")
     assert "  " == parser.parse_args(["--op", "  "]).op
     assert "" == parser.parse_args(["--op", ""]).op
     assert " abc " == parser.parse_args(["--op= abc "]).op
     assert "xyz: " == parser.parse_args(["--op=xyz: "]).op
-    assert " " == parser.parse_args(['--cfg={"op":" "}']).op
     assert None is parser.parse_args(["--op=null"]).op
+    if parser.parser_mode != "toml":
+        parser.add_argument("--cfg", action="config")
+        assert " " == parser.parse_args(['--cfg={"op":" "}']).op
 
 
 @pytest.mark.parametrize("value", ["2022-04-12", "2022-04-32"])
@@ -93,12 +96,14 @@ def test_str_not_timestamp(parser, value):
     assert value == parser.parse_args([value]).foo
 
 
+@parser_modes
 @pytest.mark.parametrize("value", ["1", "02", "3.40", "5.7e-8"])
 def test_str_number_value(parser, value):
     parser.add_argument("--val", type=str)
     assert value == parser.parse_args([f"--val={value}"]).val
 
 
+@parser_modes
 def test_str_yaml_constructor_error(parser):
     parser.add_argument("--val", type=str)
     assert "{{something}}" == parser.parse_args(["--val={{something}}"]).val
@@ -124,6 +129,7 @@ def test_bool_not_a_number(parser, num_type):
             parser.parse_object({"num": value})
 
 
+@parser_modes
 def test_float_scientific_notation(parser):
     parser.add_argument("--num", type=float)
     assert 1e-3 == parser.parse_args(["--num=1e-3"]).num
@@ -136,6 +142,7 @@ def test_complex_number(parser):
     assert json_or_yaml_load(parser.dump(cfg)) == {"complex": "(2+3j)"}
 
 
+@parser_modes
 def test_literal(parser):
     parser.add_argument("--str", type=Literal["a", "b", None])
     parser.add_argument("--int", type=Literal[3, 4])
@@ -162,6 +169,7 @@ def test_union_of_literals(parser):
     assert 2 == parser.parse_args(["--literal=2"]).literal
 
 
+@parser_modes
 def test_type_any(parser):
     parser.add_argument("--any", type=Any)
     assert "abc" == parser.parse_args(["--any=abc"]).any
@@ -215,6 +223,7 @@ class EnumABC(Enum):
     C = 3
 
 
+@parser_modes
 def test_enum_parse(parser):
     parser.add_argument("--enum", type=EnumABC)
     for val in ["A", "B", "C"]:
@@ -270,6 +279,7 @@ def test_literal_enum_values(parser):
 # set tests
 
 
+@parser_modes
 def test_set(parser):
     parser.add_argument("--set", type=Set[int])
     assert {1, 2} == parser.parse_args(["--set=[1, 2]"]).set
@@ -281,6 +291,7 @@ def test_set(parser):
 # tuple tests
 
 
+@parser_modes
 def test_tuple_without_arg(parser):
     parser.add_argument("--tuple", type=tuple)
     cfg = parser.parse_args(['--tuple=[1, "a", true]'])
@@ -289,12 +300,14 @@ def test_tuple_without_arg(parser):
     assert "--tuple [ITEM,...] (type: tuple, default: null)" in help_str
 
 
+@parser_modes
 def test_tuples_nested(parser):
     parser.add_argument("--tuple", type=Tuple[Tuple[str, str], Tuple[Tuple[int, float], Tuple[int, float]]])
     cfg = parser.parse_args(['--tuple=[["foo", "bar"], [[1, 2.02], [3, 3.09]]]'])
     assert (("foo", "bar"), ((1, 2.02), (3, 3.09))) == cfg.tuple
 
 
+@parser_modes
 def test_tuple_ellipsis(parser):
     parser.add_argument("--tuple", type=Tuple[float, ...])
     assert (1.2,) == parser.parse_args(["--tuple=[1.2]"]).tuple
@@ -325,6 +338,7 @@ def test_tuple_union(parser, tmp_cwd):
 # list tests
 
 
+@parser_modes
 @pytest.mark.parametrize("list_type", [Iterable, List, Sequence], ids=str)
 def test_list_variants(parser, list_type):
     parser.add_argument("--list", type=list_type[int])
@@ -345,6 +359,7 @@ def test_list_enum(parser):
     assert [EnumABC.B, EnumABC.A] == parser.parse_args(['--list=["B", "A"]']).list
 
 
+@parser_modes
 def test_list_tuple(parser):
     parser.add_argument("--list", type=List[Tuple[int, float]])
     cfg = parser.parse_args(["--list=[[1, 2.02], [3, 3.09]]"])
@@ -365,6 +380,7 @@ def test_list_str_positional(parser):
     assert cfg.list == ["a", "b"]
 
 
+@parser_modes
 def test_sequence_default_tuple(parser):
     parser.add_argument("--seq", type=Sequence[str], default=("one", "two"))
     cfg = parser.parse_args([])
@@ -374,6 +390,7 @@ def test_sequence_default_tuple(parser):
 # list append tests
 
 
+@parser_modes
 def test_list_append(parser):
     parser.add_argument("--val", type=Union[int, float, List[int]])
     assert 0 == parser.parse_args(["--val=0"]).val
@@ -385,6 +402,7 @@ def test_list_append(parser):
     pytest.raises(ArgumentError, lambda: parser.parse_args(["--val=a", "--val+=1"]))
 
 
+@parser_modes
 def test_list_append_default_empty(parser):
     parser.add_argument("--list", type=List[str], default=[])
     assert [] == parser.get_defaults().list
@@ -394,6 +412,7 @@ def test_list_append_default_empty(parser):
     assert [] == parser.get_defaults().list
 
 
+@parser_modes
 def test_list_append_config(parser):
     parser.add_argument("--cfg", action="config")
     parser.add_argument("--val", type=List[int], default=[1, 2])
@@ -468,6 +487,7 @@ def test_list_append_subcommand_subparser_default_config_files(parser, subparser
 # dict tests
 
 
+@parser_modes
 def test_dict_without_arg(parser):
     parser.add_argument("--dict", type=dict)
     assert {} == parser.parse_args(["--dict={}"])["dict"]
@@ -475,6 +495,7 @@ def test_dict_without_arg(parser):
     pytest.raises(ArgumentError, lambda: parser.parse_args(["--dict=1"]))
 
 
+@parser_modes
 def test_dict_int_keys(parser):
     parser.add_argument("--d", type=Dict[int, str])
     parser.add_argument("--cfg", action="config")
@@ -514,12 +535,14 @@ def test_dict_command_line_set_items_with_space(parser):
     assert {"a": "x y"} == cfg.dict
 
 
+@parser_modes
 def test_mapping_nested_without_args(parser):
     parser.add_argument("--map", type=Mapping[str, Union[int, Mapping]])
     assert {"a": 1} == parser.parse_args(['--map={"a": 1}']).map
     assert {"b": {"c": 2}} == parser.parse_args(['--map={"b": {"c": 2}}']).map
 
 
+@parser_modes
 def test_typeddict_without_arg(parser):
     parser.add_argument("--typeddict", type=TypedDict("MyDict", {}))
     assert {} == parser.parse_args(["--typeddict={}"])["typeddict"]
@@ -565,7 +588,7 @@ def test_typeddict_with_args_ntotal(parser):
 
 
 @pytest.mark.skipif(not NotRequired, reason="NotRequired introduced in python 3.11 or backported in typing_extensions")
-def test_not_required_support(parser):
+def test_not_required_support():
     assert ActionTypeHint.is_supported_typehint(NotRequired[Any])
 
 
@@ -592,7 +615,7 @@ def test_typeddict_with_not_required_arg(parser):
 
 
 @pytest.mark.skipif(not Required, reason="Required introduced in python 3.11 or backported in typing_extensions")
-def test_required_support(parser):
+def test_required_support():
     assert ActionTypeHint.is_supported_typehint(Required[Any])