Repository: litestar-org/polyfactory
Pull request #688 (2025-04-13 11:45:12)

refactor: make type utils deterministic
<!--
By submitting this pull request, you agree to:
- follow [Litestar's Code of Conduct](https://github.com/litestar-org/.github/blob/main/CODE_OF_CONDUCT.md)
- follow [Litestar's contribution guidelines](https://github.com/litestar-org/.github/blob/main/CONTRIBUTING.md)
- follow the [PSFs's Code of Conduct](https://www.python.org/psf/conduct/)
-->
## Description

- This PR aims to simplify `helpers` by making deterministic. Currently there is parsing on `Union` within here and also in factory itself
- This removes `random` throughout and deprecates these and deprecates now unused functions
- This does change behaviour of `unwrap_annotated` by removing `Union` handling there. Is this ok or can try refactoring to keep the current behaviour until v3?

<!--
Please add in issue numbers this pull request will close, if applicable
Examples: Fixes #4321 or Closes #1234

Ensure you are using a supported keyword to properly link an issue:
https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
-->
## Closes


github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/688
github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/688
github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/688
github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/688
provinzkraut: > * This does change behaviour of `unwrap_annotated` by removing `Union` handling there. Is this ok or can try refactoring to keep the current behaviour until v3?

I think this should be fine


Patch:
diff --git a/polyfactory/factories/base.py b/polyfactory/factories/base.py
index 8bc552e..d71d8d7 100644
--- a/polyfactory/factories/base.py
+++ b/polyfactory/factories/base.py
@@ -457,7 +457,7 @@ class BaseFactory(ABC, Generic[T]):
         :returns: Boolean dictating whether the annotation is a batch factory type
         """
         origin = get_type_origin(annotation) or annotation
-        if is_safe_subclass(origin, Sequence) and (args := unwrap_args(annotation, random=cls.__random__)):
+        if is_safe_subclass(origin, Sequence) and (args := unwrap_args(annotation)):
             return len(args) == 1 and BaseFactory.is_factory_type(annotation=args[0])
         return False
 
@@ -757,7 +757,7 @@ class BaseFactory(ABC, Generic[T]):
         if field_build_parameters is None and cls.should_set_none_value(field_meta=field_meta):
             return None
 
-        unwrapped_annotation = unwrap_annotation(field_meta.annotation, random=cls.__random__)
+        unwrapped_annotation = unwrap_annotation(field_meta.annotation)
 
         if is_literal(annotation=unwrapped_annotation) and (literal_args := get_args(unwrapped_annotation)):
             return cls.__random__.choice(literal_args)
@@ -773,7 +773,7 @@ class BaseFactory(ABC, Generic[T]):
                 build_context=build_context,
             )
 
-        if is_union(field_meta.annotation) and field_meta.children:
+        if (is_union(unwrapped_annotation) or is_union(field_meta.annotation)) and field_meta.children:
             seen_models = build_context["seen_models"]
             children = [child for child in field_meta.children if child.annotation not in seen_models]
 
@@ -912,7 +912,7 @@ class BaseFactory(ABC, Generic[T]):
 
             elif (origin := get_type_origin(unwrapped_annotation)) and issubclass(origin, Collection):
                 if not field_meta.children:
-                    msg = "A subclass of Collection should always have children in it's field_meta"
+                    msg = "A subclass of Collection should always have children in its field_meta"
                     raise ParameterException(msg)
 
                 # We actually want to use the parent in cases where the collection is the parent
diff --git a/polyfactory/field_meta.py b/polyfactory/field_meta.py
index c27610f..2630902 100644
--- a/polyfactory/field_meta.py
+++ b/polyfactory/field_meta.py
@@ -168,7 +168,7 @@ class FieldMeta:
 
         for value in metadata:
             if is_annotated(value):
-                _, inner_metadata = unwrap_annotated(value, random=DEFAULT_RANDOM)
+                _, inner_metadata = unwrap_annotated(value)
                 constraints.update(cast("dict[str, Any]", cls.parse_constraints(metadata=inner_metadata)))
             elif func := getattr(value, "func", None):
                 if func is str.islower:
diff --git a/polyfactory/utils/helpers.py b/polyfactory/utils/helpers.py
index bc452d7..10d1f0d 100644
--- a/polyfactory/utils/helpers.py
+++ b/polyfactory/utils/helpers.py
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Mapping, Sequence
 from typing_extensions import get_args, get_origin
 
 from polyfactory.constants import TYPE_MAPPING
+from polyfactory.utils.deprecation import check_for_deprecated_parameters, deprecated
 from polyfactory.utils.predicates import (
     is_annotated,
     is_new_type,
@@ -35,6 +36,7 @@ def unwrap_new_type(annotation: Any) -> Any:
     return annotation
 
 
+@deprecated("v2.21.0")
 def unwrap_union(annotation: Any, random: Random) -> Any:
     """Unwraps union types - recursively.
 
@@ -60,7 +62,7 @@ def unwrap_optional(annotation: Any) -> Any:
     return annotation
 
 
-def unwrap_annotation(annotation: Any, random: Random) -> Any:
+def unwrap_annotation(annotation: Any, random: Random | None = None) -> Any:
     """Unwraps an annotation.
 
     :param annotation: A type annotation.
@@ -69,23 +71,16 @@ def unwrap_annotation(annotation: Any, random: Random) -> Any:
     :returns: The unwrapped annotation.
 
     """
-    while (
-        is_optional(annotation)
-        or is_union(annotation)
-        or is_new_type(annotation)
-        or is_annotated(annotation)
-        or is_type_alias(annotation)
-    ):
+    check_for_deprecated_parameters("2.21.0", parameters=(("random", random),))
+    while is_optional(annotation) or is_new_type(annotation) or is_annotated(annotation) or is_type_alias(annotation):
         if is_new_type(annotation):
             annotation = unwrap_new_type(annotation)
         elif is_optional(annotation):
             annotation = unwrap_optional(annotation)
         elif is_annotated(annotation):
-            annotation = unwrap_annotated(annotation, random=random)[0]
+            annotation = unwrap_annotated(annotation)[0]
         elif is_type_alias(annotation):
             annotation = annotation.__value__
-        else:
-            annotation = unwrap_union(annotation, random=random)
 
     return annotation
 
@@ -114,7 +109,7 @@ def flatten_annotation(annotation: Any) -> list[Any]:
     return flat
 
 
-def unwrap_args(annotation: Any, random: Random) -> tuple[Any, ...]:
+def unwrap_args(annotation: Any, random: Random | None = None) -> tuple[Any, ...]:
     """Unwrap the annotation and return any type args.
 
     :param annotation: A type annotation
@@ -123,11 +118,11 @@ def unwrap_args(annotation: Any, random: Random) -> tuple[Any, ...]:
     :returns: A tuple of type args.
 
     """
+    check_for_deprecated_parameters("2.21.0", parameters=(("random", random),))
+    return get_args(unwrap_annotation(annotation=annotation))
 
-    return get_args(unwrap_annotation(annotation=annotation, random=random))
 
-
-def unwrap_annotated(annotation: Any, random: Random) -> tuple[Any, list[Any]]:
+def unwrap_annotated(annotation: Any, random: Random | None = None) -> tuple[Any, list[Any]]:
     """Unwrap an annotated type and return a tuple of type args and optional metadata.
 
     :param annotation: An annotated type annotation
@@ -136,11 +131,12 @@ def unwrap_annotated(annotation: Any, random: Random) -> tuple[Any, list[Any]]:
     :returns: A tuple of type args.
 
     """
+    check_for_deprecated_parameters("2.21.0", parameters=(("random", random),))
     args = [arg for arg in get_args(annotation) if arg is not None]
-    return unwrap_annotation(args[0], random=random), args[1:]
+    return unwrap_annotation(args[0]), args[1:]
 
 
-def normalize_annotation(annotation: Any, random: Random) -> Any:
+def normalize_annotation(annotation: Any, random: Random | None = None) -> Any:
     """Normalize an annotation.
 
     :param annotation: A type annotation.
@@ -148,11 +144,13 @@ def normalize_annotation(annotation: Any, random: Random) -> Any:
     :returns: A normalized type annotation.
 
     """
+    check_for_deprecated_parameters("2.21.0", parameters=(("random", random),))
+
     if is_new_type(annotation):
         annotation = unwrap_new_type(annotation)
 
     if is_annotated(annotation):
-        annotation = unwrap_annotated(annotation, random=random)[0]
+        annotation = unwrap_annotated(annotation)[0]
 
     # we have to maintain compatibility with the older non-subscriptable typings.
     if sys.version_info < (3, 9):  # pragma: no cover
@@ -164,7 +162,7 @@ def normalize_annotation(annotation: Any, random: Random) -> Any:
         origin = TYPE_MAPPING[origin]
 
     if args := get_args(annotation):
-        args = tuple(normalize_annotation(arg, random=random) for arg in args)
+        args = tuple(normalize_annotation(arg) for arg in args)
         return origin[args] if origin is not type else annotation
 
     return origin
diff --git a/pyproject.toml b/pyproject.toml
index 66b6ff5..a2f05ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -107,6 +107,7 @@ dev = [
   "aiosqlite>=0.19.0",
   "sqlalchemy[asyncio]>=1.4.29",
 ]
+pydantic = ["pydantic[email]>=2.10.4"]
 
 [tool.bumpversion]
 allow_dirty = true
diff --git a/uv.lock b/uv.lock
index ecf1388..e3a51cd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1238,6 +1238,9 @@ lint = [
     { name = "shellcheck-py" },
     { name = "sourcery" },
 ]
+pydantic = [
+    { name = "pydantic", extra = ["email"] },
+]
 test = [
     { name = "annotated-types" },
     { name = "hypothesis" },
@@ -1296,6 +1299,7 @@ lint = [
     { name = "shellcheck-py", specifier = ">=0.9.0.5" },
     { name = "sourcery", specifier = ">=1.9.0" },
 ]
+pydantic = [{ name = "pydantic", extras = ["email"], specifier = ">=2.10.4" }]
 test = [
     { name = "annotated-types", specifier = ">=0.5.0" },
     { name = "hypothesis", specifier = ">=6.86.2" },


Test patch:
diff --git a/tests/test_dataclass_factory.py b/tests/test_dataclass_factory.py
index b39b5a7..158c3f9 100644
--- a/tests/test_dataclass_factory.py
+++ b/tests/test_dataclass_factory.py
@@ -2,7 +2,7 @@ import dataclasses
 from dataclasses import dataclass as vanilla_dataclass
 from dataclasses import field
 from types import ModuleType
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, TypeVar, Union
 from unittest.mock import ANY
 
 import pytest
@@ -270,10 +270,26 @@ def test_union_types() -> None:
         a: Union[List[str], List[int]]
         b: Union[str, List[int]]
         c: List[Union[Tuple[int, int], Tuple[str, int]]]
+        d: Union[str, int]
 
     AFactory = DataclassFactory.create_factory(A)
 
-    assert AFactory.build()
+    results = AFactory.batch(size=20)
+
+    for result in results:
+        assert isinstance(result.a, list)
+        assert len(_get_types(result.a)) == 1
+
+    assert _get_types(result.b for result in results) == {str, list}
+    assert _get_types(result.d for result in results) == {int, str}
+
+    coverage_result = list(AFactory.coverage())
+    for result in coverage_result:
+        assert isinstance(result.a, list)
+        assert len(_get_types(result.a)) == 1
+
+    assert _get_types(result.b for result in coverage_result) == {str, list}
+    assert _get_types(result.d for result in coverage_result) == {int, str}
 
 
 def test_collection_unions_with_models() -> None:
@@ -290,15 +306,27 @@ def test_collection_unions_with_models() -> None:
         a: Union[List[A], List[B]]
         b: List[Union[A, B]]
 
-    CFactory = DataclassFactory.create_factory(C)
+    CFactory = DataclassFactory.create_factory(
+        C,
+        __randomize_collection_length__=True,
+        __min_collection_length__=20,
+        __max_collection_length__=20,
+    )
 
     c = CFactory.build()
 
     assert isinstance(c.a, list)
-    assert all(isinstance(value, A) for value in c.a) or all(isinstance(value, B) for value in c.a)
+    assert _get_types(c.a).issubset((A, B))
 
     assert isinstance(c.b, list)
-    assert all(isinstance(value, (A, B)) for value in c.b)
+    assert _get_types(c.b) == {A, B}
+
+
+_T = TypeVar("_T")
+
+
+def _get_types(items: Iterable[_T]) -> Set[Type[_T]]:
+    return {type(item) for item in items}
 
 
 @pytest.mark.parametrize("allow_none", (True, False))
diff --git a/tests/test_dicts.py b/tests/test_dicts.py
index a60a05a..2abb0c0 100644
--- a/tests/test_dicts.py
+++ b/tests/test_dicts.py
@@ -42,5 +42,5 @@ def test_dict_with_union_random_types() -> None:
     test_obj_1 = MyClassFactory.build()
     test_obj_2 = MyClassFactory.build()
 
-    assert isinstance(next(iter(test_obj_1.val.values())), str)
-    assert isinstance(next(iter(test_obj_2.val.values())), int)
+    assert isinstance(next(iter(test_obj_1.val.values())), int)
+    assert isinstance(next(iter(test_obj_2.val.values())), str)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index af303fe..31147ff 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -165,7 +165,7 @@ def test_unwrap_legacy_type_alias(create_module: Callable[[str], ModuleType]) ->
         """)
     )
 
-    unwrapped = unwrap_annotation(module.MyIntLegacyAlias, random=random.SystemRandom())
+    unwrapped = unwrap_annotation(module.MyIntLegacyAlias)
     assert unwrapped is int
 
 
@@ -181,5 +181,5 @@ def test_unwrap_pep695_type_alias(create_module: Callable[[str], ModuleType]) ->
         """)
     )
 
-    unwrapped = unwrap_annotation(module.MyInt, random=random.SystemRandom())
+    unwrapped = unwrap_annotation(module.MyInt)
     assert unwrapped is int