Repository: litestar-org/polyfactory (python)

Issue:
Enhancement: Deprecate collection params in FieldMeta.from_type
### Summary

After https://github.com/litestar-org/polyfactory/pull/407, these are no longer used other than passing onto child instances for https://github.com/litestar-org/polyfactory/blob/main/polyfactory/field_meta.py#L113 collection params.

Removing outright would be a breaking change so propose to
- Change defaults to None
- Remove any internal usage 
- Omit deprecation warning if these are not None and that these will be removed in next major release. This may require adding some utils

### Basic Example

_No response_

### Drawbacks and Impact

_No response_

### Unresolved questions

_No response_

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh Litestar dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/polyfactory/issues/415">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/polyfactory/issues/415/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/polyfactory/issues/415/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->


Pull request:
feat: deprecate FieldMeta collection params
<!--
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/)
-->

### Pull Request Checklist

- [ ] New code has 100% test coverage
- [ ] (If applicable) The prose documentation has been updated to reflect the changes introduced by this PR
- [ ] (If applicable) The reference documentation has been updated to reflect the changes introduced by this PR
- [ ] Pre-Commit Checks were ran and passed
- [ ] Tests were ran and passed

### Description
<!--
Please describe your pull request for new release changelog purposes
-->

- Vendor in https://github.com/litestar-org/litestar/blob/main/litestar/utils/deprecation.py#L71, adjust and add parameter util.
- Deprecate collection params for FieldMeta
- Update PydanticFieldMeta implementation. This harmonises usage with other FieldMeta and think extending is implicitly ignored right now

### Close Issue(s)
<!--
Please add in issue numbers this pull request will close, if applicable
Examples: Fixes #4321 or Closes #1234
-->

- Closes #415 


Patch:
diff --git a/polyfactory/factories/attrs_factory.py b/polyfactory/factories/attrs_factory.py
index 84b8e7c..5ab5acd 100644
--- a/polyfactory/factories/attrs_factory.py
+++ b/polyfactory/factories/attrs_factory.py
@@ -61,9 +61,6 @@ class AttrsFactory(BaseFactory[T]):
                     name=field.alias,
                     default=default_value,
                     random=cls.__random__,
-                    randomize_collection_length=cls.__randomize_collection_length__,
-                    min_collection_length=cls.__min_collection_length__,
-                    max_collection_length=cls.__max_collection_length__,
                 ),
             )
 
diff --git a/polyfactory/factories/dataclass_factory.py b/polyfactory/factories/dataclass_factory.py
index ea7ed23..b4c6d2b 100644
--- a/polyfactory/factories/dataclass_factory.py
+++ b/polyfactory/factories/dataclass_factory.py
@@ -49,9 +49,6 @@ class DataclassFactory(Generic[T], BaseFactory[T]):
                     name=field.name,
                     default=default_value,
                     random=cls.__random__,
-                    randomize_collection_length=cls.__randomize_collection_length__,
-                    min_collection_length=cls.__min_collection_length__,
-                    max_collection_length=cls.__max_collection_length__,
                 ),
             )
 
diff --git a/polyfactory/factories/msgspec_factory.py b/polyfactory/factories/msgspec_factory.py
index d97e222..1b579ae 100644
--- a/polyfactory/factories/msgspec_factory.py
+++ b/polyfactory/factories/msgspec_factory.py
@@ -1,13 +1,7 @@
 from __future__ import annotations
 
 from inspect import isclass
-from typing import (
-    TYPE_CHECKING,
-    Any,
-    Callable,
-    Generic,
-    TypeVar,
-)
+from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar
 
 from typing_extensions import get_type_hints
 
@@ -73,9 +67,6 @@ class MsgspecFactory(Generic[T], BaseFactory[T]):
                     name=field.name,
                     default=default_value,
                     random=cls.__random__,
-                    randomize_collection_length=cls.__randomize_collection_length__,
-                    min_collection_length=cls.__min_collection_length__,
-                    max_collection_length=cls.__max_collection_length__,
                 ),
             )
         return fields_meta
diff --git a/polyfactory/factories/pydantic_factory.py b/polyfactory/factories/pydantic_factory.py
index e7b0f67..a6549db 100644
--- a/polyfactory/factories/pydantic_factory.py
+++ b/polyfactory/factories/pydantic_factory.py
@@ -5,31 +5,17 @@ from datetime import timezone
 from functools import partial
 from os.path import realpath
 from pathlib import Path
-from typing import (
-    TYPE_CHECKING,
-    Any,
-    ClassVar,
-    ForwardRef,
-    Generic,
-    Mapping,
-    Tuple,
-    TypeVar,
-    cast,
-)
+from typing import TYPE_CHECKING, Any, ClassVar, ForwardRef, Generic, Mapping, Tuple, TypeVar, cast
 from uuid import NAMESPACE_DNS, uuid1, uuid3, uuid5
 
 from typing_extensions import Literal, get_args, get_origin
 
 from polyfactory.collection_extender import CollectionExtender
-from polyfactory.constants import (
-    DEFAULT_RANDOM,
-    MAX_COLLECTION_LENGTH,
-    MIN_COLLECTION_LENGTH,
-    RANDOMIZE_COLLECTION_LENGTH,
-)
+from polyfactory.constants import DEFAULT_RANDOM
 from polyfactory.exceptions import MissingDependencyException
 from polyfactory.factories.base import BaseFactory
 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.value_generators.primitives import create_random_bytes
@@ -94,9 +80,9 @@ class PydanticFieldMeta(FieldMeta):
         field_info: FieldInfo,
         use_alias: bool,
         random: Random | None,
-        randomize_collection_length: bool = RANDOMIZE_COLLECTION_LENGTH,
-        min_collection_length: int = MIN_COLLECTION_LENGTH,
-        max_collection_length: int = MAX_COLLECTION_LENGTH,
+        randomize_collection_length: bool | None = None,
+        min_collection_length: int | None = None,
+        max_collection_length: int | None = None,
     ) -> PydanticFieldMeta:
         """Create an instance from a pydantic field info.
 
@@ -110,6 +96,14 @@ class PydanticFieldMeta(FieldMeta):
 
         :returns: A PydanticFieldMeta instance.
         """
+        check_for_deprecated_parameters(
+            "2.11.0",
+            parameters=(
+                ("randomize_collection_length", randomize_collection_length),
+                ("min_collection_length", min_collection_length),
+                ("max_collection_length", max_collection_length),
+            ),
+        )
         if callable(field_info.default_factory):
             default_value = field_info.default_factory()
         else:
@@ -128,10 +122,7 @@ class PydanticFieldMeta(FieldMeta):
                 cls.from_field_info(
                     field_info=FieldInfo.from_annotation(arg),
                     field_name=field_name,
-                    max_collection_length=max_collection_length,
-                    min_collection_length=min_collection_length,
                     random=random,
-                    randomize_collection_length=randomize_collection_length,
                     use_alias=use_alias,
                 )
                 for arg in get_args(annotation)
@@ -159,11 +150,8 @@ class PydanticFieldMeta(FieldMeta):
             children=children,
             constraints=cast("Constraints", {k: v for k, v in constraints.items() if v is not None}) or None,
             default=default_value,
-            max_collection_length=max_collection_length,
-            min_collection_length=min_collection_length,
             name=name,
             random=random or DEFAULT_RANDOM,
-            randomize_collection_length=randomize_collection_length,
         )
 
     @classmethod
@@ -171,9 +159,9 @@ class PydanticFieldMeta(FieldMeta):
         cls,
         model_field: ModelField,  # pyright: ignore[reportGeneralTypeIssues]
         use_alias: bool,
-        randomize_collection_length: bool,
-        min_collection_length: int,
-        max_collection_length: int,
+        randomize_collection_length: bool | None = None,
+        min_collection_length: int | None = None,
+        max_collection_length: int | None = None,
         random: Random = DEFAULT_RANDOM,
     ) -> PydanticFieldMeta:
         """Create an instance from a pydantic model field.
@@ -187,6 +175,14 @@ class PydanticFieldMeta(FieldMeta):
         :returns: A PydanticFieldMeta instance.
 
         """
+        check_for_deprecated_parameters(
+            "2.11.0",
+            parameters=(
+                ("randomize_collection_length", randomize_collection_length),
+                ("min_collection_length", min_collection_length),
+                ("max_collection_length", max_collection_length),
+            ),
+        )
         from pydantic import AmqpDsn, AnyHttpUrl, AnyUrl, HttpUrl, KafkaDsn, PostgresDsn, RedisDsn
         from pydantic.fields import DeferredType, Undefined  # type: ignore[attr-defined]
 
@@ -251,9 +247,6 @@ class PydanticFieldMeta(FieldMeta):
 
         children: list[FieldMeta] = []
         if model_field.key_field or model_field.sub_fields:
-            number_of_args = (
-                random.randint(min_collection_length, max_collection_length) if randomize_collection_length else 1
-            )
             fields_to_iterate = (
                 ([model_field.key_field, *model_field.sub_fields])
                 if model_field.key_field is not None
@@ -271,15 +264,12 @@ class PydanticFieldMeta(FieldMeta):
             if get_origin(outer_type) in (tuple, Tuple) and get_args(outer_type)[-1] == Ellipsis:
                 # pydantic removes ellipses from Tuples in sub_fields
                 type_args += (...,)
-            extended_type_args = CollectionExtender.extend_type_args(annotation, type_args, number_of_args)
+            extended_type_args = CollectionExtender.extend_type_args(annotation, type_args, 1)
             children.extend(
                 PydanticFieldMeta.from_model_field(
                     model_field=type_arg_to_sub_field[arg],
                     use_alias=use_alias,
                     random=random,
-                    randomize_collection_length=randomize_collection_length,
-                    min_collection_length=min_collection_length,
-                    max_collection_length=max_collection_length,
                 )
                 for arg in extended_type_args
             )
@@ -335,9 +325,6 @@ class ModelFactory(Generic[T], BaseFactory[T]):
                         field,
                         use_alias=not cls.__model__.__config__.allow_population_by_field_name,  # type: ignore[attr-defined]
                         random=cls.__random__,
-                        randomize_collection_length=cls.__randomize_collection_length__,
-                        min_collection_length=cls.__min_collection_length__,
-                        max_collection_length=cls.__max_collection_length__,
                     )
                     for field in cls.__model__.__fields__.values()  # type: ignore[attr-defined]
                 ]
@@ -348,9 +335,6 @@ class ModelFactory(Generic[T], BaseFactory[T]):
                         field_name=field_name,
                         random=cls.__random__,
                         use_alias=not cls.__model__.model_config.get("populate_by_name", False),
-                        randomize_collection_length=cls.__randomize_collection_length__,
-                        min_collection_length=cls.__min_collection_length__,
-                        max_collection_length=cls.__max_collection_length__,
                     )
                     for field_name, field_info in cls.__model__.model_fields.items()
                 ]
diff --git a/polyfactory/factories/sqlalchemy_factory.py b/polyfactory/factories/sqlalchemy_factory.py
index 983c3cb..c423d0b 100644
--- a/polyfactory/factories/sqlalchemy_factory.py
+++ b/polyfactory/factories/sqlalchemy_factory.py
@@ -136,9 +136,6 @@ class SQLAlchemyFactory(Generic[T], BaseFactory[T]):
                 annotation=cls.get_type_from_column(column),
                 name=name,
                 random=cls.__random__,
-                randomize_collection_length=cls.__randomize_collection_length__,
-                min_collection_length=cls.__min_collection_length__,
-                max_collection_length=cls.__max_collection_length__,
             )
             for name, column in table.columns.items()
             if cls.should_column_be_set(column)
@@ -152,9 +149,6 @@ class SQLAlchemyFactory(Generic[T], BaseFactory[T]):
                         name=name,
                         annotation=annotation,
                         random=cls.__random__,
-                        randomize_collection_length=cls.__randomize_collection_length__,
-                        min_collection_length=cls.__min_collection_length__,
-                        max_collection_length=cls.__max_collection_length__,
                     ),
                 )
 
diff --git a/polyfactory/factories/typed_dict_factory.py b/polyfactory/factories/typed_dict_factory.py
index ec22ce2..80eb78d 100644
--- a/polyfactory/factories/typed_dict_factory.py
+++ b/polyfactory/factories/typed_dict_factory.py
@@ -41,9 +41,6 @@ class TypedDictFactory(Generic[TypedDictT], BaseFactory[TypedDictT]):
                 random=DEFAULT_RANDOM,
                 name=field_name,
                 default=getattr(cls.__model__, field_name, Null),
-                randomize_collection_length=cls.__randomize_collection_length__,
-                min_collection_length=cls.__min_collection_length__,
-                max_collection_length=cls.__max_collection_length__,
             )
             for field_name, annotation in model_type_hints.items()
         ]
diff --git a/polyfactory/field_meta.py b/polyfactory/field_meta.py
index 763caca..766a604 100644
--- a/polyfactory/field_meta.py
+++ b/polyfactory/field_meta.py
@@ -6,13 +6,8 @@ from typing import TYPE_CHECKING, Any, Literal, Pattern, TypedDict, cast
 from typing_extensions import Mapping, get_args, get_origin
 
 from polyfactory.collection_extender import CollectionExtender
-from polyfactory.constants import (
-    DEFAULT_RANDOM,
-    MAX_COLLECTION_LENGTH,
-    MIN_COLLECTION_LENGTH,
-    RANDOMIZE_COLLECTION_LENGTH,
-    TYPE_MAPPING,
-)
+from polyfactory.constants import DEFAULT_RANDOM, TYPE_MAPPING
+from polyfactory.utils.deprecation import check_for_deprecated_parameters
 from polyfactory.utils.helpers import normalize_annotation, unwrap_annotated, unwrap_args, unwrap_new_type
 from polyfactory.utils.predicates import is_annotated, is_any_annotated
 
@@ -110,9 +105,9 @@ class FieldMeta:
         name: str = "",
         default: Any = Null,
         constraints: Constraints | None = None,
-        randomize_collection_length: bool = RANDOMIZE_COLLECTION_LENGTH,
-        min_collection_length: int = MIN_COLLECTION_LENGTH,
-        max_collection_length: int = MAX_COLLECTION_LENGTH,
+        randomize_collection_length: bool | None = None,
+        min_collection_length: int | None = None,
+        max_collection_length: int | None = None,
         children: list[FieldMeta] | None = None,
     ) -> Self:
         """Builder method to create a FieldMeta from a type annotation.
@@ -128,6 +123,14 @@ class FieldMeta:
 
         :returns: A field meta instance.
         """
+        check_for_deprecated_parameters(
+            "2.11.0",
+            parameters=(
+                ("randomize_collection_length", randomize_collection_length),
+                ("min_collection_length", min_collection_length),
+                ("max_collection_length", max_collection_length),
+            ),
+        )
         field_type = normalize_annotation(annotation, random=random)
 
         if not constraints and is_annotated(annotation):
@@ -156,9 +159,6 @@ class FieldMeta:
                 FieldMeta.from_type(
                     annotation=unwrap_new_type(arg),
                     random=random,
-                    randomize_collection_length=randomize_collection_length,
-                    min_collection_length=min_collection_length,
-                    max_collection_length=max_collection_length,
                 )
                 for arg in extended_type_args
             ]
diff --git a/polyfactory/utils/deprecation.py b/polyfactory/utils/deprecation.py
new file mode 100644
index 0000000..576c4ac
--- /dev/null
+++ b/polyfactory/utils/deprecation.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import inspect
+from functools import wraps
+from typing import Any, Callable, Literal, TypeVar
+from warnings import warn
+
+from typing_extensions import ParamSpec
+
+__all__ = ("deprecated", "warn_deprecation", "check_for_deprecated_parameters")
+
+
+T = TypeVar("T")
+P = ParamSpec("P")
+DeprecatedKind = Literal["function", "method", "classmethod", "attribute", "property", "class", "parameter", "import"]
+
+
+def warn_deprecation(
+    version: str,
+    deprecated_name: str,
+    kind: DeprecatedKind,
+    *,
+    removal_in: str | None = None,
+    alternative: str | None = None,
+    info: str | None = None,
+    pending: bool = False,
+) -> None:
+    """Warn about a call to a (soon to be) deprecated function.
+
+    Args:
+        version: Polyfactory version where the deprecation will occur.
+        deprecated_name: Name of the deprecated function.
+        removal_in: Polyfactory version where the deprecated function will be removed.
+        alternative: Name of a function that should be used instead.
+        info: Additional information.
+        pending: Use ``PendingDeprecationWarning`` instead of ``DeprecationWarning``.
+        kind: Type of the deprecated thing.
+    """
+    parts = []
+
+    if kind == "import":
+        access_type = "Import of"
+    elif kind in {"function", "method"}:
+        access_type = "Call to"
+    else:
+        access_type = "Use of"
+
+    if pending:
+        parts.append(f"{access_type} {kind} awaiting deprecation {deprecated_name!r}")
+    else:
+        parts.append(f"{access_type} deprecated {kind} {deprecated_name!r}")
+
+    parts.extend(
+        (
+            f"Deprecated in polyfactory {version}",
+            f"This {kind} will be removed in {removal_in or 'the next major version'}",
+        )  # noqa: COM812
+    )
+    if alternative:
+        parts.append(f"Use {alternative!r} instead")
+
+    if info:
+        parts.append(info)
+
+    text = ". ".join(parts)
+    warning_class = PendingDeprecationWarning if pending else DeprecationWarning
+
+    warn(text, warning_class, stacklevel=2)
+
+
+def deprecated(
+    version: str,
+    *,
+    removal_in: str | None = None,
+    alternative: str | None = None,
+    info: str | None = None,
+    pending: bool = False,
+    kind: Literal["function", "method", "classmethod", "property"] | None = None,
+) -> Callable[[Callable[P, T]], Callable[P, T]]:
+    """Create a decorator wrapping a function, method or property with a warning call about a (pending) deprecation.
+
+    Args:
+        version: Polyfactory version where the deprecation will occur.
+        removal_in: Polyfactory version where the deprecated function will be removed.
+        alternative: Name of a function that should be used instead.
+        info: Additional information.
+        pending: Use ``PendingDeprecationWarning`` instead of ``DeprecationWarning``.
+        kind: Type of the deprecated callable. If ``None``, will use ``inspect`` to figure
+            out if it's a function or method.
+
+    Returns:
+        A decorator wrapping the function call with a warning
+    """
+
+    def decorator(func: Callable[P, T]) -> Callable[P, T]:
+        @wraps(func)
+        def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
+            warn_deprecation(
+                version=version,
+                deprecated_name=func.__name__,
+                info=info,
+                alternative=alternative,
+                pending=pending,
+                removal_in=removal_in,
+                kind=kind or ("method" if inspect.ismethod(func) else "function"),
+            )
+            return func(*args, **kwargs)
+
+        return wrapped
+
+    return decorator
+
+
+def check_for_deprecated_parameters(
+    version: str,
+    *,
+    parameters: tuple[tuple[str, Any], ...],
+    default_value: Any = None,
+    removal_in: str | None = None,
+    alternative: str | None = None,
+    info: str | None = None,
+    pending: bool = False,
+) -> None:
+    """Warn about a call to a (soon to be) deprecated argument to a function.
+
+    Args:
+        version: Polyfactory version where the deprecation will occur.
+        parameters: Parameters to trigger warning if used.
+        default_value: Default value for parameter to detect if supplied or not.
+        removal_in: Polyfactory version where the deprecated function will be removed.
+        alternative: Name of a function that should be used instead.
+        info: Additional information.
+        pending: Use ``PendingDeprecationWarning`` instead of ``DeprecationWarning``.
+        kind: Type of the deprecated callable. If ``None``, will use ``inspect`` to figure
+            out if it's a function or method.
+    """
+    for parameter_name, value in parameters:
+        if value == default_value:
+            continue
+
+        warn_deprecation(
+            version=version,
+            deprecated_name=parameter_name,
+            info=info,
+            alternative=alternative,
+            pending=pending,
+            removal_in=removal_in,
+            kind="parameter",
+        )


Test patch:
diff --git a/tests/utils/test_deprecation.py b/tests/utils/test_deprecation.py
new file mode 100644
index 0000000..3abed14
--- /dev/null
+++ b/tests/utils/test_deprecation.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+import pytest
+
+from polyfactory.utils.deprecation import check_for_deprecated_parameters
+
+
+def test_parameter_deprecation() -> None:
+    def my_func(a: int, b: int | None = None) -> None:
+        check_for_deprecated_parameters("5", parameters=(("b", b),))
+
+    with pytest.warns(
+        DeprecationWarning,
+        match="Use of deprecated parameter 'b'. Deprecated in polyfactory 5. This parameter will be removed in the next major version",
+    ):
+        my_func(1, 2)