From 598344ff128ebaf1570a31b76c524a2bcc479ea3 Mon Sep 17 00:00:00 2001 From: Andrew Truong Date: Sun, 16 Nov 2025 17:24:17 +0000 Subject: [PATCH 1/2] fix: handle constrained datetimes --- polyfactory/factories/base.py | 16 +++- .../value_generators/constrained_dates.py | 42 +++++++++- tests/constraints/test_date_constraints.py | 15 +++- .../constraints/test_datetime_constraints.py | 80 +++++++++++++++++++ 4 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 tests/constraints/test_datetime_constraints.py diff --git a/polyfactory/factories/base.py b/polyfactory/factories/base.py index 45cfd173..d95652a8 100644 --- a/polyfactory/factories/base.py +++ b/polyfactory/factories/base.py @@ -73,7 +73,7 @@ handle_constrained_collection, handle_constrained_mapping, ) -from polyfactory.value_generators.constrained_dates import handle_constrained_date +from polyfactory.value_generators.constrained_dates import handle_constrained_date, handle_constrained_datetime from polyfactory.value_generators.constrained_numbers import ( handle_constrained_decimal, handle_constrained_float, @@ -624,15 +624,15 @@ def create_factory( ) @classmethod - def get_constrained_field_value( # noqa: C901, PLR0911 + def get_constrained_field_value( # noqa: C901, PLR0911, PLR0912 cls, annotation: Any, field_meta: FieldMeta, field_build_parameters: Any | None = None, build_context: BuildContext | None = None, ) -> Any: + constraints = cast("Constraints", field_meta.constraints) try: - constraints = cast("Constraints", field_meta.constraints) if is_safe_subclass(annotation, float): return handle_constrained_float( random=cls.__random__, @@ -705,6 +705,16 @@ def get_constrained_field_value( # noqa: C901, PLR0911 build_context=build_context, ) + if is_safe_subclass(annotation, datetime): + return handle_constrained_datetime( + faker=cls.__faker__, + ge=cast("Any", constraints.get("ge")), + gt=cast("Any", constraints.get("gt")), + le=cast("Any", constraints.get("le")), + lt=cast("Any", constraints.get("lt")), + tz=cast("Any", constraints.get("tz")), + ) + if is_safe_subclass(annotation, date): return handle_constrained_date( faker=cls.__faker__, diff --git a/polyfactory/value_generators/constrained_dates.py b/polyfactory/value_generators/constrained_dates.py index 8069b05e..319deeec 100644 --- a/polyfactory/value_generators/constrained_dates.py +++ b/polyfactory/value_generators/constrained_dates.py @@ -27,15 +27,49 @@ def handle_constrained_date( :returns: A date instance. """ start_date = datetime.now(tz=tz).date() - timedelta(days=100) - if ge: + if ge is not None: start_date = ge - elif gt: + elif gt is not None: start_date = gt + timedelta(days=1) end_date = datetime.now(tz=timezone.utc).date() + timedelta(days=100) - if le: + if le is not None: end_date = le - elif lt: + elif lt is not None: end_date = lt - timedelta(days=1) return faker.date_between(start_date=start_date, end_date=end_date) + + +def handle_constrained_datetime( + faker: Faker, + ge: datetime | None = None, + gt: datetime | None = None, + le: datetime | None = None, + lt: datetime | None = None, + tz: tzinfo | None = None, +) -> datetime: + """Generates a datetime value fulfilling the expected constraints. + + :param faker: An instance of faker. + :param lt: Less than value. + :param le: Less than or equal value. + :param gt: Greater than value. + :param ge: Greater than or equal value. + :param tz: A timezone. If not provided, infers from constraint values. + + :returns: A datetime instance. + """ + start_datetime = datetime.now(tz=tz) - timedelta(days=100) + if ge: + start_datetime = ge + elif gt: + start_datetime = gt + timedelta(seconds=1) + + end_datetime = datetime.now(tz=tz) + timedelta(days=100) + if le is not None: + end_datetime = le + elif lt is not None: + end_datetime = lt - timedelta(seconds=1) + + return faker.date_time_between(start_date=start_datetime, end_date=end_datetime, tzinfo=tz) diff --git a/tests/constraints/test_date_constraints.py b/tests/constraints/test_date_constraints.py index 2bc7a51e..6b28981b 100644 --- a/tests/constraints/test_date_constraints.py +++ b/tests/constraints/test_date_constraints.py @@ -1,5 +1,4 @@ from datetime import date, timedelta -from typing import Optional import pytest from hypothesis import given @@ -14,10 +13,18 @@ dates(max_value=date.today() - timedelta(days=3)), dates(min_value=date.today()), ) -@pytest.mark.parametrize(("start", "end"), (("ge", "le"), ("gt", "lt"), ("ge", "lt"), ("gt", "le"))) +@pytest.mark.parametrize( + ("start", "end"), + ( + ("ge", "le"), + ("gt", "lt"), + ("ge", "lt"), + ("gt", "le"), + ), +) def test_handle_constrained_date( - start: Optional[str], - end: Optional[str], + start: str | None, + end: str | None, start_date: date, end_date: date, ) -> None: diff --git a/tests/constraints/test_datetime_constraints.py b/tests/constraints/test_datetime_constraints.py new file mode 100644 index 00000000..bfa97ca8 --- /dev/null +++ b/tests/constraints/test_datetime_constraints.py @@ -0,0 +1,80 @@ +"""Test datetime constraints, including Issue #734.""" + +from datetime import datetime, timedelta, timezone +from typing import Annotated + +import pytest +from annotated_types import Timezone +from hypothesis import given +from hypothesis.strategies import datetimes +from typing_extensions import Literal + +from pydantic import BaseModel, BeforeValidator, Field + +from polyfactory.factories.pydantic_factory import ModelFactory + + +@given( + datetimes(min_value=datetime(1900, 1, 1), max_value=datetime.now() - timedelta(days=3)), + datetimes(min_value=datetime.now(), max_value=datetime(2100, 1, 1)), +) +@pytest.mark.parametrize( + ("start", "end"), + ( + ("ge", "le"), + ("gt", "lt"), + ("ge", "lt"), + ("gt", "le"), + ), +) +def test_handle_constrained_datetime( + start: Literal["ge", "gt"], + end: Literal["le", "lt"], + start_datetime: datetime, + end_datetime: datetime, +) -> None: + """Test that constrained datetimes are generated correctly.""" + if start_datetime == end_datetime: + return + + kwargs: dict[Literal["ge", "gt", "le", "lt"], datetime] = {} + if start: + kwargs[start] = start_datetime + if end: + kwargs[end] = end_datetime + + class MyModel(BaseModel): + value: datetime = Field(**kwargs) # type: ignore + + class MyFactory(ModelFactory[MyModel]): ... + + result = MyFactory.build() + + assert result.value + assert isinstance(result.value, datetime), "Should be datetime.datetime, not date" + assert result.value >= start_datetime if "ge" in kwargs else result.value > start_datetime + assert result.value <= end_datetime if "le" in kwargs else result.value < end_datetime + + +def validate_datetime(value: datetime) -> datetime: + """Validator that expects a datetime object with timezone info.""" + assert isinstance(value, datetime), f"Expected datetime.datetime, got {type(value)}" + assert value.tzinfo == timezone.utc, f"Expected UTC timezone, got {value.tzinfo}" + return value + + +ValidatedDatetime = Annotated[datetime, BeforeValidator(validate_datetime), Timezone(tz=timezone.utc)] + + +def test_annotated_datetime_with_validator_and_constraint() -> None: + minimum_datetime = datetime(2030, 1, 1, tzinfo=timezone.utc) + + class MyModel(BaseModel): + dt: ValidatedDatetime = Field(gt=minimum_datetime) + + class MyModelFactory(ModelFactory[MyModel]): ... + + instance = MyModelFactory.build() + assert isinstance(instance.dt, datetime), "Should be datetime.datetime" + assert instance.dt.tzinfo == timezone.utc, "Should have UTC timezone" + assert instance.dt > minimum_datetime, "Should respect gt constraint" From a1e01c3e8a793c30cc24b41a944b7206133109fd Mon Sep 17 00:00:00 2001 From: Andrew Truong Date: Sun, 16 Nov 2025 17:32:12 +0000 Subject: [PATCH 2/2] fix: handle constrained datetimes --- .../value_generators/constrained_dates.py | 2 +- tests/constraints/test_date_constraints.py | 5 ++- .../constraints/test_datetime_constraints.py | 40 ++++++++++--------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/polyfactory/value_generators/constrained_dates.py b/polyfactory/value_generators/constrained_dates.py index 319deeec..d69847cb 100644 --- a/polyfactory/value_generators/constrained_dates.py +++ b/polyfactory/value_generators/constrained_dates.py @@ -66,7 +66,7 @@ def handle_constrained_datetime( elif gt: start_datetime = gt + timedelta(seconds=1) - end_datetime = datetime.now(tz=tz) + timedelta(days=100) + end_datetime = start_datetime + timedelta(days=30) if le is not None: end_datetime = le elif lt is not None: diff --git a/tests/constraints/test_date_constraints.py b/tests/constraints/test_date_constraints.py index 6b28981b..1dd07c77 100644 --- a/tests/constraints/test_date_constraints.py +++ b/tests/constraints/test_date_constraints.py @@ -1,4 +1,5 @@ from datetime import date, timedelta +from typing import Optional import pytest from hypothesis import given @@ -23,8 +24,8 @@ ), ) def test_handle_constrained_date( - start: str | None, - end: str | None, + start: Optional[str], + end: Optional[str], start_date: date, end_date: date, ) -> None: diff --git a/tests/constraints/test_datetime_constraints.py b/tests/constraints/test_datetime_constraints.py index bfa97ca8..1eb64eb4 100644 --- a/tests/constraints/test_datetime_constraints.py +++ b/tests/constraints/test_datetime_constraints.py @@ -1,15 +1,19 @@ """Test datetime constraints, including Issue #734.""" +import contextlib from datetime import datetime, timedelta, timezone -from typing import Annotated +from typing import Annotated, Optional import pytest from annotated_types import Timezone from hypothesis import given from hypothesis.strategies import datetimes -from typing_extensions import Literal -from pydantic import BaseModel, BeforeValidator, Field +from pydantic import BaseModel, Field, __version__ + +with contextlib.suppress(ImportError): + from pydantic import BeforeValidator + from polyfactory.factories.pydantic_factory import ModelFactory @@ -28,8 +32,8 @@ ), ) def test_handle_constrained_datetime( - start: Literal["ge", "gt"], - end: Literal["le", "lt"], + start: Optional[str], + end: Optional[str], start_datetime: datetime, end_datetime: datetime, ) -> None: @@ -37,7 +41,7 @@ def test_handle_constrained_datetime( if start_datetime == end_datetime: return - kwargs: dict[Literal["ge", "gt", "le", "lt"], datetime] = {} + kwargs: dict[str, datetime] = {} if start: kwargs[start] = start_datetime if end: @@ -52,25 +56,23 @@ class MyFactory(ModelFactory[MyModel]): ... assert result.value assert isinstance(result.value, datetime), "Should be datetime.datetime, not date" - assert result.value >= start_datetime if "ge" in kwargs else result.value > start_datetime - assert result.value <= end_datetime if "le" in kwargs else result.value < end_datetime - - -def validate_datetime(value: datetime) -> datetime: - """Validator that expects a datetime object with timezone info.""" - assert isinstance(value, datetime), f"Expected datetime.datetime, got {type(value)}" - assert value.tzinfo == timezone.utc, f"Expected UTC timezone, got {value.tzinfo}" - return value - - -ValidatedDatetime = Annotated[datetime, BeforeValidator(validate_datetime), Timezone(tz=timezone.utc)] + assert result.value >= start_datetime + assert result.value <= end_datetime +@pytest.mark.skipif(__version__.startswith("1"), reason="Pydantic v2 required") def test_annotated_datetime_with_validator_and_constraint() -> None: + def validate_datetime(value: datetime) -> datetime: + """Validator that expects a datetime object with timezone info.""" + assert isinstance(value, datetime), f"Expected datetime.datetime, got {type(value)}" + assert value.tzinfo == timezone.utc, f"Expected UTC timezone, got {value.tzinfo}" + return value + + ValidatedDatetime = Annotated[datetime, BeforeValidator(validate_datetime), Timezone(tz=timezone.utc)] minimum_datetime = datetime(2030, 1, 1, tzinfo=timezone.utc) class MyModel(BaseModel): - dt: ValidatedDatetime = Field(gt=minimum_datetime) + dt: ValidatedDatetime = Field(gt=minimum_datetime) # pyright: ignore[reportInvalidTypeForm] class MyModelFactory(ModelFactory[MyModel]): ...