Repository: omni-us/jsonargparse

Issue:
Parser mode is not inherited by subparsers when adapting typehints
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

The parsing mode is not passed to subparsers created in [`ActionTypeHint.get_class_parser`](https://github.com/omni-us/jsonargparse/blob/54ff57e6f22c3502fc478238714e8b8ba646fe01/jsonargparse/_typehints.py#L611), which can manifest in bugs such as a `yaml.representer.RepresenterError` when the subparser encounters a key that requires a non-default parsing mode.

The following script provides a simple contrived example that demonstrates the bug wherein the custom parsing mode loads arbitrary constants (such as functions) identified with the key `"constant_path"` analogous to `"class_path"`.
Whereas the custom dumper would convert the function object into a `{"constant_path": path.to.function}` dictionary, the default dumper used by the subparser crashes as it doesn't know how to handle the raw function object.

I believe that this can be fixed by simply passing `parser_mode=parser.parser_mode` on [this line](https://github.com/omni-us/jsonargparse/blob/54ff57e6f22c3502fc478238714e8b8ba646fe01/jsonargparse/_typehints.py#L618) of `ActionTypeHint.get_class_parser`.
In the meantime, a workaround is simply to override the default `"yaml"` parser mode with your own with `jsonargparse.set_loader`/`set_dumper`.

### To reproduce

```python
import inspect
import tempfile
import typing
from argparse import Namespace
from dataclasses import dataclass, field
from typing import Any, TypeVar

import jsonargparse
import yaml
from jsonargparse import ActionConfigFile, ArgumentParser, lazy_instance
from jsonargparse._loaders_dumpers import yaml_dump, yaml_load
from jsonargparse._util import import_object

_T = TypeVar('_T')

_VALUE_PATH = 'constant_path'



####################### Set up custom parser mode #######################


def is_constant_spec(value: Any) -> bool:
    is_constant = isinstance(value, (dict, Namespace)) and _VALUE_PATH in value
    if is_constant:
        allowed_keys = {
            _VALUE_PATH,
        }
        keys = getattr(value, "__dict__", value).keys()
        is_constant = len(set(keys) - allowed_keys) == 0
    return is_constant


def try_import_value(config: _T | dict[str, Any] | Namespace) -> _T | Any:
    if is_constant_spec(config):
        assert isinstance(config, (dict, Namespace))
        config = typing.cast(
            dict[str,
                 Any],
            getattr(config,
                    "__dict__",
                    config))
        return import_object(config[_VALUE_PATH])
    else:
        return config


def _yaml_constant_load(config: Any) -> Any:
    if is_constant_spec(config):
        return try_import_value(config)
    elif isinstance(config, (dict, Namespace)):
        config = typing.cast(
            dict[str,
                 Any],
            getattr(config,
                    "__dict__",
                    config))
        return {
            k: _yaml_constant_load(v) for k,
            v in config.items()
        }
    elif isinstance(config, (list, set, tuple)):
        return type(config)(_yaml_constant_load(v) for v in config)
    else:
        return config


def yaml_constant_load(stream: str) -> Any:
    value = yaml_load(stream)  # type: ignore[no-untyped-call]
    return _yaml_constant_load(value)


def _yaml_constant_dump(config: Any) -> Any:
    if inspect.isfunction(config):
        return {
            "constant_path": f"{config.__module__}.{config.__name__}"
        }
    elif isinstance(config, (dict, Namespace)):
        config = typing.cast(
            dict[str,
                 Any],
            getattr(config,
                    "__dict__",
                    config))
        return {
            k: _yaml_constant_dump(v) for k,
            v in config.items()
        }
    elif isinstance(config, (list, set, tuple)):
        return type(config)(_yaml_constant_dump(v) for v in config)
    else:
        return config


def yaml_constant_dump(config: Any) -> str:
    config = _yaml_constant_dump(config)
    return typing.cast(str, yaml_dump(config))  # type: ignore


jsonargparse.set_loader('constant_load', yaml_constant_load)
jsonargparse.set_dumper("constant_load", yaml_constant_dump)

################################ MWE ###################################


@dataclass
class TestInner:

    inner_kwargs: dict[str, Any] | None = None


@dataclass
class Test:

    tests: list[TestInner] = field(default_factory=lambda: [])


def foo() -> int:
    return 42


parser = ArgumentParser(exit_on_error=False, parser_mode='constant_load')
parser.add_argument(
    "-c",
    "--config",
    action=ActionConfigFile,
    help="Path to a configuration file in json or yaml format.")
parser.add_class_arguments(
    Test,
    "test",
    fail_untyped=False,
    instantiate=True,
    sub_configs=True,
    default=lazy_instance(
        Test,
    ),
)
# parser.add_argument("--config", action=ActionConfigFile)

config = yaml.safe_dump(
    {
        "test":
            {
                "tests":
                    [
                        {
                            "inner_kwargs":
                                {
                                    "function":
                                        {
                                            "constant_path": f'{__name__}.foo'
                                        }
                                }
                        }
                    ]
            }
    })

with tempfile.NamedTemporaryFile("w", suffix=".yaml") as f:
    f.write(config)
    f.flush()
    cfg = parser.parse_args(["--config", f"{f.name}"])

print(parser.dump(cfg, skip_link_targets=False, skip_none=False))
```

### Expected behavior

The script should print the following:
```yaml
test:
  tests:
  - inner_kwargs:
      function:
        constant_path: __main__.foo
```

### Actual behavior

`yaml.representer.RepresenterError: ('cannot represent an object', <function foo at 0x7f5ddc21c400>)`

<details>
<summary>Traceback</summary>

```python
Traceback (most recent call last):
  File ".../lib/python3.11/runpy.py", line 198, in _run_module_as_main
    return _run_code(code, main_globals, None,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/runpy.py", line 88, in _run_code
    exec(code, run_globals)
  File ".../.vscode-server/extensions/ms-python.debugpy-2024.10.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/__main__.py", line 39, in <module>
    cli.main()
  File ".../.vscode-server/extensions/ms-python.debugpy-2024.10.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 430, in main
    run()
  File ".../.vscode-server/extensions/ms-python.debugpy-2024.10.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 284, in run_file
    runpy.run_path(target, run_name="__main__")
  File ".../.vscode-server/extensions/ms-python.debugpy-2024.10.0-linux-x64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 321, in run_path
    return _run_module_code(code, init_globals, run_name,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../.vscode-server/extensions/ms-python.debugpy-2024.10.0-linux-x64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 135, in _run_module_code
    _run_code(code, mod_globals, init_globals,
  File ".../.vscode-server/extensions/ms-python.debugpy-2024.10.0-linux-x64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 124, in _run_code
    exec(code, run_globals)
  File ".../scripts/test_jsonargparse.py", line 161, in <module>
    print(parser.dump(cfg, skip_link_targets=False, skip_none=False))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_deprecated.py", line 150, in patched_dump
    return self._unpatched_dump(cfg, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_core.py", line 730, in dump
    self._dump_cleanup_actions(cfg, self._actions, dump_kwargs)
  File ".../lib/python3.11/site-packages/jsonargparse/_core.py", line 763, in _dump_cleanup_actions
    value = action.serialize(value, dump_kwargs=dump_kwargs)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_typehints.py", line 497, in serialize
    return adapt_typehints(
           ^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_typehints.py", line 878, in adapt_typehints
    val[n] = adapt_typehints(v, subtypehints[0], list_item=True, **adapt_kwargs_n)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_typehints.py", line 987, in adapt_typehints
    val = load_value(parser.dump(val, **dump_kwargs.get()))
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_deprecated.py", line 150, in patched_dump
    return self._unpatched_dump(cfg, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_core.py", line 741, in dump
    return dump_using_format(self, cfg_dict, "yaml_comments" if yaml_comments else format)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_loaders_dumpers.py", line 179, in dump_using_format
    dump = dumpers[dump_format](*args)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/jsonargparse/_loaders_dumpers.py", line 134, in yaml_dump
    return yaml.safe_dump(data, **dump_yaml_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/__init__.py", line 269, in safe_dump
    return dump_all([data], stream, Dumper=SafeDumper, **kwds)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/__init__.py", line 241, in dump_all
    dumper.represent(data)
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 27, in represent
    node = self.represent_data(data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 48, in represent_data
    node = self.yaml_representers[data_types[0]](self, data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 207, in represent_dict
    return self.represent_mapping('tag:yaml.org,2002:map', data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 118, in represent_mapping
    node_value = self.represent_data(item_value)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 48, in represent_data
    node = self.yaml_representers[data_types[0]](self, data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 207, in represent_dict
    return self.represent_mapping('tag:yaml.org,2002:map', data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 118, in represent_mapping
    node_value = self.represent_data(item_value)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 58, in represent_data
    node = self.yaml_representers[None](self, data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.11/site-packages/yaml/representer.py", line 231, in represent_undefined
    raise RepresenterError("cannot represent an object", data)
yaml.representer.RepresenterError: ('cannot represent an object', <function foo at 0x7f5ddc21c400>)
```
</details>

### Environment

<!-- Fill in the list below. -->

- jsonargparse version: 4.28.0
- Python version: 3.11.6
- How jsonargparse was installed: `pip`
- OS: Ubuntu 20.04


Discussion:
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=564) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=564&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=564&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=564&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=564&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=564&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=564)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=564) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=564&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=564&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=564&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=564&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=564&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=564)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 28d4c38..614018b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -23,6 +23,8 @@ Fixed
   <https://github.com/omni-us/jsonargparse/pull/560>`__).
 - ``--print_shtab`` not adding file completer for ``_ActionConfigLoad`` (`#562
   <https://github.com/omni-us/jsonargparse/pull/562>`__).
+- ``parser_mode`` not inherited by nested parsers (`#564
+  <https://github.com/omni-us/jsonargparse/pull/564>`__).
 
 
 v4.32.0 (2024-07-19)
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index 98b79c3..aeaaad2 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -615,7 +615,7 @@ class ActionTypeHint(Action):
         if skip_args:
             kwargs.setdefault("skip", set()).add(skip_args)
         parser = parent_parser.get()
-        parser = type(parser)(exit_on_error=False, logger=parser.logger)
+        parser = type(parser)(exit_on_error=False, logger=parser.logger, parser_mode=parser.parser_mode)
         remove_actions(parser, (ActionConfigFile, _ActionPrintConfig))
         if inspect.isclass(val_class):
             parser.add_class_arguments(val_class, **kwargs)


Test patch:
diff --git a/jsonargparse_tests/test_loaders_dumpers.py b/jsonargparse_tests/test_loaders_dumpers.py
index bbeedfc..692a473 100644
--- a/jsonargparse_tests/test_loaders_dumpers.py
+++ b/jsonargparse_tests/test_loaders_dumpers.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+from dataclasses import dataclass
 from typing import List
 from unittest.mock import patch
 
@@ -129,6 +130,39 @@ def test_load_value_dash():
         assert " -  " == load_value(" -  ")
 
 
+@dataclass
+class CustomData:
+    fn: dict
+
+
+class CustomContainer:
+    def __init__(self, data: CustomData):
+        self.data = data
+
+
+def custom_loader(data):
+    data = yaml.safe_load(data)
+    if isinstance(data, dict) and "fn" in data:
+        data["fn"] = {k: custom_loader for k in data["fn"]}
+    return data
+
+
+def custom_dumper(data):
+    if "data" in data and "fn" in data["data"]:
+        data["data"]["fn"] = {k: "dumped" for k in data["data"]["fn"]}
+    return yaml_dump(data)
+
+
+def test_nested_parser_mode(parser):
+    set_loader("custom", custom_loader)
+    set_dumper("custom", custom_dumper)
+    parser.parser_mode = "custom"
+    parser.add_argument("--custom", type=CustomContainer)
+    cfg = parser.parse_args(['--custom.data={"fn": {"key": "value"}}'])
+    dump = yaml.safe_load(parser.dump(cfg))
+    assert dump["custom"]["init_args"]["data"] == {"fn": {"key": "dumped"}}
+
+
 @pytest.mark.skipif(
     not (omegaconf_support and "JSONARGPARSE_OMEGACONF_FULL_TEST" in os.environ),
     reason="only for omegaconf as the yaml loader",