Repository: omni-us/jsonargparse

Issue:
Support of `TypedDict`s
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->
Hi, 
Thanks for this great library! Extremely useful!!

## 🚀 Feature request

<!-- A clear and concise description of the feature proposal. -->
The idea here would be to support arguments with type `typing.TypedDict`.

### Motivation

<!--
Please outline the motivation for the proposal. Is your feature request related
to a problem? e.g., I'm always frustrated when [...]. If this is related to
another GitHub issue, please link here.
-->

This feature would be great when we want to get a dictionary instead of a namespace but still want to check the keys and types of its elements. 

This could also allow checking the elements in `**kwargs`: https://peps.python.org/pep-0692/

### Pitch

<!-- Description of what you want to happen. -->
For example, if a function calls another function, we could then pass arguments to the second one very easily:
```python
# script.py
from typing import TypedDict
from jsonargparse import CLI

def aux(a: int = 1):
    print(a)

AuxKwargs = TypedDict("AuxKwargs", aux.__annotation__)

def main(aux_kwargs: AuxKwargs):
    aux(**aux_kwargs)

CLI(main)
```

Then we would be able to call:
```bash
python script.py --aux_kwargs.a=2
```

### Alternatives

<!-- Any alternative solutions or features you've considered, if any. -->
Currently, the only alternative for such cases is to use the `dict` type, which prevents from checking the key names and types individually.
In the above example, the current alternative is:
```python
def main(aux_kwargs: dict):
    aux(**aux_kwargs)
```


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=494) **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=494&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=494&metric=new_accepted_issues&view=list)

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=494&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=494&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=494)


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=494) **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=494&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=494&metric=new_accepted_issues&view=list)

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=494&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=494&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=494)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494?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 [(`af37f08`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/af37f088f257a184f1f9e77812539d8a2e584276?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`2ccf3f0`)](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494?dropdown=coverage&src=pr&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      #494   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           21        21           
  Lines         6102      6116   +14     
=========================================
+ Hits          6102      6116   +14     
```

| [Flag](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/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/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.23% <100.00%> (+0.02%)` | :arrow_up: |
| [py3.10_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.64% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.10_pydantic1](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `49.06% <31.25%> (-0.05%)` | :arrow_down: |
| [py3.10_pydantic2](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `48.83% <31.25%> (-0.05%)` | :arrow_down: |
| [py3.10_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.65% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.11](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.21% <100.00%> (+0.02%)` | :arrow_up: |
| [py3.11_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.64% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.11_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.65% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.12](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.36% <100.00%> (+0.02%)` | :arrow_up: |
| [py3.12_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.64% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.12_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.7](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.77% <31.25%> (-0.14%)` | :arrow_down: |
| [py3.7_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.25% <31.25%> (-0.17%)` | :arrow_down: |
| [py3.7_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.25% <31.25%> (-0.17%)` | :arrow_down: |
| [py3.8](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.94% <93.75%> (+0.02%)` | :arrow_up: |
| [py3.8_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.41% <93.75%> (+<0.01%)` | :arrow_up: |
| [py3.8_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.41% <93.75%> (+<0.01%)` | :arrow_up: |
| [py3.9](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.78% <93.75%> (+0.02%)` | :arrow_up: |
| [py3.9_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.28% <93.75%> (+<0.01%)` | :arrow_up: |
| [py3.9_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/494/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.29% <93.75%> (+<0.01%)` | :arrow_up: |

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/494?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).

PierreGtch: Ready for review :)
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=494) **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=494&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=494&metric=new_accepted_issues&view=list)

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=494&resolved=false&inNewCodePeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](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=494&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=494)


PierreGtch: All done @mauvilsa :)

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2e9eedc..9d729d7 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.29.0 (2024-04-??)
+--------------------
+
+Added
+^^^^^
+- Support for ``TypedDict`` (`#457
+  <https://github.com/omni-us/jsonargparse/issues/457>`__).
+
+
 v4.28.0 (2024-04-17)
 --------------------
 
diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst
index 2e053da..f402745 100644
--- a/DOCUMENTATION.rst
+++ b/DOCUMENTATION.rst
@@ -400,8 +400,8 @@ Some notes about this support are:
   :ref:`restricted-strings` and paths and URLs as explained in sections
   :ref:`parsing-paths` and :ref:`parsing-urls`.
 
-- ``Dict``, ``Mapping``, and ``MutableMapping`` are supported but only with
-  ``str`` or ``int`` keys. For more details see :ref:`dict-items`.
+- ``Dict``, ``Mapping``, ``MutableMapping``, and ``TypedDict`` are supported but
+  only with ``str`` or ``int`` keys. For more details see :ref:`dict-items`.
 
 - ``Tuple``, ``Set`` and ``MutableSet`` are supported even though they can't be
   represented in json distinguishable from a list. Each ``Tuple`` element
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index 7fe36f9..1108946 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -820,6 +820,16 @@ def adapt_typehints(
                 else:
                     kwargs = adapt_kwargs
                 val[k] = adapt_typehints(v, subtypehints[1], **kwargs)
+        if get_import_path(typehint.__class__) == "typing._TypedDictMeta":
+            if typehint.__total__:
+                missing_keys = typehint.__annotations__.keys() - val.keys()
+                if missing_keys:
+                    raise_unexpected_value(f"Missing required keys: {missing_keys}", val)
+            extra_keys = val.keys() - typehint.__annotations__.keys()
+            if extra_keys:
+                raise_unexpected_value(f"Unexpected keys: {extra_keys}", val)
+            for k, v in val.items():
+                val[k] = adapt_typehints(v, typehint.__annotations__[k], **adapt_kwargs)
 
     # Callable
     elif typehint_origin in callable_origin_types or typehint in callable_origin_types:
diff --git a/jsonargparse/_util.py b/jsonargparse/_util.py
index 6e1ab6b..9176791 100644
--- a/jsonargparse/_util.py
+++ b/jsonargparse/_util.py
@@ -262,8 +262,12 @@ def object_path_serializer(value):
 
 
 def get_typehint_origin(typehint):
-    if not hasattr(typehint, "__origin__") and get_import_path(typehint.__class__) == "types.UnionType":
-        return Union
+    if not hasattr(typehint, "__origin__"):
+        typehint_class = get_import_path(typehint.__class__)
+        if typehint_class == "types.UnionType":
+            return Union
+        if typehint_class == "typing._TypedDictMeta":
+            return dict
     return getattr(typehint, "__origin__", None)
 
 


Test patch:
diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py
index bd0f818..2e05d84 100644
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -22,6 +22,9 @@ from typing import (
     Type,
     Union,
 )
+
+if sys.version_info >= (3, 8):
+    from typing import TypedDict
 from unittest import mock
 from warnings import catch_warnings
 
@@ -489,6 +492,53 @@ def test_mapping_nested_without_args(parser):
     assert {"b": {"c": 2}} == parser.parse_args(['--map={"b": {"c": 2}}']).map
 
 
+@pytest.mark.skipif(sys.version_info < (3, 8), reason="TypedDict introduced in python 3.8")
+def test_typeddict_without_arg(parser):
+    parser.add_argument("--typeddict", type=TypedDict("MyDict", {}))
+    assert {} == parser.parse_args(["--typeddict={}"])["typeddict"]
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--typeddict={"a":1}'])
+    ctx.match("Unexpected keys")
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(["--typeddict=1"])
+    ctx.match("Expected a <class 'dict'>")
+
+
+@pytest.mark.skipif(sys.version_info < (3, 8), reason="TypedDict introduced in python 3.8")
+def test_typeddict_with_args(parser):
+    parser.add_argument("--typeddict", type=TypedDict("MyDict", {"a": int}))
+    assert {"a": 1} == parser.parse_args(["--typeddict={'a': 1}"])["typeddict"]
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--typeddict={"a":1, "b":2}'])
+    ctx.match("Unexpected keys")
+    pytest.raises(ArgumentError, lambda: parser.parse_args(['--typeddict={"a":1, "b":2}']))
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(["--typeddict={}"])
+    ctx.match("Missing required keys")
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--typeddict={"a":"x"}'])
+    ctx.match("Expected a <class 'int'>")
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(["--typeddict=1"])
+    ctx.match("Expected a <class 'dict'>")
+
+
+@pytest.mark.skipif(sys.version_info < (3, 8), reason="TypedDict introduced in python 3.8")
+def test_typeddict_with_args_ntotal(parser):
+    parser.add_argument("--typeddict", type=TypedDict("MyDict", {"a": int}, total=False))
+    assert {"a": 1} == parser.parse_args(["--typeddict={'a': 1}"])["typeddict"]
+    assert {} == parser.parse_args(["--typeddict={}"])["typeddict"]
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--typeddict={"a":1, "b":2}'])
+    ctx.match("Unexpected keys")
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(['--typeddict={"a":"x"}'])
+    ctx.match("Expected a <class 'int'>")
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args(["--typeddict=1"])
+    ctx.match("Expected a <class 'dict'>")
+
+
 # union tests