Repository: omni-us/jsonargparse

Issue:
Linked arguments are not validated correctly on config dump
## 🕰️ Regression report

Starting from v4.37.0 (including the latest v.4.38.0), saving a config using ArgumentParser.save triggers an internal validation error when linked arguments are used.

### To reproduce

Using LightningCLI, follow the tutorial on argument linking.
https://lightning.ai/docs/pytorch/stable/cli/lightning_cli_expert.html#argument-linking

The issue happens when the SaveConfigCallback of LightningCLI tries to save the configuration. It is not triggered when using parse_args (the config is parsed correctly and the objects such as the model, loggers, datamodules, ... are correctly instantiated).

Version v4.36.0 works correctly when using the very same code.

Investigating a bit I found that in v4.36.0 the "target" value is correctly found in the ` val` and `ccfg` variables of [this part of the code](https://github.com/omni-us/jsonargparse/blob/b7eed539f71b6c1be3be40d3d1148af2d28e8dec/jsonargparse/_core.py#L1134), while the target value is not present in those vars starting from version v4.37.0.

### Prior behavior

Version v4.36.0 works correctly and SaveConfigCallback is able to save the configuration correctly.

### Env info
PyTorch Lightning version: 2.5.1 (pip install)
jsonargparse: affected versions are v4.37/38.0 (pip install), v.36.0 works correctly
Python: 3.12.9 (conda-forge)

### Stack trace (v4.38.0)

<details>
```
Traceback (most recent call last):
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 838, in adapt_typehints
    vals.append(adapt_typehints(val, subtype, **adapt_kwargs))
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 787, in adapt_typehints
    raise_unexpected_value(f"Expected a {typehint}", val)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 718, in raise_unexpected_value
    raise ValueError(message) from exception
ValueError: Expected a <class 'NoneType'>. Got value: Namespace(class_path='lightning_data_modules.AIGenBenchPipeline', init_args=Namespace(dataset_loader=Namespace(class_path='lightning_data_modules.datasets.AIGenBenchDatasetLoader', init_args=Namespace(dataset_path='/home/lorenzo/deepfake_fast/deepfake_benchmark_raw/')), train_batch_size=512, eval_batch_size=512, num_workers=8, train_subset_size=0.2, validation_subset_size=0.2, move_data_to_local_storage=False, deterministic_augmentations=True, augmentation_factor=4, augmentations_base_seed=4321, maximum_processing_size=1080))

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 597, in _check_type
    raise ex
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 582, in _check_type
    val = adapt_typehints(val, self._typehint, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 846, in adapt_typehints
    raise_union_unexpected_value(sorted_subtypes, val, vals)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 725, in raise_union_unexpected_value
    raise ValueError(
ValueError: Does not validate against any of the Union subtypes
Subtypes: [<class 'NoneType'>, <class 'lightning.pytorch.core.datamodule.LightningDataModule'>]
Errors:
  - Expected a <class 'NoneType'>
  - Problem with given class_path 'lightning_data_modules.AIGenBenchPipeline':
      Validation failed: Key "sliding_windows_definition.benchmark_type" is required but not included in config object or its value is None.
Given value type: <class 'jsonargparse._namespace.Namespace'>
Given value: Namespace(class_path='lightning_data_modules.AIGenBenchPipeline', init_args=Namespace(dataset_loader=Namespace(class_path='lightning_data_modules.datasets.AIGenBenchDatasetLoader', init_args=Namespace(dataset_path='/home/lorenzo/deepfake_fast/deepfake_benchmark_raw/')), train_batch_size=512, eval_batch_size=512, num_workers=8, train_subset_size=0.2, validation_subset_size=0.2, move_data_to_local_storage=False, deterministic_augmentations=True, augmentation_factor=4, augmentations_base_seed=4321, maximum_processing_size=1080))

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_core.py", line 1147, in validate
    check_values(cfg)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_core.py", line 1134, in check_values
    raise ex
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_core.py", line 1129, in check_values
    self._check_value_key(action, val, key, ccfg)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_core.py", line 1424, in _check_value_key
    value = action._check_type_(value, cfg=cfg)  # type: ignore[attr-defined]
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_common.py", line 333, in _check_type_
    return self._check_type(value, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_typehints.py", line 610, in _check_type
    raise TypeError(f'Parser key "{self.dest}"{elem}:\n{error}') from ex
TypeError: Parser key "data":
  Does not validate against any of the Union subtypes
  Subtypes: [<class 'NoneType'>, <class 'lightning.pytorch.core.datamodule.LightningDataModule'>]
  Errors:
    - Expected a <class 'NoneType'>
    - Problem with given class_path 'lightning_data_modules.AIGenBenchPipeline':
        Validation failed: Key "sliding_windows_definition.benchmark_type" is required but not included in config object or its value is None.
  Given value type: <class 'jsonargparse._namespace.Namespace'>
  Given value: Namespace(class_path='lightning_data_modules.AIGenBenchPipeline', init_args=Namespace(dataset_loader=Namespace(class_path='lightning_data_modules.datasets.AIGenBenchDatasetLoader', init_args=Namespace(dataset_path='/home/lorenzo/deepfake_fast/deepfake_benchmark_raw/')), train_batch_size=512, eval_batch_size=512, num_workers=8, train_subset_size=0.2, validation_subset_size=0.2, move_data_to_local_storage=False, deterministic_augmentations=True, augmentation_factor=4, augmentations_base_seed=4321, maximum_processing_size=1080))

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/lorenzo/Desktop/VCS/deepfake-benchmark/training_and_evaluation/lightning_main.py", line 215, in <module>
    CheckpointableTrainingSlidingWindows()()
  File "/home/lorenzo/Desktop/VCS/deepfake-benchmark/training_and_evaluation/lightning_main.py", line 111, in __call__
    cli._proceed_with_subcommand()
  File "/home/lorenzo/Desktop/VCS/deepfake-benchmark/training_and_evaluation/training_utils/lightning_cli_sliding_windows.py", line 45, in _proceed_with_subcommand
    return self._run_subcommand(self.subcommand)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/Desktop/VCS/deepfake-benchmark/training_and_evaluation/training_utils/lightning_cli_sliding_windows.py", line 39, in _run_subcommand
    return super()._run_subcommand(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/cli.py", line 708, in _run_subcommand
    fn(**fn_kwargs)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/trainer/trainer.py", line 561, in fit
    call._call_and_handle_interrupt(
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/trainer/call.py", line 48, in _call_and_handle_interrupt
    return trainer_fn(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/trainer/trainer.py", line 599, in _fit_impl
    self._run(model, ckpt_path=ckpt_path)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/trainer/trainer.py", line 974, in _run
    call._call_setup_hook(self)  # allow user to set up LightningModule in accelerator environment
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/trainer/call.py", line 108, in _call_setup_hook
    _call_callback_hooks(trainer, "setup", stage=fn)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/trainer/call.py", line 227, in _call_callback_hooks
    fn(trainer, trainer.lightning_module, *args, **kwargs)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/lightning/pytorch/cli.py", line 275, in setup
    self.parser.save(
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_deprecated.py", line 159, in patched_save
    return self._unpatched_save(cfg, *args, multifile=multifile, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_core.py", line 911, in save
    f.write(self.dump(cfg, **dump_kwargs))  # type: ignore[arg-type]
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_deprecated.py", line 150, in patched_dump
    return self._unpatched_dump(cfg, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_core.py", line 792, in dump
    self.validate(cfg)
  File "/home/lorenzo/miniconda3/envs/deepfake_benchmark_cu124/lib/python3.12/site-packages/jsonargparse/_core.py", line 1155, in validate
    raise type(ex)(message) from ex
TypeError: Parser key "data":
  Does not validate against any of the Union subtypes
  Subtypes: [<class 'NoneType'>, <class 'lightning.pytorch.core.datamodule.LightningDataModule'>]
  Errors:
    - Expected a <class 'NoneType'>
    - Problem with given class_path 'lightning_data_modules.AIGenBenchPipeline':
        Validation failed: Key "sliding_windows_definition.benchmark_type" is required but not included in config object or its value is None.
  Given value type: <class 'jsonargparse._namespace.Namespace'>
  Given value: Namespace(class_path='lightning_data_modules.AIGenBenchPipeline', init_args=Namespace(dataset_loader=Namespace(class_path='lightning_data_modules.datasets.AIGenBenchDatasetLoader', init_args=Namespace(dataset_path='/home/lorenzo/deepfake_fast/deepfake_benchmark_raw/')), train_batch_size=512, eval_batch_size=512, num_workers=8, train_subset_size=0.2, validation_subset_size=0.2, move_data_to_local_storage=False, deterministic_augmentations=True, augmentation_factor=4, augmentations_base_seed=4321, maximum_processing_size=1080))
```
</details>

Discussion:
@mauvilsa: Thank you for reporting! I will take a look.@mauvilsa: Both in pytorch-lightning and in jsonargparse there are unit test that have linked arguments and dump the configuration, e.g. [tests_pytorch/test_cli.py#L574-L601](https://github.com/Lightning-AI/pytorch-lightning/blob/69d8fa9b95261da552a42fc3bbde36d6de452e99/tests/tests_pytorch/test_cli.py#L574-L601) and [test_link_arguments.py#L203-L210](https://github.com/omni-us/jsonargparse/blob/b7eed539f71b6c1be3be40d3d1148af2d28e8dec/jsonargparse_tests/test_link_arguments.py#L203-L210). These tests don't fail. So if this is failing for you, then it should be some case specific to your code. Without more details it is difficult to know what is going on. @lrzpellegrini please provide a minimal code snippet that reproduces the issue.@lrzpellegrini: Hi @mauvilsa thank you for the reply. I uploaded a slimmed down version of my codebase here: https://github.com/lrzpellegrini/jsonargparse_issue_714
I think that the issue is related to the root-level dataclass `experiment_info`.

Note that, being it ultra-slimmed down codebase, there are a few arguments/config keys that are not used, but are used in my codebase (like `experiment_info`).


I also included my conda env definition and a script to reproduce the error!@mauvilsa: Created pull request #717 with a fix. Please test it out to be sure that it actually fixes your problem.@lrzpellegrini: Hi, sorry for the delay. I can confirm this fixes the issue. 
I also tested the latest commit in main branch and it works.

Thank you!

Solution patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0b1ebe30..25a077f5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -19,6 +19,9 @@ Fixed
 ^^^^^
 - ``set_parsing_settings(validate_defaults=True)`` fails when the parser has a
   config action (`#718 <https://github.com/omni-us/jsonargparse/pull/718>`__).
+- Regression causing dump/save to fail when ``skip_link_targets=True`` and target
+  being an entire required dataclass (`#717
+  <https://github.com/omni-us/jsonargparse/pull/717>`__).
 
 
 v4.39.0 (2025-04-29)
diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py
index 26141446..6c3f252a 100644
--- a/jsonargparse/_core.py
+++ b/jsonargparse/_core.py
@@ -786,13 +786,14 @@ def dump(
         check_valid_dump_format(format)
 
         cfg = strip_meta(cfg)
-        if skip_link_targets:
-            ActionLink.strip_link_target_keys(self, cfg)
 
         with parser_context(load_value_mode=self.parser_mode):
             if not skip_validation:
                 self.validate(cfg)
 
+            if skip_link_targets:
+                ActionLink.strip_link_target_keys(self, cfg)
+
             dump_kwargs = {"skip_validation": skip_validation, "skip_none": skip_none}
             self._dump_cleanup_actions(cfg, self._actions, dump_kwargs)
 
@@ -826,7 +827,7 @@ def _dump_cleanup_actions(self, cfg, actions, dump_kwargs, prefix=""):
             if isinstance(action, ActionTypeHint):
                 value = cfg.get(action_dest)
                 if value is not None:
-                    with parser_context(parent_parser=self):
+                    with parser_context(parent_parser=self, lenient_check=True):
                         if dump_kwargs.get("skip_validation"):
                             with suppress(ValueError):
                                 value = action.serialize(value, dump_kwargs=dump_kwargs)
@@ -914,12 +915,13 @@ def check_overwrite(path):
 
         else:
             cfg = cfg.clone()
-            ActionLink.strip_link_target_keys(self, cfg)
 
             if not skip_validation:
                 with parser_context(load_value_mode=self.parser_mode):
                     self.validate(strip_meta(cfg), branch=branch)
 
+            ActionLink.strip_link_target_keys(self, cfg)
+
             def save_paths(cfg):
                 for key in cfg.get_sorted_keys():
                     val = cfg[key]


Test patch:
diff --git a/jsonargparse_tests/test_link_arguments.py b/jsonargparse_tests/test_link_arguments.py
index a0e4361f..0be7cf06 100644
--- a/jsonargparse_tests/test_link_arguments.py
+++ b/jsonargparse_tests/test_link_arguments.py
@@ -442,6 +442,21 @@ def test_on_parse_save_required_target_subclass_param(parser, tmp_cwd):
     assert saved == {"a": {"a": 1}, "c": {"class_path": f"{__name__}.RequiredTargetC", "init_args": {}}}
 
 
+def test_on_parse_save_required_target_entire_dataclass(parser, tmp_cwd):
+    parser.add_class_arguments(RequiredTargetA, "a")
+    parser.add_subclass_arguments(RequiredTargetC, "c")
+    parser.link_arguments("a", "c.init_args.b")
+    cfg = parser.parse_args(["--a.a=1", f"--c={__name__}.RequiredTargetC"])
+    expected = {"a": {"a": 1}, "c": {"class_path": f"{__name__}.RequiredTargetC"}}
+    parser.save(cfg, "config.yaml", multifile=False)
+    saved = json_or_yaml_load((tmp_cwd / "config.yaml").read_text())
+    assert saved == expected
+    (tmp_cwd / "config.yaml").unlink()
+    parser.save(cfg, "config.yaml", multifile=True)
+    saved = json_or_yaml_load((tmp_cwd / "config.yaml").read_text())
+    assert saved == expected
+
+
 class Optimizer:
     def __init__(self, params: List[int], lr: float):
         self.params = params