Repository: samuelcolvin/pydantic
Pull request #2970 (2021-07-09 14:39:45)

Parse JSON for union type with complex subfields (#2936)
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->
<!-- See https://pydantic-docs.helpmanual.io/contributing/ for help on Contributing -->

## Change Summary

Parse environment variables as JSON, if they have a Union Type with a complex subfield.

## Related issue number

Fixes [# 2936](https://github.com/samuelcolvin/pydantic/issues/2936)

## Checklist

* [x] Unit tests for the changes exist
* [x] Tests pass on CI and coverage remains at 100%
* [x] Documentation reflects the changes where applicable - n/a
* [x] `changes/<pull request or issue id>-<github username>.md` file added describing change
  (see [changes/README.md](https://github.com/samuelcolvin/pydantic/blob/master/changes/README.md) for details)
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**

<!-- please review -->


cbartz: please review

Patch:
diff --git a/changes/2936-cbartz.md b/changes/2936-cbartz.md
new file mode 100644
index 000000000..eabf91d8e
--- /dev/null
+++ b/changes/2936-cbartz.md
@@ -0,0 +1,1 @@
+Parse environment variables as JSON, if they have a `Union` type with a complex subfield.
\ No newline at end of file
diff --git a/pydantic/env_settings.py b/pydantic/env_settings.py
index c6a0219f2..e6d9ccca1 100644
--- a/pydantic/env_settings.py
+++ b/pydantic/env_settings.py
@@ -6,7 +6,7 @@ from typing import AbstractSet, Any, Callable, Dict, List, Mapping, Optional, Tu
 from .config import BaseConfig, Extra
 from .fields import ModelField
 from .main import BaseModel
-from .typing import StrPath, display_as_type
+from .typing import StrPath, display_as_type, get_origin, is_union_origin
 from .utils import deep_update, path_type, sequence_like
 
 env_file_sentinel = str(object())
@@ -173,6 +173,15 @@ class EnvSettingsSource:
                     env_val = settings.__config__.json_loads(env_val)  # type: ignore
                 except ValueError as e:
                     raise SettingsError(f'error parsing JSON for "{env_name}"') from e
+            elif (
+                is_union_origin(get_origin(field.type_))
+                and field.sub_fields
+                and any(f.is_complex() for f in field.sub_fields)
+            ):
+                try:
+                    env_val = settings.__config__.json_loads(env_val)  # type: ignore
+                except ValueError:
+                    pass
             d[field.alias] = env_val
         return d
 


Test patch:
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 8ccc29738..5fa2f549f 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,8 +1,9 @@
 import os
 import sys
 import uuid
+from datetime import datetime, timezone
 from pathlib import Path
-from typing import Any, Dict, List, Optional, Set, Tuple
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
 
 import pytest
 
@@ -463,6 +464,45 @@ def test_config_file_settings_nornir(env):
     assert s.param_c == 'env setting c'
 
 
+def test_env_union_with_complex_subfields_parses_json(env):
+    class A(BaseSettings):
+        a: str
+
+    class B(BaseSettings):
+        b: int
+
+    class Settings(BaseSettings):
+        content: Union[A, B, int]
+
+    env.set('content', '{"a": "test"}')
+    s = Settings()
+    assert s.content == A(a='test')
+
+
+def test_env_union_with_complex_subfields_parses_plain_if_json_fails(env):
+    class A(BaseSettings):
+        a: str
+
+    class B(BaseSettings):
+        b: int
+
+    class Settings(BaseSettings):
+        content: Union[A, B, datetime]
+
+    env.set('content', '2020-07-05T00:00:00Z')
+    s = Settings()
+    assert s.content == datetime(2020, 7, 5, 0, 0, tzinfo=timezone.utc)
+
+
+def test_env_union_without_complex_subfields_does_not_parse_json(env):
+    class Settings(BaseSettings):
+        content: Union[datetime, str]
+
+    env.set('content', '2020-07-05T00:00:00Z')
+    s = Settings()
+    assert s.content == datetime(2020, 7, 5, 0, 0, tzinfo=timezone.utc)
+
+
 test_env_file = """\
 # this is a comment
 A=good string