From 6366a5129bb61903af89d76a9eec3cd52c7d75d2 Mon Sep 17 00:00:00 2001 From: Michael Bianco Date: Tue, 25 Mar 2025 07:20:25 -0600 Subject: [PATCH 1/4] feat: add NeverNone to always generate an optional field --- polyfactory/__init__.py | 3 ++- polyfactory/factories/base.py | 18 ++++++++++++++---- polyfactory/fields.py | 4 ++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/polyfactory/__init__.py b/polyfactory/__init__.py index 0a2269ea..5a7a5971 100644 --- a/polyfactory/__init__.py +++ b/polyfactory/__init__.py @@ -1,6 +1,6 @@ from .exceptions import ConfigurationException from .factories import BaseFactory -from .fields import Fixture, Ignore, PostGenerated, Require, Use +from .fields import Fixture, Ignore, PostGenerated, Require, Use, NeverNone from .persistence import AsyncPersistenceProtocol, SyncPersistenceProtocol __all__ = ( @@ -11,6 +11,7 @@ "Ignore", "PostGenerated", "Require", + "NeverNone", "SyncPersistenceProtocol", "Use", ) diff --git a/polyfactory/factories/base.py b/polyfactory/factories/base.py index 09058037..40b347e8 100644 --- a/polyfactory/factories/base.py +++ b/polyfactory/factories/base.py @@ -54,7 +54,7 @@ ) from polyfactory.exceptions import ConfigurationException, MissingBuildKwargException, ParameterException from polyfactory.field_meta import Null -from polyfactory.fields import Fixture, Ignore, PostGenerated, Require, Use +from polyfactory.fields import Fixture, Ignore, NeverNone, PostGenerated, Require, Use from polyfactory.utils.helpers import ( flatten_annotation, get_collection_type, @@ -334,6 +334,7 @@ def _handle_factory_field( # noqa: PLR0911 if isinstance(field_value, Fixture): return field_value.to_value() + # if a raw lambda is passed, invoke it if callable(field_value): return field_value() @@ -946,8 +947,12 @@ def should_set_none_value(cls, field_meta: FieldMeta) -> bool: :returns: A boolean determining whether 'None' should be set for the given field_meta. """ + field_value = hasattr(cls, field_meta.name) and getattr(cls, field_meta.name) + never_none = field_value and isinstance(field_value, NeverNone) + return ( cls.__allow_none_optionals__ + and not never_none and is_optional(field_meta.annotation) and create_random_boolean(random=cls.__random__) ) @@ -1021,13 +1026,15 @@ def _check_declared_fields_exist_in_model(cls) -> None: f"{field_name} is declared on the factory {cls.__name__}" f" but it is not part of the model {cls.__model__.__name__}" ) - if isinstance(field_value, (Use, PostGenerated, Ignore, Require)): + if isinstance(field_value, (Use, PostGenerated, Ignore, Require, NeverNone)): raise ConfigurationException(error_message) @classmethod def process_kwargs(cls, **kwargs: Any) -> dict[str, Any]: """Process the given kwargs and generate values for the factory's model. + If you need to deeply customize field values, you'll want to override this method. + :param kwargs: Any build kwargs. :returns: A dictionary of build results. @@ -1038,8 +1045,11 @@ def process_kwargs(cls, **kwargs: Any) -> dict[str, Any]: for field_meta in cls.get_model_fields(): field_build_parameters = cls.extract_field_build_parameters(field_meta=field_meta, build_args=kwargs) if cls.should_set_field_value(field_meta, **kwargs) and not cls.should_use_default_value(field_meta): - if hasattr(cls, field_meta.name) and not hasattr(BaseFactory, field_meta.name): - field_value = getattr(cls, field_meta.name) + field_value = getattr(cls, field_meta.name, None) + + # TODO why do we need the BaseFactory check here, only dunder methods which are ignored would trigger this? + # NeverNone should be treated as a normally-generated field + if field_value and not hasattr(BaseFactory, field_meta.name) and not isinstance(field_value, NeverNone): if isinstance(field_value, Ignore): continue diff --git a/polyfactory/fields.py b/polyfactory/fields.py index 6598e450..2de3c98b 100644 --- a/polyfactory/fields.py +++ b/polyfactory/fields.py @@ -22,6 +22,10 @@ class Require: """A factory field that marks an attribute as a required build-time kwarg.""" +class NeverNone: + """A factory field that marks as always generated, even if it's an optional""" + + class Ignore: """A factory field that marks an attribute as ignored.""" From 708a4358773851fe4f3904ec75db76c4991f7549 Mon Sep 17 00:00:00 2001 From: Michael Bianco Date: Tue, 25 Mar 2025 07:26:52 -0600 Subject: [PATCH 2/4] fix: minor typo fix --- tests/test_optional_model_field_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_optional_model_field_inference.py b/tests/test_optional_model_field_inference.py index 7bb318fe..14f2282c 100644 --- a/tests/test_optional_model_field_inference.py +++ b/tests/test_optional_model_field_inference.py @@ -72,7 +72,7 @@ class TypedDictBase(TypedDict): (TypedDictFactory, TypedDictBase), ], ) -def test_modeL_inference_ok(base_factory: Type[BaseFactory], generic_arg: Type[Any]) -> None: +def test_model_inference_ok(base_factory: Type[BaseFactory], generic_arg: Type[Any]) -> None: class Foo(base_factory[generic_arg]): # type: ignore ... From baf381783a505695c6df6f1e9e95ad9c846b28f2 Mon Sep 17 00:00:00 2001 From: Michael Bianco Date: Thu, 17 Apr 2025 07:53:16 -0600 Subject: [PATCH 3/4] feat: add AlwaysNone --- polyfactory/__init__.py | 5 +++-- polyfactory/factories/base.py | 17 ++++++++++++----- polyfactory/fields.py | 16 ++++++++++++++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/polyfactory/__init__.py b/polyfactory/__init__.py index 5a7a5971..6c1a904c 100644 --- a/polyfactory/__init__.py +++ b/polyfactory/__init__.py @@ -1,17 +1,18 @@ from .exceptions import ConfigurationException from .factories import BaseFactory -from .fields import Fixture, Ignore, PostGenerated, Require, Use, NeverNone +from .fields import AlwaysNone, Fixture, Ignore, NeverNone, PostGenerated, Require, Use from .persistence import AsyncPersistenceProtocol, SyncPersistenceProtocol __all__ = ( + "AlwaysNone", "AsyncPersistenceProtocol", "BaseFactory", "ConfigurationException", "Fixture", "Ignore", + "NeverNone", "PostGenerated", "Require", - "NeverNone", "SyncPersistenceProtocol", "Use", ) diff --git a/polyfactory/factories/base.py b/polyfactory/factories/base.py index 40b347e8..69d739b5 100644 --- a/polyfactory/factories/base.py +++ b/polyfactory/factories/base.py @@ -54,7 +54,7 @@ ) from polyfactory.exceptions import ConfigurationException, MissingBuildKwargException, ParameterException from polyfactory.field_meta import Null -from polyfactory.fields import Fixture, Ignore, NeverNone, PostGenerated, Require, Use +from polyfactory.fields import AlwaysNone, Fixture, Ignore, NeverNone, PostGenerated, Require, Use from polyfactory.utils.helpers import ( flatten_annotation, get_collection_type, @@ -949,6 +949,10 @@ def should_set_none_value(cls, field_meta: FieldMeta) -> bool: """ field_value = hasattr(cls, field_meta.name) and getattr(cls, field_meta.name) never_none = field_value and isinstance(field_value, NeverNone) + always_none = field_value and isinstance(field_value, AlwaysNone) + + if always_none: + return True return ( cls.__allow_none_optionals__ @@ -1026,7 +1030,7 @@ def _check_declared_fields_exist_in_model(cls) -> None: f"{field_name} is declared on the factory {cls.__name__}" f" but it is not part of the model {cls.__model__.__name__}" ) - if isinstance(field_value, (Use, PostGenerated, Ignore, Require, NeverNone)): + if isinstance(field_value, (Use, PostGenerated, Ignore, Require, NeverNone, AlwaysNone)): raise ConfigurationException(error_message) @classmethod @@ -1047,9 +1051,12 @@ def process_kwargs(cls, **kwargs: Any) -> dict[str, Any]: if cls.should_set_field_value(field_meta, **kwargs) and not cls.should_use_default_value(field_meta): field_value = getattr(cls, field_meta.name, None) - # TODO why do we need the BaseFactory check here, only dunder methods which are ignored would trigger this? - # NeverNone should be treated as a normally-generated field - if field_value and not hasattr(BaseFactory, field_meta.name) and not isinstance(field_value, NeverNone): + # NeverNone & AlwaysNone should be treated as a normally-generated field, since this changes logic + # within get_field_value. + excluded_field_value = field_value and isinstance(field_value, (NeverNone, AlwaysNone)) + + # TODO why do we need the BaseFactory check here, only dunder methods which are ignored would trigger this? # noqa: FIX002 + if field_value and not hasattr(BaseFactory, field_meta.name) and not excluded_field_value: if isinstance(field_value, Ignore): continue diff --git a/polyfactory/fields.py b/polyfactory/fields.py index 2de3c98b..00e85f52 100644 --- a/polyfactory/fields.py +++ b/polyfactory/fields.py @@ -23,11 +23,23 @@ class Require: class NeverNone: - """A factory field that marks as always generated, even if it's an optional""" + """A factory field that marks as always generated, even if it's an optional.""" + + +class AlwaysNone: + """A factory field that marks as never generated, setting the value to None, regardless of if it's an optional field + + This is distinct from Ignore() which does not set a value for a field at all. If Ignore() is used and a default value + for a field is not set on the underlying model, then the field will not be set at all. + """ class Ignore: - """A factory field that marks an attribute as ignored.""" + """A factory field that marks an attribute as ignored. This prevents the factory generating any value for this field. + + If you are using this on a pydantic model this will cause the field to be omitted from the resulting pydantic model + if there is no default value set for the pydantic field. + """ class Use(Generic[P, T]): From 5b004ee3904b0f15e2b5b17bf2b253793266c184 Mon Sep 17 00:00:00 2001 From: Michael Bianco Date: Thu, 17 Apr 2025 07:59:13 -0600 Subject: [PATCH 4/4] test: additional tests for none field hints --- polyfactory/factories/base.py | 10 ++++++---- tests/test_nones.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 tests/test_nones.py diff --git a/polyfactory/factories/base.py b/polyfactory/factories/base.py index 69d739b5..d18d5ef3 100644 --- a/polyfactory/factories/base.py +++ b/polyfactory/factories/base.py @@ -1037,7 +1037,8 @@ def _check_declared_fields_exist_in_model(cls) -> None: def process_kwargs(cls, **kwargs: Any) -> dict[str, Any]: """Process the given kwargs and generate values for the factory's model. - If you need to deeply customize field values, you'll want to override this method. + If you need to deeply customize field values, you'll want to override this method. This is where values are + generated and assigned for the fields on the model. :param kwargs: Any build kwargs. @@ -1049,14 +1050,15 @@ def process_kwargs(cls, **kwargs: Any) -> dict[str, Any]: for field_meta in cls.get_model_fields(): field_build_parameters = cls.extract_field_build_parameters(field_meta=field_meta, build_args=kwargs) if cls.should_set_field_value(field_meta, **kwargs) and not cls.should_use_default_value(field_meta): - field_value = getattr(cls, field_meta.name, None) + has_field_value = hasattr(cls, field_meta.name) + field_value = has_field_value and getattr(cls, field_meta.name) # NeverNone & AlwaysNone should be treated as a normally-generated field, since this changes logic # within get_field_value. - excluded_field_value = field_value and isinstance(field_value, (NeverNone, AlwaysNone)) + excluded_field_value = has_field_value and isinstance(field_value, (NeverNone, AlwaysNone)) # TODO why do we need the BaseFactory check here, only dunder methods which are ignored would trigger this? # noqa: FIX002 - if field_value and not hasattr(BaseFactory, field_meta.name) and not excluded_field_value: + if has_field_value and not hasattr(BaseFactory, field_meta.name) and not excluded_field_value: if isinstance(field_value, Ignore): continue diff --git a/tests/test_nones.py b/tests/test_nones.py new file mode 100644 index 00000000..43d5ca9a --- /dev/null +++ b/tests/test_nones.py @@ -0,0 +1,28 @@ +from typing import Optional + +from pydantic import BaseModel + +from polyfactory.factories.pydantic_factory import ModelFactory +from polyfactory.fields import AlwaysNone, NeverNone + + +def test_never_none() -> None: + class MyModel(BaseModel): + name: Optional[str] + + class MyFactory(ModelFactory[MyModel]): + name = NeverNone() + + assert MyFactory.build().name is not None + + +def test_always_none() -> None: + class MyModel(BaseModel): + name: Optional[str] + + class MyFactory(ModelFactory[MyModel]): + name = AlwaysNone() + # NOTE `name = None` does not end up + + # field is still accessible even though there is + assert MyFactory.build().name is None