Repository: omni-us/jsonargparse

Issue:
Cannot pass saving check when linking to required value of dataclass parameter of subclass argument
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

<!-- A clear and concise description of the bug. -->

### To reproduce

```python
from dataclasses import dataclass

from jsonargparse import ArgumentParser

class A:
    def __init__(self, a: int):
        pass

@dataclass
class B:
    a: int

class C:
    def __init__(self, b: B):
        pass

def main():
    parser = ArgumentParser()
    parser.add_class_arguments(A, 'a')
    parser.add_subclass_arguments(C, 'c')
    parser.link_arguments('a.a', 'c.init_args.b.a')
    args = parser.parse_args(['--a.a', '1', '--c', '__main__.C'])
    parser.save(args, 'test.yaml', overwrite=True)

if __name__ == '__main__':
    main()
```

### Expected behavior

The config is saved normally.

### Actual Result

```
Traceback (most recent call last):
  File ".../jsonargparse/_core.py", line 1025, in check_required
    val = cfg[reqkey]
          ~~~^^^^^^^^
  File ".../jsonargparse/_namespace.py", line 211, in __getitem__
    leaf_key, parent_ns, _ = self._parse_required_key(key)
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_namespace.py", line 172, in _parse_required_key
    raise KeyError(f'Key "{key}" not found in namespace.')
KeyError: 'Key "b.a" not found in namespace.'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File ".../jsonargparse/_core.py", line 1068, in check_config
    check_required(cfg, self)
  File ".../jsonargparse/_core.py", line 1029, in check_required
    raise TypeError(
TypeError: Key "b.a" is required but not included in config object or its value is None.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File ".../jsonargparse/_core.py", line 436, in parse_object
    parsed_cfg = self._parse_common(
                 ^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_core.py", line 314, in _parse_common
    self.check_config(cfg, skip_required=skip_required)
  File ".../jsonargparse/_core.py", line 1076, in check_config
    raise type(ex)(message) from ex
TypeError: Configuration check failed :: Key "b.a" is required but not included in config object or its value is None.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File ".../jsonargparse/_typehints.py", line 828, in adapt_typehints
    val = adapt_class_type(val, serialize, instantiate_classes, sub_add_kwargs, prev_val=prev_val)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_typehints.py", line 1066, in adapt_class_type
    init_args = parser.parse_object(init_args, cfg_base=prev_init_args, defaults=sub_defaults.get())
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_deprecated.py", line 122, in patched_parse
    cfg = parse_method(*args, _skip_check=_skip_check, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_core.py", line 446, in parse_object
    self.error(str(ex), ex)
  File ".../jsonargparse/_core.py", line 988, in error
    raise argument_error(message) from ex
argparse.ArgumentError: Configuration check failed :: Key "b.a" is required but not included in config object or its value is None.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File ".../jsonargparse/_typehints.py", line 474, in _check_type
    raise ex
  File ".../jsonargparse/_typehints.py", line 462, in _check_type
    val = adapt_typehints(val, self._typehint, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_typehints.py", line 832, in adapt_typehints
    raise_unexpected_value(f"Problem with given class_path {class_path!r}:\n{error}", exception=ex)
  File ".../jsonargparse/_typehints.py", line 549, in raise_unexpected_value
    raise ValueError(message) from exception
ValueError: Problem with given class_path '__main__.C':
  Configuration check failed :: Key "b.a" is required but not included in config object or its value is None.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File ".../jsonargparse/_core.py", line 1070, in check_config
    check_values(cfg)
  File ".../jsonargparse/_core.py", line 1060, in check_values
    raise ex
  File ".../jsonargparse/_core.py", line 1055, in check_values
    self._check_value_key(action, val, key, ccfg)
  File ".../jsonargparse/_core.py", line 1296, in _check_value_key
    value = action._check_type(value, cfg=cfg)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_typehints.py", line 484, in _check_type
    raise TypeError(f'Parser key "{self.dest}"{elem}:\n{error}') from ex
TypeError: Parser key "c":
  Problem with given class_path '__main__.C':
    Configuration check failed :: Key "b.a" is required but not included in config object or its value is None.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test.py", line 26, in <module>
    main()
  File "test.py", line 23, in main
    parser.save(args, 'test.yaml', overwrite=True, multifile=True)
  File ".../jsonargparse/_deprecated.py", line 158, in patched_save
    return self._unpatched_save(cfg, *args, multifile=multifile, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../jsonargparse/_core.py", line 822, in save
    self.check_config(strip_meta(cfg), branch=branch)
  File ".../jsonargparse/_core.py", line 1076, in check_config
    raise type(ex)(message) from ex
TypeError: Parser key "c":
  Problem with given class_path '__main__.C':
    Configuration check failed :: Key "b.a" is required but not included in config object or its value is None.
```

### Environment

<!-- Fill in the list below. -->

- jsonargparse version: 4.22.1
- Python version: 3.11.4
- How jsonargparse was installed: `pip install jsonargparse[signatures]`
- OS: Ubuntu 22.04


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/333?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
Patch coverage: **`100.00%`** and project coverage change: **`-0.04%`** :warning:
> Comparison is base [(`0e6b180`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/0e6b18070e108598bd1b51889c93e34fe62a6d2f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 100.00% compared to head [(`a4ddb47`)](https://app.codecov.io/gh/omni-us/jsonargparse/pull/333?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 99.96%.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #333      +/-   ##
===========================================
- Coverage   100.00%   99.96%   -0.04%     
===========================================
  Files           21       21              
  Lines         5537     5541       +4     
===========================================
+ Hits          5537     5539       +2     
- Misses           0        2       +2     
```

| Flag | Coverage Δ | |
|---|---|---|
| py3.10 | `86.57% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.10_all | `98.33% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.10_types | `98.33% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11 | `86.55% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11_all | `98.32% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11_types | `98.32% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.6 | `85.98% <100.00%> (+0.01%)` | :arrow_up: |
| py3.6_all | `97.81% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7 | `87.97% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7_all | `99.47% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7_types | `99.45% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8 | `88.14% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8_all | `99.51% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8_types | `99.49% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9 | `88.07% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9_all | `?` | |
| py3.9_types | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files Changed](https://app.codecov.io/gh/omni-us/jsonargparse/pull/333?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [jsonargparse/\_typehints.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/333?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL190eXBlaGludHMucHk=) | `99.76% <100.00%> (-0.24%)` | :arrow_down: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/333?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=333)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=333&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=333) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=333&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=333&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 24e2e48..75e8529 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,15 @@ The semantic versioning only considers the public API as described in
 paths are considered internals and can change in minor and patch releases.
 
 
+v4.23.1 (2023-07-??)
+--------------------
+
+Fixed
+^^^^^
+- ``save`` fails when a link target is a required parameter nested in a subclass
+  (`#332 <https://github.com/omni-us/jsonargparse/issues/332>`__).
+
+
 v4.23.0 (2023-07-27)
 --------------------
 
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index 233a68a..ee4f7be 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -509,6 +509,10 @@ class ActionTypeHint(Action):
         parser = type(parser)(exit_on_error=False, logger=parser.logger)
         remove_actions(parser, (ActionConfigFile, _ActionPrintConfig))
         parser.add_class_arguments(val_class, **kwargs)
+        if "linked_targets" in kwargs and parser.required_args:
+            for key in kwargs["linked_targets"]:
+                if key in parser.required_args:
+                    parser.required_args.remove(key)
         return parser
 
     def extra_help(self):


Test patch:
diff --git a/jsonargparse_tests/test_link_arguments.py b/jsonargparse_tests/test_link_arguments.py
index 4f6f2b7..6d7e300 100644
--- a/jsonargparse_tests/test_link_arguments.py
+++ b/jsonargparse_tests/test_link_arguments.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from calendar import Calendar, TextCalendar
+from dataclasses import dataclass
 from typing import Any, List, Mapping, Optional, Union
 
 import pytest
@@ -292,6 +293,31 @@ def test_on_parse_within_subcommand(parser, subparser):
     assert isinstance(init["cmd"]["foo"], Foo)
 
 
+class RequiredTargetA:
+    def __init__(self, a: int):
+        pass
+
+
+@dataclass
+class RequiredTargetB:
+    a: int
+
+
+class RequiredTargetC:
+    def __init__(self, b: RequiredTargetB):
+        pass
+
+
+def test_on_parse_save_required_target_subclass_param(parser, tmp_cwd):
+    parser.add_class_arguments(RequiredTargetA, "a")
+    parser.add_subclass_arguments(RequiredTargetC, "c")
+    parser.link_arguments("a.a", "c.init_args.b.a")
+    cfg = parser.parse_args(["--a.a=1", f"--c={__name__}.RequiredTargetC"])
+    parser.save(cfg, "config.yaml")
+    saved = yaml.safe_load((tmp_cwd / "config.yaml").read_text())
+    assert saved == {"a": {"a": 1}, "c": {"class_path": f"{__name__}.RequiredTargetC", "init_args": {}}}
+
+
 # tests for links applied on instantiate