Repository: omni-us/jsonargparse
Pull request #587 (2024-10-02 05:16:30)

Local full test coverage with tox
## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/main/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [n/a] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [n/a] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


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


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587?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 [(`a8b8b15`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/a8b8b151ab26db57cae94c6f5de9c618daf4971b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`41d3e04`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/41d3e04fd1c925158325b96e3f17132bd442f871?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      #587   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           22        22           
  Lines         6451      6451           
=========================================
  Hits          6451      6451           
```

| [Flag](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/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/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `85.92% <ø> (ø)` | |
| [py3.10_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.85% <ø> (ø)` | |
| [py3.10_pydantic1](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `49.15% <ø> (ø)` | |
| [py3.10_pydantic2](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `48.93% <ø> (ø)` | |
| [py3.10_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.86% <ø> (ø)` | |
| [py3.11](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `85.90% <ø> (ø)` | |
| [py3.11_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.82% <ø> (ø)` | |
| [py3.11_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.83% <ø> (ø)` | |
| [py3.12](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.04% <ø> (ø)` | |
| [py3.12_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.82% <ø> (ø)` | |
| [py3.12_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.83% <ø> (ø)` | |
| [py3.8](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.55% <ø> (ø)` | |
| [py3.8_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.48% <ø> (ø)` | |
| [py3.8_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.51% <ø> (ø)` | |
| [py3.9](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.48% <ø> (ø)` | |
| [py3.9_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.44% <ø> (ø)` | |
| [py3.9_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/587/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.45% <ø> (ø)` | |

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


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


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



Patch:
diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml
new file mode 100644
index 0000000..9b77834
--- /dev/null
+++ b/.github/workflows/manual.yml
@@ -0,0 +1,24 @@
+name: manual
+
+on:
+  workflow_dispatch:
+
+jobs:
+
+  tox-coverage-windows:
+    runs-on: windows-2019
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        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 b39e037..ebcefff 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,20 @@ The semantic versioning only considers the public API as described in
 paths are considered internals and can change in minor and patch releases.
 
 
+v4.34.0 (2024-10-??)
+--------------------
+
+Added
+^^^^^
+- Support for PEP 692 (i.e., ``Unpack[TypedDict]`` annotations for ``**kwargs``)
+  (`#586 <https://github.com/omni-us/jsonargparse/pull/586>`)
+
+Fixed
+^^^^^
+- Empty tuples are now parsed correctly instead of raising an error.
+  (`#592 <https://github.com/omni-us/jsonargparse/pull/592`__).
+
+
 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/DOCUMENTATION.rst b/DOCUMENTATION.rst
index d8e57b7..8f0bcc4 100644
--- a/DOCUMENTATION.rst
+++ b/DOCUMENTATION.rst
@@ -404,6 +404,8 @@ Some notes about this support are:
   ``OrderedDict``, and ``TypedDict`` are supported but only with ``str`` or
   ``int`` keys. ``Required`` and ``NotRequired`` are also supported for
   fine-grained specification of required/optional ``TypedDict`` keys.
+  ``Unpack`` is supported with ``TypedDict`` for more precise ``**kwargs``
+  typing as described in PEP `692 <https://peps.python.org/pep-0692/>`__.
   For more details see :ref:`dict-items`.
 
 - ``Tuple``, ``Set`` and ``MutableSet`` are supported even though they can't be
diff --git a/jsonargparse/_common.py b/jsonargparse/_common.py
index c552a78..f4e99ba 100644
--- a/jsonargparse/_common.py
+++ b/jsonargparse/_common.py
@@ -20,12 +20,14 @@ from typing import (  # type: ignore[attr-defined]
 
 from ._namespace import Namespace
 from ._optionals import (
+    capture_typing_extension_shadows,
     get_alias_target,
     get_annotated_base_type,
     import_reconplogger,
     is_alias_type,
     is_annotated,
     reconplogger_support,
+    typing_extensions_import,
 )
 from ._type_checking import ArgumentParser
 
@@ -36,6 +38,13 @@ __all__ = [
 
 ClassType = TypeVar("ClassType")
 
+_UnpackGenericAlias = typing_extensions_import("_UnpackAlias")
+
+unpack_meta_types = set()
+if _UnpackGenericAlias:
+    unpack_meta_types.add(_UnpackGenericAlias)
+capture_typing_extension_shadows(_UnpackGenericAlias, "_UnpackGenericAlias", unpack_meta_types)
+
 
 class InstantiatorCallable(Protocol):
     def __call__(self, class_type: Type[ClassType], *args, **kwargs) -> ClassType:
@@ -96,6 +105,10 @@ def is_generic_class(cls) -> bool:
     return isinstance(cls, _GenericAlias) and getattr(cls, "__module__", "") != "typing"
 
 
+def is_unpack_typehint(cls) -> bool:
+    return any(isinstance(cls, unpack_type) for unpack_type in unpack_meta_types)
+
+
 def get_generic_origin(cls):
     return cls.__origin__ if is_generic_class(cls) else cls
 
diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py
index 2bee852..0f137bc 100644
--- a/jsonargparse/_core.py
+++ b/jsonargparse/_core.py
@@ -1337,6 +1337,7 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
         with parser_context(parent_parser=self):
             ActionTypeHint.discard_init_args_on_class_path_change(self, cfg_to, cfg_from)
         ActionTypeHint.delete_init_args_required_none(cfg_from, cfg_to)
+        ActionTypeHint.delete_not_required_args(cfg_from, cfg_to)
         cfg_to.update(cfg_from)
         ActionTypeHint.apply_appends(self, cfg_to)
         return cfg_to
diff --git a/jsonargparse/_optionals.py b/jsonargparse/_optionals.py
index 27ad4f5..e1f2f01 100644
--- a/jsonargparse/_optionals.py
+++ b/jsonargparse/_optionals.py
@@ -40,6 +40,17 @@ def typing_extensions_import(name):
         return getattr(__import__("typing"), name, False)
 
 
+def capture_typing_extension_shadows(typehint, name: str, *collections) -> None:
+    """
+    Ensure different origins for types in typing_extensions are captured.
+    """
+    if (typehint is False or getattr(typehint, "__module__", None) == "typing_extensions") and hasattr(
+        __import__("typing"), name
+    ):
+        for collection in collections:
+            collection.add(getattr(__import__("typing"), name))
+
+
 def final(cls):
     """Decorator to make a class ``final``, i.e., it shouldn't be subclassed.
 
diff --git a/jsonargparse/_parameter_resolvers.py b/jsonargparse/_parameter_resolvers.py
index 81ffd5c..616b8d3 100644
--- a/jsonargparse/_parameter_resolvers.py
+++ b/jsonargparse/_parameter_resolvers.py
@@ -20,6 +20,7 @@ from ._common import (
     is_dataclass_like,
     is_generic_class,
     is_subclass,
+    is_unpack_typehint,
     parse_logger,
 )
 from ._optionals import get_annotated_base_type, is_annotated, is_pydantic_model, parse_docs
@@ -28,6 +29,7 @@ from ._stubs_resolver import get_stub_types
 from ._util import (
     ClassFromFunctionBase,
     get_import_path,
+    get_typehint_args,
     get_typehint_origin,
     iter_to_set_str,
     unique,
@@ -318,6 +320,35 @@ def replace_generic_type_vars(params: ParamList, parent) -> None:
             param.annotation = replace_type_vars(param.annotation)
 
 
+def unpack_typed_dict_kwargs(params: ParamList, kwargs_idx: int) -> int:
+    kwargs = params[kwargs_idx]
+    annotation = kwargs.annotation
+    if is_unpack_typehint(annotation):
+        params.pop(kwargs_idx)
+        annotation_args = get_typehint_args(annotation)
+        assert len(annotation_args) == 1, "Unpack requires a single type argument"
+        dict_annotations = annotation_args[0].__annotations__
+        new_params = []
+        for nm, annot in dict_annotations.items():
+            new_params.append(
+                ParamData(
+                    name=nm,
+                    annotation=annot,
+                    default=inspect._empty,
+                    kind=inspect._ParameterKind.KEYWORD_ONLY,
+                    doc=None,
+                    component=kwargs.component,
+                    parent=kwargs.parent,
+                    origin=kwargs.origin,
+                )
+            )
+        # insert in-place
+        assert kwargs_idx == len(params), "trailing params should yield a syntax error"
+        params.extend(new_params)
+        return -1
+    return kwargs_idx
+
+
 def add_stub_types(stubs: Optional[Dict[str, Any]], params: ParamList, component) -> None:
     if not stubs:
         return
@@ -838,6 +869,8 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
             self.component, self.parent, self.logger
         )
         self.replace_param_default_subclass_specs(params)
+        if kwargs_idx >= 0:
+            kwargs_idx = unpack_typed_dict_kwargs(params, kwargs_idx)
         if args_idx >= 0 or kwargs_idx >= 0:
             self.doc_params = doc_params
             with mro_context(self.parent):
diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py
index 807a8d4..7d75e19 100644
--- a/jsonargparse/_signatures.py
+++ b/jsonargparse/_signatures.py
@@ -29,8 +29,9 @@ from ._typehints import (
     callable_instances,
     get_subclass_names,
     is_optional,
+    not_required_types,
 )
-from ._util import NoneType, get_private_kwargs, iter_to_set_str
+from ._util import NoneType, get_private_kwargs, get_typehint_origin, iter_to_set_str
 from .typing import register_pydantic_type
 
 __all__ = [
@@ -322,7 +323,7 @@ class SignatureArguments(LoggerProperty):
             default = param.default
             if default == inspect_empty and is_optional(annotation):
                 default = None
-        is_required = default == inspect_empty
+        is_required = default == inspect_empty and get_typehint_origin(annotation) not in not_required_types
         src = get_parameter_origins(param.component, param.parent)
         skip_message = f'Skipping parameter "{name}" from "{src}" because of: '
         if not fail_untyped and annotation == inspect_empty:
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index 22204f8..aab043d 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -62,6 +62,7 @@ from ._loaders_dumpers import (
 )
 from ._namespace import Namespace
 from ._optionals import (
+    capture_typing_extension_shadows,
     get_alias_target,
     is_alias_type,
     is_annotated,
@@ -93,6 +94,7 @@ __all__ = ["lazy_instance"]
 NotRequired = typing_extensions_import("NotRequired")
 Required = typing_extensions_import("Required")
 _TypedDictMeta = typing_extensions_import("_TypedDictMeta")
+Unpack = typing_extensions_import("Unpack")
 
 
 def _capture_typing_extension_shadows(name: str, *collections) -> None:
@@ -101,9 +103,7 @@ def _capture_typing_extension_shadows(name: str, *collections) -> None:
     """
     current_module = sys.modules[__name__]
     typehint = getattr(current_module, name)
-    if getattr(typehint, "__module__", None) == "typing_extensions" and hasattr(__import__("typing"), name):
-        for collection in collections:
-            collection.add(getattr(__import__("typing"), name))
+    return capture_typing_extension_shadows(typehint, name, *collections)
 
 
 root_types = {
@@ -142,6 +142,7 @@ root_types = {
     abc.Callable,
     NotRequired,
     Required,
+    Unpack,
 }
 
 leaf_types = {
@@ -193,6 +194,9 @@ _capture_typing_extension_shadows("TypedDict", typed_dict_types)
 typed_dict_meta_types = {_TypedDictMeta}
 _capture_typing_extension_shadows("_TypedDictMeta", typed_dict_meta_types)
 
+unpack_types = {Unpack}
+_capture_typing_extension_shadows("Unpack", unpack_types)
+
 subclass_arg_parser: ContextVar = ContextVar("subclass_arg_parser")
 allow_default_instance: ContextVar = ContextVar("allow_default_instance", default=False)
 sub_defaults: ContextVar = ContextVar("sub_defaults", default=False)
@@ -441,6 +445,12 @@ class ActionTypeHint(Action):
                         if skip_key in parser.required_args:
                             del val.init_args[skip_key]
 
+    @staticmethod
+    def delete_not_required_args(cfg_from, cfg_to):
+        for key, val in list(cfg_to.items(branches=True)):
+            if val == inspect._empty and key not in cfg_from:
+                del cfg_to[key]
+
     @staticmethod
     @contextmanager
     def subclass_arg_context(parser):
@@ -849,8 +859,6 @@ def adapt_typehints(
             for n, v in enumerate(val):
                 subtypehint = subtypehints[0 if is_ellipsis or not is_tuple else n]
                 val[n] = adapt_typehints(v, subtypehint, **adapt_kwargs)
-            if is_tuple and len(val) == 0:
-                raise_unexpected_value("Expected a non-empty tuple", val)
         if not serialize:
             val = tuple(val) if typehint_origin in {Tuple, tuple} else set(val)
 
diff --git a/jsonargparse/_util.py b/jsonargparse/_util.py
index e97ea2a..3c3d6c7 100644
--- a/jsonargparse/_util.py
+++ b/jsonargparse/_util.py
@@ -268,6 +268,10 @@ def object_path_serializer(value):
         raise ValueError(f"Only possible to serialize an importable object, given {value}: {ex}") from ex
 
 
+def get_typehint_args(typehint):
+    return getattr(typehint, "__args__", tuple())
+
+
 def get_typehint_origin(typehint):
     if not hasattr(typehint, "__origin__"):
         typehint_class = get_import_path(typehint.__class__)
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 86c8374..332e35e 100644
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import json
 import pickle
 import random
 import sys
@@ -7,6 +8,7 @@ import time
 import uuid
 from calendar import Calendar, TextCalendar
 from collections import OrderedDict
+from dataclasses import dataclass
 from enum import Enum
 from pathlib import Path
 from types import MappingProxyType
@@ -37,6 +39,7 @@ from jsonargparse._typehints import (
     ActionTypeHint,
     NotRequired,
     Required,
+    Unpack,
     get_all_subclass_paths,
     get_subclass_types,
     is_optional,
@@ -286,7 +289,7 @@ def test_tuple_ellipsis(parser):
     parser.add_argument("--tuple", type=Tuple[float, ...])
     assert (1.2,) == parser.parse_args(["--tuple=[1.2]"]).tuple
     assert (1.2, 3.4) == parser.parse_args(["--tuple=[1.2, 3.4]"]).tuple
-    pytest.raises(ArgumentError, lambda: parser.parse_args(["--tuple=[]"]))
+    assert () == parser.parse_args(["--tuple=[]"]).tuple
     pytest.raises(ArgumentError, lambda: parser.parse_args(['--tuple=[2, "a"]']))
 
 
@@ -605,6 +608,77 @@ def test_typeddict_with_required_arg(parser):
     ctx.match("Expected a <class 'int'>")
 
 
+@pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions")
+def test_unpack_support(parser):
+    assert ActionTypeHint.is_supported_typehint(Unpack[Any])
+
+
+if Unpack:  # and Required and NotRequired
+    MyTestUnpackDict = TypedDict("MyTestUnpackDict", {"a": Required[int], "b": NotRequired[int]}, total=True)
+
+    class UnpackClass:
+
+        def __init__(self, **kwargs: Unpack[MyTestUnpackDict]) -> None:
+            self.a = kwargs["a"]
+            self.b = kwargs.get("b")
+
+    @dataclass
+    class MyTestUnpackClass:
+
+        test: UnpackClass
+
+    class MyTestInheritedUnpackClass(UnpackClass):
+
+        def __init__(self, **kwargs) -> None:
+            super().__init__(**kwargs)
+
+
+@pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions")
+@pytest.mark.parametrize(["init_args"], [({"a": 1},), ({"a": 2, "b": None},), ({"a": 3, "b": 1},)])
+def test_valid_unpack_typeddict(parser, init_args):
+    parser.add_argument("--testclass", type=MyTestUnpackClass)
+    test_config = {"test": {"class_path": f"{__name__}.UnpackClass", "init_args": init_args}}
+    cfg = parser.parse_args([f"--testclass={json.dumps(test_config)}"])
+    assert test_config == cfg["testclass"].as_dict()
+    # also assert no issues with dumping
+    if test_config["test"]["init_args"].get("b") is None:
+        # parser.dump does not dump null b
+        test_config["test"]["init_args"].pop("b", None)
+    assert json.dumps({"testclass": test_config}).replace(" ", "") == parser.dump(cfg, format="json")
+
+
+@pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions")
+@pytest.mark.parametrize(["init_args"], [({},), ({"b": None},), ({"b": 1},)])
+def test_invalid_unpack_typeddict(parser, init_args):
+    parser.add_argument("--testclass", type=MyTestUnpackClass)
+    test_config = {"test": {"class_path": f"{__name__}.UnpackClass", "init_args": init_args}}
+    with pytest.raises(ArgumentError):
+        parser.parse_args([f"--testclass={json.dumps(test_config)}"])
+
+
+@pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions")
+@pytest.mark.parametrize(["init_args"], [({"a": 1},), ({"a": 2, "b": None},), ({"a": 3, "b": 1},)])
+def test_valid_inherited_unpack_typeddict(parser, init_args):
+    parser.add_argument("--testclass", type=MyTestInheritedUnpackClass)
+    test_config = {"class_path": f"{__name__}.MyTestInheritedUnpackClass", "init_args": init_args}
+    cfg = parser.parse_args([f"--testclass={json.dumps(test_config)}"])
+    assert test_config == cfg["testclass"].as_dict()
+    # also assert no issues with dumping
+    if test_config["init_args"].get("b") is None:
+        # parser.dump does not dump null b
+        test_config["init_args"].pop("b", None)
+    assert json.dumps({"testclass": test_config}).replace(" ", "") == parser.dump(cfg, format="json")
+
+
+@pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions")
+@pytest.mark.parametrize(["init_args"], [({},), ({"b": None},), ({"b": 1},)])
+def test_invalid_inherited_unpack_typeddict(parser, init_args):
+    parser.add_argument("--testclass", type=MyTestInheritedUnpackClass)
+    test_config = {"class_path": f"{__name__}.MyTestInheritedUnpackClass", "init_args": init_args}
+    with pytest.raises(ArgumentError):
+        parser.parse_args([f"--testclass={json.dumps(test_config)}"])
+
+
 def test_mapping_proxy_type(parser):
     parser.add_argument("--mapping", type=MappingProxyType)
     cfg = parser.parse_args(['--mapping={"x":1}'])