Repository: omni-us/jsonargparse

Issue:
Dot syntax not working correctly for pydantic 2 models
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

<!-- A clear and concise description of the bug. -->

### To reproduce

```py
from jsonargparse import CLI
from pydantic import BaseModel, Field
from typing import Annotated, Literal, Union


class PingTask(BaseModel):
    type: Literal["ping"] = "ping"


class PongTask(BaseModel):
    type: Literal["pong"] = "pong"

Task = Annotated[
    Union[PingTask, PongTask],
    Field(discriminator="type"),
]

def train_func(model: Task) -> None:
    print(model)


if __name__ == '__main__':
    CLI(train_func, as_positional=False)
```

It works well when the model argument is specified as a json string:

```bash
$ python x.py --model '{"type": "ping"}'
type='ping'
```

However, it doesn't work using the dot syntax:

```bash
$ python x.py --model.type ping                                                                                                

usage: x.py [-h] [--config CONFIG] [--print_config[=flags]] --model MODEL
error: Parser key "model":
  1 validation error for tagged-union[PingTask,PongTask]
    Unable to extract tag using discriminator 'type' [type=union_tag_not_found, input_value=NestedArg(key='type', val='ping'), input_type=NestedArg]
      For further information visit https://errors.pydantic.dev/2.9/v/union_tag_not_found. Got value: NestedArg(key='type', val='ping')
```

### Expected behavior

<!-- Describe how the behavior or output of the reproduction snippet should be. -->

### Environment

<!-- Fill in the list below. -->

- jsonargparse version: 4.33.2
- Python version: 3.11.9
- How jsonargparse was installed: pip install jsonargparse
- OS: mac


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/667?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 [(`2a4454a`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/2a4454abfb15172029fba222a69bd3c86164181a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`e730014`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/e73001495b4b8e06e17cdb5cc624749120e7dac9?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      #667   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           22        22           
  Lines         6670      6670           
=========================================
  Hits          6670      6670           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/667?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).


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 19c2a29..de173b2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -25,6 +25,8 @@ Fixed
 ^^^^^
 - Incorrect instantiation order when instantiation targets share a parent (`#662
   <https://github.com/omni-us/jsonargparse/pull/662>`__).
+- Pydantic discriminated unions handled incorrectly (`#667
+  <https://github.com/omni-us/jsonargparse/pull/667>`__).
 
 
 v4.36.0 (2025-01-17)
diff --git a/jsonargparse/_optionals.py b/jsonargparse/_optionals.py
index 6a02143..97ed2c8 100644
--- a/jsonargparse/_optionals.py
+++ b/jsonargparse/_optionals.py
@@ -5,7 +5,7 @@ import os
 from contextlib import contextmanager
 from importlib.metadata import version
 from importlib.util import find_spec
-from typing import Optional
+from typing import Optional, Union
 
 __all__ = [
     "get_config_read_mode",
@@ -348,10 +348,13 @@ def get_module(value):
 
 
 def is_annotated_validator(typehint: type) -> bool:
+    from ._util import get_typehint_origin
+
     return (
         pydantic_support > 1
         and is_annotated(typehint)
         and any(get_module(m) in {"pydantic", "annotated_types"} for m in typehint.__metadata__)  # type: ignore[attr-defined]
+        and get_typehint_origin(typehint.__origin__) != Union  # type: ignore[attr-defined]
     )
 
 


Test patch:
diff --git a/jsonargparse_tests/test_dataclass_like.py b/jsonargparse_tests/test_dataclass_like.py
index 51a990a..1148f60 100644
--- a/jsonargparse_tests/test_dataclass_like.py
+++ b/jsonargparse_tests/test_dataclass_like.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import dataclasses
 import json
-from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar, Union
+from typing import Any, Dict, Generic, List, Literal, Optional, Tuple, TypeVar, Union
 from unittest.mock import patch
 
 import pytest
@@ -713,6 +713,18 @@ if annotated and pydantic_support > 1:
         cfg = parser.parse_args(["--n", "{}"])
         assert cfg.n == Namespace(a1=Namespace(a2=1))
 
+    class PingTask(pydantic.BaseModel):
+        type: Literal["ping"] = "ping"
+        attr: str = ""
+
+    class PongTask(pydantic.BaseModel):
+        type: Literal["pong"] = "pong"
+
+    PingPongTask = annotated[
+        Union[PingTask, PongTask],
+        pydantic.Field(discriminator="type"),
+    ]
+
 
 length = "length"
 if pydantic_support:
@@ -806,6 +818,8 @@ class TestPydantic:
         parser.add_argument("--model", type=PydanticSubModel, default=PydanticSubModel(p1="a"))
         cfg = parser.parse_args(["--model.p3=0.2"])
         assert Namespace(p1="a", p2=3, p3=0.2) == cfg.model
+        init = parser.instantiate_classes(cfg)
+        assert isinstance(init.model, PydanticSubModel)
 
     def test_field_default_factory(self, parser):
         parser.add_argument("--model", type=PydanticFieldFactory)
@@ -831,6 +845,18 @@ class TestPydantic:
             parser.parse_args(["--model.p1=0"])
         ctx.match("model.p1")
 
+    @pytest.mark.skipif(not (annotated and pydantic_support > 1), reason="Annotated is required")
+    def test_field_union_discriminator_dot_syntax(self, parser):
+        parser.add_argument("--model", type=PingPongTask)
+        cfg = parser.parse_args(["--model.type=pong"])
+        assert cfg.model == Namespace(type="pong")
+        init = parser.instantiate_classes(cfg)
+        assert isinstance(init.model, PongTask)
+        cfg = parser.parse_args(["--model.type=ping", "--model.attr=abc"])
+        assert cfg.model == Namespace(type="ping", attr="abc")
+        init = parser.instantiate_classes(cfg)
+        assert isinstance(init.model, PingTask)
+
     @pytest.mark.parametrize(
         ["valid_value", "invalid_value", "cast", "type_str"],
         [