Repository: omni-us/jsonargparse
Pull request #266 (2023-04-12 20:14:49)

Experimental support for pydantic models and attrs defines
## What does this PR do?

- Fixes dataclass from pydantic not working ([#100 comment](https://github.com/omni-us/jsonargparse/issues/100#issuecomment-1408413796)).
- Adds support for pydantic models and attr defines similar to dataclasses.

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/master/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] 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**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


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=266)

[![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=266&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=266&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266) 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=266&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=266&metric=new_duplicated_lines_density&view=list)


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=266)

[![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=266&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=266&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266) 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=266&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=266&metric=new_duplicated_lines_density&view=list)


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=266)

[![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=266&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=266&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=266&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=266&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=266&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=266&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=266) 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=266&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=266&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index c16e315..834fe48 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -15,12 +15,18 @@ paths are considered internals and can change in minor and patch releases.
 v4.21.0 (2023-04-??)
 --------------------
 
+Added
+^^^^^
+- Support for pydantic models and attr defines similar to dataclasses.
+
 Fixed
 ^^^^^
 - `str` parameter in subclass incorrectly parsed as dict with implicit `null`
   value (`#262 <https://github.com/omni-us/jsonargparse/issues/262>`__).
 - Wrong error indentation for subclass in union (`pytorch-lightning#17254
   <https://github.com/Lightning-AI/lightning/issues/17254>`__).
+- ``dataclass`` from pydantic not working (`#100 (comment)
+  <https://github.com/omni-us/jsonargparse/issues/100#issuecomment-1408413796>`__).
 
 Changed
 ^^^^^^^
@@ -30,6 +36,11 @@ Changed
   (`pytorch-lightning#17247
   <https://github.com/Lightning-AI/lightning/issues/17247>`__).
 
+Deprecated
+^^^^^^^^^^
+- Support for python 3.6 will be removed in v5.0.0. New features added in
+  future v4 releases are not guaranteed to work with python 3.6.
+
 
 v4.20.1 (2023-03-30)
 --------------------
diff --git a/README.rst b/README.rst
index aec8cd0..11fa06b 100644
--- a/README.rst
+++ b/README.rst
@@ -433,7 +433,9 @@ Some notes about this support are:
 - ``dataclasses`` are supported as a type but only for pure data classes and not
   nested in a type. By pure it is meant that the class only inherits from data
   classes. Not a mixture of normal classes and data classes. Data classes as
-  fields of other data classes is supported.
+  fields of other data classes is supported. Pydantic's ``dataclass`` decorator
+  and ``BaseModel`` classes, and attrs' ``define`` decorator are supported
+  like standard dataclasses. Though, this support is currently experimental.
 
 - To set a value to ``None`` it is required to use ``null`` since this is how
   json/yaml defines it. To avoid confusion in the help, ``NoneType`` is
diff --git a/jsonargparse/optionals.py b/jsonargparse/optionals.py
index 53fea96..e43aa3f 100644
--- a/jsonargparse/optionals.py
+++ b/jsonargparse/optionals.py
@@ -25,6 +25,8 @@ fsspec_support = find_spec('fsspec') is not None
 ruyaml_support = find_spec('ruyaml') is not None
 omegaconf_support = find_spec('omegaconf') is not None
 reconplogger_support = find_spec('reconplogger') is not None
+pydantic_support = find_spec('pydantic') is not None
+attrs_support = find_spec('attrs') is not None
 
 _config_read_mode = 'fr'
 _docstring_parse_options = {
@@ -133,6 +135,18 @@ def import_reconplogger(importer):
     return reconplogger
 
 
+def import_pydantic(importer):
+    with missing_package_raise('pydantic', importer):
+        import pydantic
+    return pydantic
+
+
+def import_attrs(importer):
+    with missing_package_raise('attrs', importer):
+        import attrs
+    return attrs
+
+
 def set_config_read_mode(
     urls_enabled: bool = False,
     fsspec_enabled: bool = False,
@@ -207,15 +221,16 @@ def parse_docstring(component, params=False, logger=None):
 
 
 def parse_docs(component, parent, logger):
-    docs = []
+    docs = {}
     if docstring_parser_support:
         doc_sources = [component]
         if inspect.isclass(parent) and component.__name__ == '__init__':
-            doc_sources = [parent] + doc_sources
+            doc_sources += [parent]
         for src in doc_sources:
             doc = parse_docstring(src, params=True, logger=logger)
             if doc:
-                docs.append(doc)
+                for param in doc.params:
+                    docs[param.arg_name] = param.description
     return docs
 
 
diff --git a/jsonargparse/parameter_resolvers.py b/jsonargparse/parameter_resolvers.py
index 7c235bc..4bb4eef 100644
--- a/jsonargparse/parameter_resolvers.py
+++ b/jsonargparse/parameter_resolvers.py
@@ -80,7 +80,7 @@ def is_staticmethod(attr) -> bool:
 
 
 def is_method(attr) -> bool:
-    return inspect.isfunction(attr) and not is_staticmethod(attr)
+    return (inspect.isfunction(attr) or attr.__class__.__name__ == 'cython_function_or_method') and not is_staticmethod(attr)
 
 
 def is_property(attr) -> bool:
@@ -265,10 +265,7 @@ def get_signature_parameters_and_indexes(component, parent, logger):
         params = params[1:]
     args_idx = get_arg_kind_index(params, kinds.VAR_POSITIONAL)
     kwargs_idx = get_arg_kind_index(params, kinds.VAR_KEYWORD)
-    doc_params = {}
-    for doc in parse_docs(component, parent, logger):
-        for param in doc.params:
-            doc_params[param.arg_name] = param.description
+    doc_params = parse_docs(component, parent, logger)
     for num, param in enumerate(params):
         params[num] = ParamData(
             doc=doc_params.get(param.name),
@@ -767,6 +764,37 @@ def get_parameters_by_assumptions(
     return params
 
 
+def get_parameters_from_pydantic(
+    function_or_class: Union[Callable, Type],
+    method_or_property: Optional[str],
+    logger: logging.Logger,
+) -> Optional[ParamList]:
+    from .optionals import import_pydantic, pydantic_support
+    if not pydantic_support or method_or_property:
+        return None
+    pydantic = import_pydantic('get_parameters_from_pydantic')
+    if not is_subclass(function_or_class, pydantic.BaseModel):
+        return None
+    params = []
+    doc_params = parse_docs(function_or_class, None, logger)
+    for field in function_or_class.__fields__.values():  # type: ignore
+        if field.required:
+            default = inspect._empty
+        elif field.default_factory:
+            default = field.default_factory()
+        else:
+            default = field.default
+        params.append(ParamData(
+            name=field.name,
+            annotation=field.annotation,
+            default=default,
+            kind=kinds.KEYWORD_ONLY,
+            doc=field.field_info.description or doc_params.get(field.name),
+            component=function_or_class,
+        ))
+    return params
+
+
 def get_signature_parameters(
     function_or_class: Union[Callable, Type],
     method_or_property: Optional[str] = None,
@@ -787,6 +815,9 @@ def get_signature_parameters(
     """
     logger = parse_logger(logger, 'get_signature_parameters')
     try:
+        params = get_parameters_from_pydantic(function_or_class, method_or_property, logger)
+        if params is not None:
+            return params
         visitor = ParametersVisitor(function_or_class, method_or_property, logger=logger)
         return visitor.get_parameters()
     except Exception as ex:
diff --git a/jsonargparse/signatures.py b/jsonargparse/signatures.py
index f8648e4..4be0552 100644
--- a/jsonargparse/signatures.py
+++ b/jsonargparse/signatures.py
@@ -8,7 +8,13 @@ from contextlib import suppress
 from typing import Any, Callable, List, Optional, Set, Tuple, Type, Union
 
 from .actions import _ActionConfigLoad
-from .optionals import get_doc_short_description
+from .optionals import (
+    attrs_support,
+    get_doc_short_description,
+    import_attrs,
+    import_pydantic,
+    pydantic_support,
+)
 from .parameter_resolvers import (
     ParamData,
     get_parameter_origins,
@@ -411,20 +417,18 @@ class SignatureArguments(LoggerProperty):
                     default = theclass(**default)
             if not isinstance(default, theclass):
                 raise ValueError(f'Expected "default" argument to be an instance of "{theclass.__name__}" or its kwargs dict, given {default}')
-            defaults = dataclasses.asdict(default)
+            defaults = dataclass_to_dict(default)
 
         added_args: List[str] = []
-        params = {p.name: p for p in get_signature_parameters(theclass, None, logger=self.logger)}
-        for field in dataclasses.fields(theclass):
-            if field.name in params:
-                self._add_signature_parameter(
-                    group,
-                    nested_key,
-                    params[field.name],
-                    added_args,
-                    fail_untyped=fail_untyped,
-                    default=defaults.get(field.name, inspect_empty),
-                )
+        for param in get_signature_parameters(theclass, None, logger=self.logger):
+            self._add_signature_parameter(
+                group,
+                nested_key,
+                param,
+                added_args,
+                fail_untyped=fail_untyped,
+                default=defaults.get(param.name, inspect_empty),
+            )
 
         return added_args
 
@@ -550,7 +554,24 @@ def is_pure_dataclass(value):
     if not inspect.isclass(value):
         return False
     classes = [c for c in inspect.getmro(value) if c != object]
-    return all(dataclasses.is_dataclass(c) for c in classes)
+    all_dataclasses = all(dataclasses.is_dataclass(c) for c in classes)
+    if not all_dataclasses and pydantic_support:
+        pydantic = import_pydantic('is_pure_dataclass')
+        classes = [c for c in classes if c != pydantic.utils.Representation]
+        all_dataclasses = all(is_subclass(c, pydantic.BaseModel) for c in classes)
+    if not all_dataclasses and attrs_support:
+        attrs = import_attrs('is_pure_dataclass')
+        if attrs.has(value):
+            return True
+    return all_dataclasses
+
+
+def dataclass_to_dict(value):
+    if pydantic_support:
+        pydantic = import_pydantic('dataclass_to_dict')
+        if isinstance(value, pydantic.BaseModel):
+            return value.dict()
+    return dataclasses.asdict(value)
 
 
 def compose_dataclasses(*args):
diff --git a/pyproject.toml b/pyproject.toml
index f20bcaa..038521c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -85,6 +85,8 @@ test = [
     "jsonargparse[types-pyyaml]",
     "responses>=0.12.0",
     "types-requests>=2.28.9",
+    "pydantic>=1.10.7; python_version > '3.6'",
+    "attrs>=22.2.0",
 ]
 test-no-urls = [
     "coverage>=4.5.1",


Test patch:
diff --git a/jsonargparse_tests/test_dataclasses.py b/jsonargparse_tests/test_dataclasses.py
new file mode 100755
index 0000000..78f9fd0
--- /dev/null
+++ b/jsonargparse_tests/test_dataclasses.py
@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+
+import dataclasses
+import sys
+import unittest
+from io import StringIO
+from typing import Dict, List
+
+import yaml
+
+from jsonargparse import ArgumentError, ArgumentParser, Namespace, compose_dataclasses
+from jsonargparse.optionals import (
+    attrs_support,
+    docstring_parser_support,
+    import_attrs,
+    import_pydantic,
+    pydantic_support,
+    set_docstring_parse_options,
+)
+from jsonargparse.typing import PositiveFloat, PositiveInt
+from jsonargparse_tests.base import mock_module
+
+
+@dataclasses.dataclass(frozen=True)
+class MyDataClassA:
+    """MyDataClassA description
+
+    Args:
+        a1: a1 help
+        a2: a2 help
+    """
+    a1: PositiveInt = PositiveInt(1)  # type: ignore
+    a2: str = '2'
+
+@dataclasses.dataclass
+class MyDataClassB:
+    """MyDataClassB description
+
+    Args:
+        b1: b1 help
+        b2: b2 help
+    """
+    b1: PositiveFloat = PositiveFloat(3.0)  # type: ignore
+    b2: MyDataClassA = MyDataClassA()
+
+
+class DataclassesTests(unittest.TestCase):
+
+    @unittest.skipIf(not docstring_parser_support, 'docstring-parser package is required')
+    def test_add_dataclass_arguments(self):
+        parser = ArgumentParser(exit_on_error=False)
+        parser.add_dataclass_arguments(MyDataClassA, 'a', default=MyDataClassA(), title='CustomA title')
+        parser.add_dataclass_arguments(MyDataClassB, 'b', default=MyDataClassB())
+
+        cfg = parser.get_defaults()
+        self.assertEqual(dataclasses.asdict(MyDataClassA()), cfg['a'].as_dict())
+        self.assertEqual(dataclasses.asdict(MyDataClassB()), cfg['b'].as_dict())
+        dump = yaml.safe_load(parser.dump(cfg))
+        self.assertEqual(dataclasses.asdict(MyDataClassA()), dump['a'])
+        self.assertEqual(dataclasses.asdict(MyDataClassB()), dump['b'])
+
+        cfg = parser.instantiate_classes(cfg)
+        self.assertIsInstance(cfg['a'], MyDataClassA)
+        self.assertIsInstance(cfg['b'], MyDataClassB)
+        self.assertIsInstance(cfg['b'].b2, MyDataClassA)
+
+        self.assertEqual(5, parser.parse_args(['--b.b2.a1=5']).b.b2.a1)
+        self.assertRaises(ArgumentError, lambda: parser.parse_args(['--b.b2.a1=x']))
+
+        help_str = StringIO()
+        parser.print_help(help_str)
+        self.assertIn('CustomA title:', help_str.getvalue())
+        self.assertIn('MyDataClassB description:', help_str.getvalue())
+        self.assertIn('b2 help:', help_str.getvalue())
+
+        with self.assertRaises(ValueError):
+            parser.add_dataclass_arguments(1, 'c')
+
+        with self.assertRaises(ValueError):
+            parser.add_dataclass_arguments(MyDataClassB, 'c', default=MyDataClassB(b2=MyDataClassB()))
+
+        class MyClass(int, MyDataClassA):
+            """MyClass description"""
+
+        with self.assertRaises(ValueError):
+            parser.add_dataclass_arguments(MyClass, 'c')
+
+
+    def test_dataclass_typehint(self):
+
+        class MyClass:
+            def __init__(
+                self,
+                a1: MyDataClassA = MyDataClassA(),
+                a2: MyDataClassB = MyDataClassB(),
+            ):
+                self.a1 = a1
+                self.a2 = a2
+
+        parser = ArgumentParser()
+        parser.add_class_arguments(MyClass, 'g')
+
+        cfg = parser.get_defaults()
+        self.assertEqual(dataclasses.asdict(MyDataClassA()), cfg['g']['a1'].as_dict())
+        self.assertEqual(dataclasses.asdict(MyDataClassB()), cfg['g']['a2'].as_dict())
+        dump = yaml.safe_load(parser.dump(cfg))
+        self.assertEqual(dataclasses.asdict(MyDataClassA()), dump['g']['a1'])
+        self.assertEqual(dataclasses.asdict(MyDataClassB()), dump['g']['a2'])
+
+        cfg_init = parser.instantiate_classes(cfg)
+        self.assertIsInstance(cfg_init.g.a1, MyDataClassA)
+        self.assertIsInstance(cfg_init.g.a2, MyDataClassB)
+        self.assertIsInstance(cfg_init.g.a2.b2, MyDataClassA)
+
+
+    def test_dataclass_typehint_in_subclass(self):
+
+        class MyClass1:
+            def __init__(self, a1: MyDataClassB = MyDataClassB()):
+                """MyClass1 description"""
+                self.a1 = a1
+
+        class MyClass2:
+            def __init__(self, c1: MyClass1):
+                """MyClass2 description"""
+                self.c1 = c1
+
+        parser = ArgumentParser(exit_on_error=False)
+        parser.add_class_arguments(MyClass2)
+
+        with mock_module(MyClass1, MyClass2) as module:
+            class_path = f'"class_path": "{module}.MyClass1"'
+            init_args = '"init_args": {"a1": {"b2": {"a1": 7}}}'
+            cfg = parser.parse_args(['--c1={'+class_path+', '+init_args+'}'])
+            self.assertEqual(cfg.c1.class_path, f'{module}.MyClass1')
+            self.assertEqual(cfg.c1.init_args.a1.b2.a1, 7)
+            self.assertIsInstance(cfg.c1.init_args.a1.b2.a1, PositiveInt)
+            cfg = parser.instantiate_classes(cfg)
+            self.assertIsInstance(cfg['c1'], MyClass1)
+            self.assertIsInstance(cfg['c1'].a1, MyDataClassB)
+            self.assertIsInstance(cfg['c1'].a1.b2, MyDataClassA)
+            self.assertIsInstance(cfg['c1'].a1.b1, PositiveFloat)
+
+
+    def test_dataclass_add_argument_type(self):
+        parser = ArgumentParser()
+        parser.add_argument('--b', type=MyDataClassB, default=MyDataClassB(b1=7.0))
+
+        cfg = parser.get_defaults()
+        self.assertEqual({'b1': 7.0, 'b2': {'a1': 1, 'a2': '2'}}, cfg['b'].as_dict())
+
+        cfg = parser.instantiate_classes(cfg)
+        self.assertIsInstance(cfg['b'], MyDataClassB)
+        self.assertIsInstance(cfg['b'].b2, MyDataClassA)
+
+
+    def test_dataclass_add_argument_type_some_required(self):
+
+        @dataclasses.dataclass
+        class MyDataClass:
+            a1: str
+            a2: float = 1.2
+
+        parser = ArgumentParser(exit_on_error=False)
+        parser.add_argument('--b', type=MyDataClass)
+
+        self.assertEqual(Namespace(a1='v', a2=1.2), parser.parse_args(['--b.a1=v']).b)
+        self.assertRaises(ArgumentError, lambda: parser.parse_args([]))
+
+
+    def test_dataclass_field_init_false(self):
+
+        @dataclasses.dataclass
+        class DataInitFalse:
+            p1: str = '-'
+            p2: str = dataclasses.field(init=False)
+
+        parser = ArgumentParser(exit_on_error=False)
+        added = parser.add_dataclass_arguments(DataInitFalse, 'd')
+        self.assertEqual(added, ['d.p1'])
+        self.assertEqual(parser.get_defaults(), Namespace(d=Namespace(p1='-')))
+
+
+    def test_dataclass_field_default_factory(self):
+
+        @dataclasses.dataclass
+        class MyDataClass:
+            a1: List[int] = dataclasses.field(default_factory=lambda: [1, 2, 3])
+            a2: Dict[str, float] = dataclasses.field(default_factory=lambda: {'a': 1.2, 'b': 3.4})
+
+        parser = ArgumentParser()
+        parser.add_class_arguments(MyDataClass)
+
+        cfg = parser.get_defaults().as_dict()
+        self.assertEqual([1, 2, 3], cfg['a1'])
+        self.assertEqual({'a': 1.2, 'b': 3.4}, cfg['a2'])
+
+
+    def test_dataclass_fail_untyped(self):
+
+        class MyClass:
+            def __init__(self, c1) -> None:
+                self.c1 = c1
+
+        @dataclasses.dataclass
+        class MyDataclass:
+            a1: MyClass
+            a2: str = "a2"
+            a3: str = "a3"
+
+        parser = ArgumentParser(exit_on_error=False)
+        parser.add_argument('--cfg', type=MyDataclass, fail_untyped=False)
+
+        with mock_module(MyDataclass, MyClass) as module:
+            class_path = f'"class_path": "{module}.MyClass"'
+            init_args = '"init_args": {"c1": 1}'
+            cfg = parser.parse_args(['--cfg.a1={'+class_path+', '+init_args+'}'])
+            cfg = parser.instantiate_classes(cfg)
+            self.assertIsInstance(cfg['cfg'], MyDataclass)
+            self.assertIsInstance(cfg['cfg'].a1, MyClass)
+            self.assertIsInstance(cfg['cfg'].a2, str)
+            self.assertIsInstance(cfg['cfg'].a3, str)
+
+
+    def test_compose_dataclasses(self):
+
+        @dataclasses.dataclass
+        class MyDataClassA:
+            a: int = 1
+
+            def __post_init__(self):
+                self.a += 1
+
+        @dataclasses.dataclass
+        class MyDataClassB:
+            b: str = '1'
+
+        MyDataClassAB = compose_dataclasses(MyDataClassA, MyDataClassB)
+        self.assertEqual(2, len(dataclasses.fields(MyDataClassAB)))
+        self.assertEqual({'a': 3, 'b': '2'}, dataclasses.asdict(MyDataClassAB(a=2, b='2')))  # pylint: disable=unexpected-keyword-arg
+
+
+    def test_instantiate_classes_dataclasses_lightning_issue_9207(self):
+        # https://github.com/PyTorchLightning/pytorch-lightning/issues/9207
+
+        @dataclasses.dataclass
+        class MyDataClass:
+            name: str = 'name'
+
+        class MyClass:
+            def __init__(self, data: MyDataClass):
+                self.data = data
+
+        parser = ArgumentParser()
+        parser.add_class_arguments(MyClass, 'class')
+        cfg = parser.parse_args([])
+        cfg = parser.instantiate_classes(cfg)
+        self.assertIsInstance(cfg['class'], MyClass)
+        self.assertIsInstance(cfg['class'].data, MyDataClass)
+
+
+    @unittest.skipIf(not docstring_parser_support, 'docstring-parser package is required')
+    def test_attribute_docstrings(self):
+
+        @dataclasses.dataclass
+        class WithAttrDocs:
+            attr_str: str = 'a'
+            "attr_str description"
+            attr_int: int = 1
+            "attr_int description"
+
+        set_docstring_parse_options(attribute_docstrings=True)
+
+        parser = ArgumentParser()
+        parser.add_class_arguments(WithAttrDocs)
+        help_str = StringIO()
+        parser.print_help(help_str)
+        self.assertIn('attr_str description (type: str, default: a)', help_str.getvalue())
+        self.assertIn('attr_int description (type: int, default: 1)', help_str.getvalue())
+
+        set_docstring_parse_options(attribute_docstrings=False)
+
+
+@unittest.skipIf(sys.version_info == (3, 6), 'pydantic not supported in python 3.10')
+@unittest.skipIf(not pydantic_support, 'pydantic package is required')
+class PydanticTests(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.pydantic = import_pydantic('PydanticTests')
+
+
+    def test_dataclass(self):
+        @self.pydantic.dataclasses.dataclass
+        class Data:
+            p1: float = 0.1
+            p2: str = '-'
+
+        parser = ArgumentParser()
+        parser.add_argument('--data', type=Data)
+        defaults = parser.get_defaults()
+        self.assertEqual(Namespace(p1=0.1, p2='-'), defaults.data)
+        cfg = parser.parse_args(['--data.p1=0.2', '--data.p2=x'])
+        self.assertEqual(Namespace(p1=0.2, p2='x'), cfg.data)
+
+
+    def test_basemodel(self):
+        class Model(self.pydantic.BaseModel):
+            p1: str
+            p2: int = 3
+
+        parser = ArgumentParser()
+        parser.add_argument('--model', type=Model, default=Model(p1='a'))
+        cfg = parser.parse_args(['--model.p2=5'])
+        self.assertEqual(Namespace(p1='a', p2=5), cfg.model)
+
+
+    def test_field_default_factory(self):
+        class Model(self.pydantic.BaseModel):
+            p1: List[int] = self.pydantic.Field(default_factory=lambda: [1, 2])
+
+        parser = ArgumentParser()
+        parser.add_argument('--model', type=Model)
+        cfg1 = parser.parse_args([])
+        cfg2 = parser.parse_args([])
+        self.assertEqual(cfg1.model.p1, [1, 2])
+        self.assertEqual(cfg1.model.p1, cfg2.model.p1)
+        self.assertIsNot(cfg1.model.p1, cfg2.model.p1)
+
+
+    def test_field_description(self):
+        class Model(self.pydantic.BaseModel):
+            """
+            Args:
+                p1: p1 help
+            """
+            p1: str
+            p2: int = self.pydantic.Field(2, description="p2 help")
+
+        parser = ArgumentParser()
+        parser.add_argument('--model', type=Model)
+        help_str = StringIO()
+        parser.print_help(help_str)
+        if docstring_parser_support:
+            self.assertIn('p1 help (required, type: str)', help_str.getvalue())
+        self.assertIn('p2 help (type: int, default: 2)', help_str.getvalue())
+
+
+@unittest.skipIf(not attrs_support, 'attrs package is required')
+class AttrsTests(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.attrs = import_attrs('AttrsTests')
+
+    def test_define(self):
+        @self.attrs.define
+        class Data:
+            p1: float = 0.1
+            p2: str = '-'
+
+        parser = ArgumentParser()
+        parser.add_argument('--data', type=Data)
+        defaults = parser.get_defaults()
+        self.assertEqual(Namespace(p1=0.1, p2='-'), defaults.data)
+        cfg = parser.parse_args(['--data.p1=0.2', '--data.p2=x'])
+        self.assertEqual(Namespace(p1=0.2, p2='x'), cfg.data)
+
+
+if __name__ == '__main__':
+    unittest.main(verbosity=2)
diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py
index 4fd903e..dd0b83a 100755
--- a/jsonargparse_tests/test_signatures.py
+++ b/jsonargparse_tests/test_signatures.py
@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 
-import dataclasses
 import json
 import os
 import textwrap
@@ -21,12 +20,11 @@ from jsonargparse import (
     ArgumentParser,
     Namespace,
     class_from_function,
-    compose_dataclasses,
     lazy_instance,
     strip_meta,
 )
 from jsonargparse.actions import _find_action
-from jsonargparse.optionals import docstring_parser_support, set_docstring_parse_options
+from jsonargparse.optionals import docstring_parser_support
 from jsonargparse.typing import OpenUnitInterval, PositiveFloat, PositiveInt, final
 from jsonargparse_tests.base import TempDirTestCase, mock_module, suppress_stderr
 
@@ -1496,265 +1494,6 @@ class SignaturesConfigTests(TempDirTestCase):
             self.assertTrue(init.main.sub['init_args']['s2'], 2)
             self.assertTrue(any("discarding init_args: {'s1': 1}" in o for o in log.output))
 
-@dataclasses.dataclass(frozen=True)
-class MyDataClassA:
-    """MyDataClassA description
-
-    Args:
-        a1: a1 help
-        a2: a2 help
-    """
-    a1: PositiveInt = PositiveInt(1)  # type: ignore
-    a2: str = '2'
-
-@dataclasses.dataclass
-class MyDataClassB:
-    """MyDataClassB description
-
-    Args:
-        b1: b1 help
-        b2: b2 help
-    """
-    b1: PositiveFloat = PositiveFloat(3.0)  # type: ignore
-    b2: MyDataClassA = MyDataClassA()
-
-
-class DataclassesTests(unittest.TestCase):
-
-    @unittest.skipIf(not docstring_parser_support, 'docstring-parser package is required')
-    def test_add_dataclass_arguments(self):
-        parser = ArgumentParser(exit_on_error=False)
-        parser.add_dataclass_arguments(MyDataClassA, 'a', default=MyDataClassA(), title='CustomA title')
-        parser.add_dataclass_arguments(MyDataClassB, 'b', default=MyDataClassB())
-
-        cfg = parser.get_defaults()
-        self.assertEqual(dataclasses.asdict(MyDataClassA()), cfg['a'].as_dict())
-        self.assertEqual(dataclasses.asdict(MyDataClassB()), cfg['b'].as_dict())
-        dump = yaml.safe_load(parser.dump(cfg))
-        self.assertEqual(dataclasses.asdict(MyDataClassA()), dump['a'])
-        self.assertEqual(dataclasses.asdict(MyDataClassB()), dump['b'])
-
-        cfg = parser.instantiate_classes(cfg)
-        self.assertIsInstance(cfg['a'], MyDataClassA)
-        self.assertIsInstance(cfg['b'], MyDataClassB)
-        self.assertIsInstance(cfg['b'].b2, MyDataClassA)
-
-        self.assertEqual(5, parser.parse_args(['--b.b2.a1=5']).b.b2.a1)
-        self.assertRaises(ArgumentError, lambda: parser.parse_args(['--b.b2.a1=x']))
-
-        help_str = StringIO()
-        parser.print_help(help_str)
-        self.assertIn('CustomA title:', help_str.getvalue())
-        self.assertIn('MyDataClassB description:', help_str.getvalue())
-        self.assertIn('b2 help:', help_str.getvalue())
-
-        with self.assertRaises(ValueError):
-            parser.add_dataclass_arguments(1, 'c')
-
-        with self.assertRaises(ValueError):
-            parser.add_dataclass_arguments(MyDataClassB, 'c', default=MyDataClassB(b2=MyDataClassB()))
-
-        class MyClass(int, MyDataClassA):
-            """MyClass description"""
-
-        with self.assertRaises(ValueError):
-            parser.add_dataclass_arguments(MyClass, 'c')
-
-
-    def test_dataclass_typehint(self):
-
-        class MyClass:
-            def __init__(
-                self,
-                a1: MyDataClassA = MyDataClassA(),
-                a2: MyDataClassB = MyDataClassB(),
-            ):
-                self.a1 = a1
-                self.a2 = a2
-
-        parser = ArgumentParser()
-        parser.add_class_arguments(MyClass, 'g')
-
-        cfg = parser.get_defaults()
-        self.assertEqual(dataclasses.asdict(MyDataClassA()), cfg['g']['a1'].as_dict())
-        self.assertEqual(dataclasses.asdict(MyDataClassB()), cfg['g']['a2'].as_dict())
-        dump = yaml.safe_load(parser.dump(cfg))
-        self.assertEqual(dataclasses.asdict(MyDataClassA()), dump['g']['a1'])
-        self.assertEqual(dataclasses.asdict(MyDataClassB()), dump['g']['a2'])
-
-        cfg_init = parser.instantiate_classes(cfg)
-        self.assertIsInstance(cfg_init.g.a1, MyDataClassA)
-        self.assertIsInstance(cfg_init.g.a2, MyDataClassB)
-        self.assertIsInstance(cfg_init.g.a2.b2, MyDataClassA)
-
-
-    def test_dataclass_typehint_in_subclass(self):
-
-        class MyClass1:
-            def __init__(self, a1: MyDataClassB = MyDataClassB()):
-                """MyClass1 description"""
-                self.a1 = a1
-
-        class MyClass2:
-            def __init__(self, c1: MyClass1):
-                """MyClass2 description"""
-                self.c1 = c1
-
-        parser = ArgumentParser(exit_on_error=False)
-        parser.add_class_arguments(MyClass2)
-
-        with mock_module(MyClass1, MyClass2) as module:
-            class_path = f'"class_path": "{module}.MyClass1"'
-            init_args = '"init_args": {"a1": {"b2": {"a1": 7}}}'
-            cfg = parser.parse_args(['--c1={'+class_path+', '+init_args+'}'])
-            self.assertEqual(cfg.c1.class_path, f'{module}.MyClass1')
-            self.assertEqual(cfg.c1.init_args.a1.b2.a1, 7)
-            self.assertIsInstance(cfg.c1.init_args.a1.b2.a1, PositiveInt)
-            cfg = parser.instantiate_classes(cfg)
-            self.assertIsInstance(cfg['c1'], MyClass1)
-            self.assertIsInstance(cfg['c1'].a1, MyDataClassB)
-            self.assertIsInstance(cfg['c1'].a1.b2, MyDataClassA)
-            self.assertIsInstance(cfg['c1'].a1.b1, PositiveFloat)
-
-
-    def test_dataclass_add_argument_type(self):
-        parser = ArgumentParser()
-        parser.add_argument('--b', type=MyDataClassB, default=MyDataClassB(b1=7.0))
-
-        cfg = parser.get_defaults()
-        self.assertEqual({'b1': 7.0, 'b2': {'a1': 1, 'a2': '2'}}, cfg['b'].as_dict())
-
-        cfg = parser.instantiate_classes(cfg)
-        self.assertIsInstance(cfg['b'], MyDataClassB)
-        self.assertIsInstance(cfg['b'].b2, MyDataClassA)
-
-
-    def test_dataclass_add_argument_type_some_required(self):
-
-        @dataclasses.dataclass
-        class MyDataClass:
-            a1: str
-            a2: float = 1.2
-
-        parser = ArgumentParser(exit_on_error=False)
-        parser.add_argument('--b', type=MyDataClass)
-
-        self.assertEqual(Namespace(a1='v', a2=1.2), parser.parse_args(['--b.a1=v']).b)
-        self.assertRaises(ArgumentError, lambda: parser.parse_args([]))
-
-
-    def test_dataclass_field_init_false(self):
-
-        @dataclasses.dataclass
-        class DataInitFalse:
-            p1: str = '-'
-            p2: str = dataclasses.field(init=False)
-
-        parser = ArgumentParser(exit_on_error=False)
-        added = parser.add_dataclass_arguments(DataInitFalse, 'd')
-        self.assertEqual(added, ['d.p1'])
-        self.assertEqual(parser.get_defaults(), Namespace(d=Namespace(p1='-')))
-
-
-    def test_dataclass_field_default_factory(self):
-
-        @dataclasses.dataclass
-        class MyDataClass:
-            a1: List[int] = dataclasses.field(default_factory=lambda: [1, 2, 3])
-            a2: Dict[str, float] = dataclasses.field(default_factory=lambda: {'a': 1.2, 'b': 3.4})
-
-        parser = ArgumentParser()
-        parser.add_class_arguments(MyDataClass)
-
-        cfg = parser.get_defaults().as_dict()
-        self.assertEqual([1, 2, 3], cfg['a1'])
-        self.assertEqual({'a': 1.2, 'b': 3.4}, cfg['a2'])
-
-
-    def test_dataclass_fail_untyped(self):
-
-        class MyClass:
-            def __init__(self, c1) -> None:
-                self.c1 = c1
-
-        @dataclasses.dataclass
-        class MyDataclass:
-            a1: MyClass
-            a2: str = "a2"
-            a3: str = "a3"
-
-        parser = ArgumentParser(exit_on_error=False)
-        parser.add_argument('--cfg', type=MyDataclass, fail_untyped=False)
-
-        with mock_module(MyDataclass, MyClass) as module:
-            class_path = f'"class_path": "{module}.MyClass"'
-            init_args = '"init_args": {"c1": 1}'
-            cfg = parser.parse_args(['--cfg.a1={'+class_path+', '+init_args+'}'])
-            cfg = parser.instantiate_classes(cfg)
-            self.assertIsInstance(cfg['cfg'], MyDataclass)
-            self.assertIsInstance(cfg['cfg'].a1, MyClass)
-            self.assertIsInstance(cfg['cfg'].a2, str)
-            self.assertIsInstance(cfg['cfg'].a3, str)
-
-
-    def test_compose_dataclasses(self):
-
-        @dataclasses.dataclass
-        class MyDataClassA:
-            a: int = 1
-
-            def __post_init__(self):
-                self.a += 1
-
-        @dataclasses.dataclass
-        class MyDataClassB:
-            b: str = '1'
-
-        MyDataClassAB = compose_dataclasses(MyDataClassA, MyDataClassB)
-        self.assertEqual(2, len(dataclasses.fields(MyDataClassAB)))
-        self.assertEqual({'a': 3, 'b': '2'}, dataclasses.asdict(MyDataClassAB(a=2, b='2')))  # pylint: disable=unexpected-keyword-arg
-
-
-    def test_instantiate_classes_dataclasses_lightning_issue_9207(self):
-        # https://github.com/PyTorchLightning/pytorch-lightning/issues/9207
-
-        @dataclasses.dataclass
-        class MyDataClass:
-            name: str = 'name'
-
-        class MyClass:
-            def __init__(self, data: MyDataClass):
-                self.data = data
-
-        parser = ArgumentParser()
-        parser.add_class_arguments(MyClass, 'class')
-        cfg = parser.parse_args([])
-        cfg = parser.instantiate_classes(cfg)
-        self.assertIsInstance(cfg['class'], MyClass)
-        self.assertIsInstance(cfg['class'].data, MyDataClass)
-
-
-    @unittest.skipIf(not docstring_parser_support, 'docstring-parser package is required')
-    def test_attribute_docstrings(self):
-
-        @dataclasses.dataclass
-        class WithAttrDocs:
-            attr_str: str = 'a'
-            "attr_str description"
-            attr_int: int = 1
-            "attr_int description"
-
-        set_docstring_parse_options(attribute_docstrings=True)
-
-        parser = ArgumentParser()
-        parser.add_class_arguments(WithAttrDocs)
-        help_str = StringIO()
-        parser.print_help(help_str)
-        self.assertIn('attr_str description (type: str, default: a)', help_str.getvalue())
-        self.assertIn('attr_int description (type: int, default: 1)', help_str.getvalue())
-
-        set_docstring_parse_options(attribute_docstrings=False)
-
 
 if __name__ == '__main__':
     unittest.main(verbosity=2)