Repository: omni-us/jsonargparse

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.

Hints from issue comments:
@mauvilsa: Thank you for the interest. Since tomllib was added in python 3.11, lately I have though about adding the support for it in jsonargparse. Some comments:

- Current versions of jsonargparse has [custom-loaders](https://jsonargparse.readthedocs.io/en/stable/#custom-loaders), which means that TOML support can already be added by whoever wants it. This does no mean that it would be later added to jsonargparse for more convenience.
- Even though tomllib is now in the standard library, it only supports parsing (`load` and `loads`). For TOML to make sense in jsonargparse, it would be necessary to output TOML (e.g. so that `--print_config` works). Therefore, TOML support requires adding dependencies, and would only be an optional feature.
- If TOML support were to be added, would it be only for python >= 3.11 or for all python versions that jsonargparse supports?
- I also work on machine learning which is why jsonargparse even though generic, it does have many features useful for it. However, for machine learning very commonly leads to several levels of nesting in YAML, e.g. [LightningCLI](https://pytorch-lightning.readthedocs.io/en/latest/cli/lightning_cli_advanced_3.html). My impression has been that with such depth of nesting and use of lists, TOML is much less intuitive than YAML. I have though about TOML support but for simpler use cases.@mauvilsa: @haoxiangsnr, @mihaic there is now pull request #660 implementing toml support. You can try it out.

Solution patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a7b10085..f45b4b11 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 0c3153f2..d19d4030 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 61c7ea00..db56715d 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 9a650593..e3f1d697 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 f9f10098..cdbc569e 100644
--- a/jsonargparse/_core.py
+++ b/jsonargparse/_core.py
@@ -682,8 +682,6 @@ def _load_config_parser_mode(
             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 @@ def get_defaults(self, skip_validation: bool = False, **kwargs) -> Namespace:
 
         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 9d76d241..6358e9a8 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 @@
 ]
 
 
+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 remove_implicit_resolver(cls, tag_to_remove):
     )
 
     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 @@ def json_indented_dump(data):
     "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 fb0dacc7..6a02143a 100644
--- a/jsonargparse/_optionals.py
+++ b/jsonargparse/_optionals.py
@@ -15,6 +15,8 @@
 
 
 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 6cf4ee23..5fb98bd1 100644
--- a/jsonargparse/_parameter_resolvers.py
+++ b/jsonargparse/_parameter_resolvers.py
@@ -29,7 +29,6 @@
 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 bd6b2f71..b87b42cc 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -176,6 +176,7 @@ def _capture_typing_extension_shadows(name: str, *collections) -> None:
     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 @@ def _check_type(self, value, append=False, cfg=None):
                 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 partial_instance(*args):
     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 4d3c0104..6d1c031c 100644
--- a/jsonargparse/_util.py
+++ b/jsonargparse/_util.py
@@ -37,7 +37,7 @@
     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 1eb0bdae..2f7767f5 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 b40ea108..76bdfd2f 100644
--- a/jsonargparse_tests/conftest.py
+++ b/jsonargparse_tests/conftest.py
@@ -13,13 +13,15 @@
 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 @@
 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 @@ def nothing_decorator(func):
     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 a3c1449f..3f6384ce 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 2e2862e0..01dc3931 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 e207500b..56c9e6b6 100644
--- a/jsonargparse_tests/test_loaders_dumpers.py
+++ b/jsonargparse_tests/test_loaders_dumpers.py
@@ -3,6 +3,7 @@
 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 @@
 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 f894eaeb..368e58fe 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 47fe8563..aa39a8d6 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 771d5ded..1bdf5e4d 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 16ae8097..539ee482 100644
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -58,6 +58,7 @@
     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])