Repository: litestar-org/polyfactory
Pull request #666 (2025-03-19 20:00:58)

fix: no blank children names
## Description

ensure no blank field names on children variants


iloveitaly: @adhtruong updated this! Tests are passing, ready for final review.
github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/666
github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/666

Patch:
diff --git a/polyfactory/factories/pydantic_factory.py b/polyfactory/factories/pydantic_factory.py
index 33a8e05..72d54d3 100644
--- a/polyfactory/factories/pydantic_factory.py
+++ b/polyfactory/factories/pydantic_factory.py
@@ -181,14 +181,21 @@ class PydanticFieldMeta(FieldMeta):
         if is_union(annotation):
             constraints = {}
             children = []
+
+            # create a child for each of the possible union values
             for arg in get_args(annotation):
+                # don't add the NoneType in an optional to the list of children
                 if arg is NoneType:
                     continue
                 child_field_info = FieldInfo.from_annotation(arg)
                 merged_field_info = FieldInfo.merge_field_infos(field_info, child_field_info)
+
                 children.append(
+                    # recurse for each element of the union
                     cls.from_field_info(
-                        field_name="",
+                        # this is a fake field name, but it makes it possible to debug which type variant
+                        # is the source of an exception downstream
+                        field_name=field_name,
                         field_info=merged_field_info,
                         use_alias=use_alias,
                     ),
diff --git a/polyfactory/field_meta.py b/polyfactory/field_meta.py
index 2630902..7e35521 100644
--- a/polyfactory/field_meta.py
+++ b/polyfactory/field_meta.py
@@ -144,6 +144,8 @@ class FieldMeta:
             metadata = cls.get_constraints_metadata(annotation)
             constraints = cls.parse_constraints(metadata)
 
+        # annotations can take many forms: Optional, an Annotated type, or anything with __args__
+        # in order to normalize the annotation, we need to unwrap the annotation.
         if not annotated and (origin := get_origin(annotation)) and origin in TYPE_MAPPING:
             container = TYPE_MAPPING[origin]
             annotation = container[get_args(annotation)]  # type: ignore[index]
diff --git a/polyfactory/utils/helpers.py b/polyfactory/utils/helpers.py
index 10d1f0d..2abae53 100644
--- a/polyfactory/utils/helpers.py
+++ b/polyfactory/utils/helpers.py
@@ -38,7 +38,7 @@ def unwrap_new_type(annotation: Any) -> Any:
 
 @deprecated("v2.21.0")
 def unwrap_union(annotation: Any, random: Random) -> Any:
-    """Unwraps union types - recursively.
+    """Unwraps union types recursively and picks a random type from each union.
 
     :param annotation: A type annotation, possibly a type union.
     :param random: An instance of random.Random.
@@ -86,7 +86,12 @@ def unwrap_annotation(annotation: Any, random: Random | None = None) -> Any:
 
 
 def flatten_annotation(annotation: Any) -> list[Any]:
-    """Flattens an annotation.
+    """Flattens an annotation into an array of possible types. For example:
+
+    * Union[str, int] → [str, int]
+    * Optional[str] → [str, None]
+    * Union[str, Optional[int]] → [str, int, None]
+    * NewType('UserId', int) → [int]
 
     :param annotation: A type annotation.
 


Test patch:
diff --git a/tests/test_pydantic_factory.py b/tests/test_pydantic_factory.py
index f6eb3ea..129ff5f 100644
--- a/tests/test_pydantic_factory.py
+++ b/tests/test_pydantic_factory.py
@@ -6,7 +6,7 @@ from datetime import date, datetime, time, timedelta
 from decimal import Decimal
 from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network
 from pathlib import Path
-from typing import Callable, Dict, FrozenSet, List, Literal, Optional, Sequence, Set, Tuple, Type, Union
+from typing import Any, Callable, Dict, FrozenSet, List, Literal, Optional, Sequence, Set, Tuple, Type, Union
 from uuid import UUID
 
 import pytest
@@ -64,8 +64,10 @@ from pydantic import (
     validator,
 )
 
+from polyfactory.exceptions import ParameterException
 from polyfactory.factories import DataclassFactory
 from polyfactory.factories.pydantic_factory import _IS_PYDANTIC_V1, ModelFactory
+from polyfactory.field_meta import FieldMeta
 from tests.models import Person, PetFactory
 
 IS_PYDANTIC_V1 = _IS_PYDANTIC_V1
@@ -634,6 +636,49 @@ def test_union_types() -> None:
     assert AFactory.build()
 
 
+@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires modern union types")
+@pytest.mark.skipif(IS_PYDANTIC_V1, reason="pydantic 2 only test")
+def test_optional_custom_type() -> None:
+    from pydantic_core import core_schema
+
+    class CustomType:
+        def __init__(self, _: Any) -> None:
+            pass
+
+        def __get_pydantic_core_schema__(self, _: Any) -> core_schema.StringSchema:
+            # for pydantic to stop complaining
+            return core_schema.str_schema()
+
+    class OptionalFormOne(BaseModel):
+        optional_custom_type: Optional[CustomType]
+
+        @classmethod
+        def should_set_none_value(cls, field_meta: FieldMeta) -> bool:
+            return False
+
+    class OptionalFormOneFactory(ModelFactory[OptionalFormOne]):
+        @classmethod
+        def should_set_none_value(cls, field_meta: FieldMeta) -> bool:
+            return False
+
+    class OptionalFormTwo(BaseModel):
+        # this is represented differently than `Optional[None]` internally
+        optional_custom_type_second_form: CustomType | None
+
+    class OptionalFormTwoFactory(ModelFactory[OptionalFormTwo]):
+        @classmethod
+        def should_set_none_value(cls, field_meta: FieldMeta) -> bool:
+            return False
+
+    # ensure the custom type field name and variant is in the error message
+
+    with pytest.raises(ParameterException, match=r"optional_custom_type"):
+        OptionalFormOneFactory.build()
+
+    with pytest.raises(ParameterException, match=r"optional_custom_type_second_form"):
+        OptionalFormTwoFactory.build()
+
+
 def test_collection_unions_with_models() -> None:
     class A(BaseModel):
         a: int