Repository: litestar-org/polyfactory
Pull request #712 (2025-06-10 18:23:14)

fix: Generate correct collection size when annotation_types.Len is used
## Description
Fixes a bug where annotation_types.Len can go over the described length

## Test
### Before the fix:
```
================================================ short test summary info ================================================
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[1-List] - AssertionError: 2
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[1-Dict] - AssertionError: 2
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[2-List] - AssertionError: 3
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[2-Dict] - AssertionError: 3
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[3-List] - AssertionError: 4
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[3-Dict] - AssertionError: 4
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[4-List] - AssertionError: 5
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[4-Dict] - AssertionError: 5
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[5-List] - AssertionError: 6
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[5-Dict] - AssertionError: 6
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[6-List] - AssertionError: 7
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[6-Dict] - AssertionError: 7
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[7-List] - AssertionError: 8
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[7-Dict] - AssertionError: 8
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[8-List] - AssertionError: 9
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[8-Dict] - AssertionError: 9
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[9-List] - AssertionError: 10
FAILED tests/test_collection_length.py::test_annotated_type_collection_length[9-Dict] - AssertionError: 10
============================================= 18 failed, 79 passed in 0.58s =============================================
```

### After the fix
```
tests/test_collection_length.py ...................................................................................................

================================================== 99 passed in 0.62s ===================================================
```
## Closes
Fixes #710


priyankc: @Rub1kCube addressed the comments. please take a look when you have a moment.
github-actions[bot]: Documentation preview will be available shortly at https://litestar-org.github.io/polyfactory-docs-preview/712

Patch:
diff --git a/polyfactory/factories/pydantic_factory.py b/polyfactory/factories/pydantic_factory.py
index 5ad8286..33a8e05 100644
--- a/polyfactory/factories/pydantic_factory.py
+++ b/polyfactory/factories/pydantic_factory.py
@@ -16,7 +16,7 @@ from polyfactory.factories.base import BuildContext as BaseBuildContext
 from polyfactory.field_meta import Constraints, FieldMeta, Null
 from polyfactory.utils.deprecation import check_for_deprecated_parameters
 from polyfactory.utils.helpers import unwrap_new_type, unwrap_optional
-from polyfactory.utils.predicates import is_optional, is_safe_subclass, is_union
+from polyfactory.utils.predicates import is_annotated, is_optional, is_safe_subclass, is_union
 from polyfactory.utils.types import NoneType
 from polyfactory.value_generators.primitives import create_random_bytes
 
@@ -270,31 +270,37 @@ class PydanticFieldMeta(FieldMeta):
             else unwrap_new_type(model_field.annotation)
         )
 
-        constraints = cast(
-            "Constraints",
-            {
-                "ge": getattr(outer_type, "ge", model_field.field_info.ge),
-                "gt": getattr(outer_type, "gt", model_field.field_info.gt),
-                "le": getattr(outer_type, "le", model_field.field_info.le),
-                "lt": getattr(outer_type, "lt", model_field.field_info.lt),
-                "min_length": (
-                    getattr(outer_type, "min_length", model_field.field_info.min_length)
-                    or getattr(outer_type, "min_items", model_field.field_info.min_items)
-                ),
-                "max_length": (
-                    getattr(outer_type, "max_length", model_field.field_info.max_length)
-                    or getattr(outer_type, "max_items", model_field.field_info.max_items)
-                ),
-                "pattern": getattr(outer_type, "regex", model_field.field_info.regex),
-                "unique_items": getattr(outer_type, "unique_items", model_field.field_info.unique_items),
-                "decimal_places": getattr(outer_type, "decimal_places", None),
-                "max_digits": getattr(outer_type, "max_digits", None),
-                "multiple_of": getattr(outer_type, "multiple_of", None),
-                "upper_case": getattr(outer_type, "to_upper", None),
-                "lower_case": getattr(outer_type, "to_lower", None),
-                "item_type": getattr(outer_type, "item_type", None),
-            },
-        )
+        # In pydantic v1, we need to check if the annotation is directly annotated to properly extract constraints
+        # from the metadata, as v1 doesn't automatically propagate constraints like v2 does
+        annotation_constraints: Constraints = {}
+        if is_annotated(model_field.annotation):
+            annotation_metadata = cls.get_constraints_metadata(model_field.annotation)
+            annotation_constraints = cls.parse_constraints(annotation_metadata) if annotation_metadata else {}
+
+        field_info_constraints = {
+            "ge": getattr(outer_type, "ge", model_field.field_info.ge),
+            "gt": getattr(outer_type, "gt", model_field.field_info.gt),
+            "le": getattr(outer_type, "le", model_field.field_info.le),
+            "lt": getattr(outer_type, "lt", model_field.field_info.lt),
+            "min_length": (
+                getattr(outer_type, "min_length", model_field.field_info.min_length)
+                or getattr(outer_type, "min_items", model_field.field_info.min_items)
+            ),
+            "max_length": (
+                getattr(outer_type, "max_length", model_field.field_info.max_length)
+                or getattr(outer_type, "max_items", model_field.field_info.max_items)
+            ),
+            "pattern": getattr(outer_type, "regex", model_field.field_info.regex),
+            "unique_items": getattr(outer_type, "unique_items", model_field.field_info.unique_items),
+            "decimal_places": getattr(outer_type, "decimal_places", None),
+            "max_digits": getattr(outer_type, "max_digits", None),
+            "multiple_of": getattr(outer_type, "multiple_of", None),
+            "upper_case": getattr(outer_type, "to_upper", None),
+            "lower_case": getattr(outer_type, "to_lower", None),
+            "item_type": getattr(outer_type, "item_type", None),
+        }
+
+        constraints = cast("Constraints", {**field_info_constraints, **annotation_constraints})
 
         # pydantic v1 has constraints set for these values, but we generate them using faker
         if unwrap_optional(annotation) in (
diff --git a/polyfactory/value_generators/constrained_collections.py b/polyfactory/value_generators/constrained_collections.py
index 580b7d1..059148c 100644
--- a/polyfactory/value_generators/constrained_collections.py
+++ b/polyfactory/value_generators/constrained_collections.py
@@ -42,7 +42,7 @@ def handle_constrained_collection(  # noqa: C901
         return collection_type()
 
     min_items = abs(min_items if min_items is not None else (max_items or 0))
-    max_items = abs(max_items if max_items is not None else min_items + 1)
+    max_items = abs(max_items) if max_items is not None else min_items
 
     if max_items < min_items:
         msg = "max_items must be larger or equal to min_items"
@@ -110,13 +110,13 @@ def handle_constrained_mapping(
         return {}
 
     min_items = abs(min_items if min_items is not None else (max_items or 0))
-    max_items = abs(max_items if max_items is not None else min_items + 1)
+    max_items = abs(max_items) if max_items is not None else min_items
 
     if max_items < min_items:
         msg = "max_items must be larger or equal to min_items"
         raise ParameterException(msg)
 
-    length = factory.__random__.randint(min_items, max_items) or 1
+    length = factory.__random__.randint(min_items, max_items)
 
     collection: dict[Any, Any] = {}
 


Test patch:
diff --git a/tests/test_collection_length.py b/tests/test_collection_length.py
index 8b9b3a5..c6ebc7d 100644
--- a/tests/test_collection_length.py
+++ b/tests/test_collection_length.py
@@ -2,6 +2,8 @@ from enum import Enum
 from typing import Any, Dict, FrozenSet, List, Literal, Optional, Set, Tuple, get_args
 
 import pytest
+from annotated_types import Len
+from typing_extensions import Annotated
 
 from pydantic import BaseModel
 from pydantic.dataclasses import dataclass
@@ -12,6 +14,20 @@ from polyfactory.factories.pydantic_factory import ModelFactory
 MIN_MAX_PARAMETERS = ((10, 15), (20, 25), (30, 40), (40, 50))
 
 
+@pytest.mark.parametrize("type_", (List[int], Dict[int, int]))
+@pytest.mark.parametrize("length", range(1, 10))
+def test_annotated_type_collection_length(type_: type, length: int) -> None:
+    class Foo(BaseModel):
+        foo: Annotated[type_, Len(length)]  # type: ignore
+
+    class FooFactory(ModelFactory):
+        __model__ = Foo
+
+    for _ in range(10):
+        foo = FooFactory.build()
+        assert len(foo.foo) == length, len(foo.foo)
+
+
 @pytest.mark.parametrize("type_", (List, Set))
 @pytest.mark.parametrize(("min_val", "max_val"), MIN_MAX_PARAMETERS)
 def test_collection_length_with_list(min_val: int, max_val: int, type_: type) -> None: