Repository: omni-us/jsonargparse

Issue:
Totality of inherited TypedDict keys is not respected
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

<!-- A clear and concise description of the bug. -->

One more `TypedDict`-related issue: totality of `TypedDict`s is not respected when inherited, which can lead to spurious expectations of required or optional arguments by the argument parser. 

The problem is that the `TypedDict.__required_keys__` attribute is ignored by `jsonargparse`.  For additional context, see https://github.com/python/cpython/issues/83015 and https://github.com/python/cpython/pull/17214. 

I have gone ahead an opened a PR (#597) with a suggested fix.

### To reproduce

```python
from jsonargparse import ArgumentParser
import pytest

class BottomDict(TypedDict, total=True):
    a: int

class MiddleDict(BottomDict, total=False):
    b: int

class TopDict(MiddleDict, total=True):
    c: int

parser.add_argument("--middledict", type=MiddleDict, required=False)
parser.add_argument("--topdict", type=TopDict, required=False)
# the following should (but does not) raise an ArgumentError about missing required keys
with pytest.raises(ArgumentError) as ctx:
    parser.parse_args(['--middledict={}'])
ctx.match("Missing required keys")
with pytest.raises(ArgumentError) as ctx:
    assert {"b": 2} == parser.parse_args(["--middledict={'b': 2}"])["middledict"]
ctx.match("Missing required keys")
# the following should not (but does) raise an ArgumentError about missing required keys
assert {"a": 1, "c": 2} == parser.parse_args(["--topdict={'a': 1, 'c': 2}"])["topdict"]
```

<!--
Please include a code snippet that reproduces the bug and the output that
running it gives. The following snippet templates might help:

1. Using the CLI function

```python
from jsonargparse import CLI

# Here define one or more functions or classes
def func1(param1: int, ...):
    ...

# Run the CLI providing the components
CLI([func1, ...], exit_on_error=False)
```

2. Manually constructing a parser

```python
from jsonargparse import ArgumentParser

parser = ArgumentParser(exit_on_error=False)
# Here add to the parser only argument(s) relevant to the problem

# If a yaml config is required, it can be included in the same snippet as follows:
import yaml

parser.add_argument("--config", action="config")
config = yaml.safe_dump(
    {
        "key1": "val1",
    }
)

# Preferable that the command line arguments are given to the parse_args call
result = parser.parse_args([f"--config={config}", "--key2=val2", ...])

# If the problem is in the parsed result, print it to stdout
print(parser.dump(result))

# If the problem is in class instantiation
parser.instantiate_classes(result)
```
-->

### Expected behavior

<!-- Describe how the behavior or output of the reproduction snippet should be. -->

The assertions should evaluate to true.

### Environment

<!-- Fill in the list below. -->

- jsonargparse version: 4.32.1
- 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=597) **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=597&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=597&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=597&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=597&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=597&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=597)


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=597) **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=597&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=597&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=597&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=597&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=597&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=597)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`2160982`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/2160982e2be129864dd875f3c5d5bbdceaaa3d74?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`5a1c42e`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/5a1c42eda9d41769da4eadf8af018b742223b0ca?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

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


```diff
@@            Coverage Diff            @@
##              main      #597   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           22        22           
  Lines         6488      6492    +4     
=========================================
+ Hits          6488      6492    +4     
```

| [Flag](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [py3.10](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `85.76% <80.00%> (-0.03%)` | :arrow_down: |
| [py3.10_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.79% <80.00%> (-0.05%)` | :arrow_down: |
| [py3.10_pydantic1](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `49.15% <0.00%> (-0.04%)` | :arrow_down: |
| [py3.10_pydantic2](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `48.93% <0.00%> (-0.04%)` | :arrow_down: |
| [py3.10_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.81% <80.00%> (-0.05%)` | :arrow_down: |
| [py3.11](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `85.95% <80.00%> (-0.03%)` | :arrow_down: |
| [py3.11_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.75% <80.00%> (-0.05%)` | :arrow_down: |
| [py3.11_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.76% <80.00%> (-0.05%)` | :arrow_down: |
| [py3.12](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.09% <80.00%> (-0.04%)` | :arrow_down: |
| [py3.12_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.75% <80.00%> (-0.05%)` | :arrow_down: |
| [py3.12_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.76% <80.00%> (-0.05%)` | :arrow_down: |
| [py3.8](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.38% <40.00%> (-0.04%)` | :arrow_down: |
| [py3.8_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.42% <40.00%> (-0.05%)` | :arrow_down: |
| [py3.8_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.46% <40.00%> (-0.05%)` | :arrow_down: |
| [py3.9](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.31% <80.00%> (-0.03%)` | :arrow_down: |
| [py3.9_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.38% <80.00%> (-0.05%)` | :arrow_down: |
| [py3.9_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.39% <80.00%> (-0.05%)` | :arrow_down: |

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.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/597?dropdown=coverage&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]: ## [![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=597) **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=597&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=597&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=597&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=597&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=597&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=597)



Patch:
diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml
new file mode 100644
index 0000000..082dbe8
--- /dev/null
+++ b/.github/workflows/manual.yml
@@ -0,0 +1,25 @@
+name: manual
+
+on:
+  workflow_dispatch:
+
+jobs:
+
+  tox-coverage-windows:
+    runs-on: windows-2019
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: |
+            3.8
+            3.9
+            3.10
+            3.11
+            3.12
+      - run: pip install -e ".[dev,all]"
+      - run: tox -- --cov=../jsonargparse --cov-append
+      - uses: actions/upload-artifact@v4
+        with:
+          name: coverage
+          path: jsonargparse_tests/.coverage
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 35a0d8b..87cae4c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -123,29 +123,6 @@ repos:
     language: system
     pass_filenames: false
 
-  - id: test-pydantic-v1
-    name: test-pydantic-v1
-    entry: bash -c '
-      set -e;
-      TEST_DIR=$(mktemp -d -t _jsonargparse_tests_XXXXXX);
-      cleanup () { rm -rf "$TEST_DIR"; };
-      trap cleanup EXIT;
-      python -m venv "$TEST_DIR/venv";
-      . "$TEST_DIR/venv/bin/activate";
-      pip install -e ".[test]";
-      cp jsonargparse_tests/*.py "$TEST_DIR";
-      cd "$TEST_DIR";
-      pip install "pydantic<2";
-      pytest test_dataclass_like.py;
-      sed -i "s|import pydantic|import pydantic.v1 as pydantic|" test_dataclass_like.py;
-      sed -i "s|^annotated = .*|annotated = False|" test_dataclass_like.py;
-      sed -i "s|test_pydantic_types|_test_pydantic_types|" test_dataclass_like.py;
-      pip install "pydantic>=2";
-      pytest test_dataclass_like.py;'
-    stages: [pre-push]
-    language: system
-    pass_filenames: false
-
   - id: doctest
     name: sphinx-build -M doctest sphinx sphinx/_build sphinx/index.rst
     entry: bash -c '
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ebcefff..43a7208 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -24,6 +24,9 @@ Fixed
 ^^^^^
 - Empty tuples are now parsed correctly instead of raising an error.
   (`#592 <https://github.com/omni-us/jsonargparse/pull/592`__).
+- Required and optional `TypedDict` keys are now correctly inferred when one
+  inherits one `TypedDict` from another with different totality
+  (`#597 <https://github.com/omni-us/jsonargparse/pull/597>`__).
 
 
 v4.33.2 (2024-10-07)
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 92536bc..9cd32a1 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -96,7 +96,7 @@ the package, thus can be run in a production system.
     python -m jsonargparse_tests             # Run tests on installed package (requires pytest and pytest-subtests)
     pre-commit run -a --hook-stage pre-push  # Run pre-push git hooks (tests, doctests, mypy, coverage)
 
-To get a nice html test coverage report, run:
+For a nice html test coverage report, run:
 
 .. code-block:: bash
 
@@ -104,6 +104,18 @@ To get a nice html test coverage report, run:
 
 Then open the file ``htmlcov/index.html`` in a browser.
 
+To get a full coverage report, you need to install all supported python
+versions, and then:
+
+.. code-block:: bash
+
+    rm -fr jsonargparse_tests/.coverage jsonargparse_tests/htmlcov
+    tox -- --cov=../jsonargparse --cov-append
+    cd jsonargparse_tests
+    coverage html
+
+Then open the file ``jsonargparse_tests/htmlcov/index.html`` in a browser.
+
 Pull requests
 -------------
 
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index aab043d..14f095c 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -931,7 +931,22 @@ def adapt_typehints(
                         kwargs["prev_val"] = None
                 val[k] = adapt_typehints(v, subtypehints[1], **kwargs)
         if type(typehint) in typed_dict_meta_types:
-            if typehint.__total__:
+            if hasattr(typehint, "__required_keys__"):
+                required_keys = set(typehint.__required_keys__)
+                # The standard library TypedDict below Python 3.11 does not store runtime
+                # information about optional and required keys when using Required or NotRequired.
+                # Thus, capture explicitly Required keys
+                required_keys.update(
+                    {k for k, v in typehint.__annotations__.items() if get_typehint_origin(v) in required_types}
+                )
+                # And remove explicitly NotRequired keys
+                required_keys.difference_update(
+                    {k for k, v in typehint.__annotations__.items() if get_typehint_origin(v) in not_required_types}
+                )
+            # The standard library TypedDict in Python 3.8 does not store runtime information
+            # about which (if any) keys are optional. See https://bugs.python.org/issue38834.
+            # Thus, fall back to totality and explicitly Required keys
+            elif typehint.__total__:
                 required_keys = {
                     k for k, v in typehint.__annotations__.items() if get_typehint_origin(v) not in not_required_types
                 }
diff --git a/pyproject.toml b/pyproject.toml
index 01686ac..566fe4c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -190,19 +190,44 @@ Villegas = "Villegas"
 [tool.tox]
 legacy_tox_ini = """
 [tox]
-envlist = py{38,39,310,311,312,313}-{all,no}-extras,omegaconf
+envlist = py{38,39,310,311,312,313}-{all,no}-extras,omegaconf,pydantic-v1
 skip_missing_interpreters = true
 
 [testenv]
 extras =
-    all-extras: test,all
-    no-extras: test-no-urls
+    all-extras: test,coverage,all
+    no-extras: coverage
 changedir = jsonargparse_tests
-commands = python -m pytest
+commands = python -m pytest {posargs}
 usedevelop = true
 
 [testenv:omegaconf]
-extras = test,all
+extras = test,coverage,all
 setenv =
     JSONARGPARSE_OMEGACONF_FULL_TEST = true
+
+[testenv:pydantic-v1]
+extras = coverage
+commands =
+    # Test with pydantic<2
+    python -c "\
+        from shutil import copy;\
+        copy('conftest.py', '{envtmpdir}/conftest.py');\
+        copy('test_dataclass_like.py', '{envtmpdir}/test_dataclass_like.py')\
+    "
+    pip install "pydantic<2"
+    python -m pytest {posargs} {envtmpdir}/test_dataclass_like.py
+
+    # Test with pydantic>=2 importing from pydantic.v1
+    python -c "\
+        import re, pathlib;\
+        path = pathlib.Path('{envtmpdir}/test_dataclass_like.py');\
+        content = path.read_text();\
+        content = re.sub(r'import pydantic', 'import pydantic.v1 as pydantic', content);\
+        content = re.sub(r'test_pydantic_types', '_test_pydantic_types', content);\
+        content = re.sub(r'^annotated = [^\\n]*', 'annotated = False', content, flags=re.MULTILINE);\
+        path.write_text(content)\
+    "
+    pip install "pydantic>=2"
+    python -m pytest {posargs} {envtmpdir}/test_dataclass_like.py
 """


Test patch:
diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py
index 332e35e..b5dfb06 100644
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -679,6 +679,38 @@ def test_invalid_inherited_unpack_typeddict(parser, init_args):
         parser.parse_args([f"--testclass={json.dumps(test_config)}"])
 
 
+@pytest.mark.skipif(sys.version_info < (3, 9), reason="Python 3.8 lacked runtime inspection of TypedDict required keys")
+def test_typeddict_totality_inheritance(parser):
+
+    class BottomDict(TypedDict, total=True):
+        a: int
+
+    class MiddleDict(BottomDict, total=False):
+        b: int
+
+    class TopDict(MiddleDict, total=True):
+        c: int
+
+    parser.add_argument("--middledict", type=MiddleDict, required=False)
+    parser.add_argument("--topdict", type=TopDict, required=False)
+    assert {"a": 1} == parser.parse_args(["--middledict={'a': 1}"])["middledict"]
+    assert {"a": 1, "b": 2} == parser.parse_args(["--middledict={'a': 1, 'b': 2}"])["middledict"]
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(["--middledict={}"])
+    ctx.match("Missing required keys")
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--middledict={"b": 2}'])
+    ctx.match("Missing required keys")
+    assert {"a": 1, "c": 2} == parser.parse_args(["--topdict={'a': 1, 'c': 2}"])["topdict"]
+    assert {"a": 1, "b": 2, "c": 3} == parser.parse_args(["--topdict={'a': 1, 'b': 2, 'c': 3}"])["topdict"]
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--topdict={"a": 1, "b": 2}'])
+    ctx.match("Missing required keys")
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--topdict={"b":2, "c": 3}'])
+    ctx.match("Missing required keys")
+
+
 def test_mapping_proxy_type(parser):
     parser.add_argument("--mapping", type=MappingProxyType)
     cfg = parser.parse_args(['--mapping={"x":1}'])