From 38ca36f7f4d672a81009b5a0e7623ba3cfef5f7c Mon Sep 17 00:00:00 2001 From: Sidney Richards Date: Wed, 27 Nov 2024 15:12:24 +0100 Subject: [PATCH] Clarify behavior around field defaults in the docs --- README.rst | 66 ++++++++++++++++++++++++---- testapp/models.py | 1 + tests/test_django_model_ref_field.py | 45 ++++++++++++++++++- 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 881b9ca..451c97f 100644 --- a/README.rst +++ b/README.rst @@ -63,11 +63,9 @@ Key Concepts - **Configuration Model**: A `Pydantic `_ model defining the structure and validation rules for your configuration. - **Configuration Step**: A class that implements the actual configuration logic using the validated configuration model. -Getting Started ---------------- Define a Configuration Model -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +---------------------------- .. code-block:: python @@ -75,7 +73,7 @@ Define a Configuration Model from django_setup_configuration import ConfigurationModel, DjangoModelRef class UserConfigurationModel(ConfigurationModel): - # Use Pydantic's validation features + # A regular Pydantic field add_to_groups: list[str] = Field( default_factory=list, description="Groups to add the user to" @@ -95,8 +93,58 @@ Define a Configuration Model User: ["password"] } + +Field Defaults +^^^^^^^^^^^^^^ + +For regular Pydantic fields, you must explicitly configure defaults using `Field +(default=...)` or `Field(default_factory=lambda: ...)` as specified in the `Pydantic +documentation `_. + +**NOTE:** Marking a field as ``Optional`` or using ``... | None`` does *not* automatically +set the field's default to `None`. You must set this explicitly if you want the field to +be optional: + +.. code-block:: python + + from pydantic import Field + + class ConfigModel(ConfigurationModel): + optional_field: int | None = DjangoModelRef(SomeModel, "some_field", default=None) + +For ``DjangoModelRef``, the default value handling follows these rules: + +You can provide explicit defaults using the ``default`` or ``default_factory`` kwargs, +similar to regular Pydantic fields: + +.. code-block:: python + + class ConfigModel(ConfigurationModel): + # Explicit string default + field_with_explicit_default = DjangoModelRef(SomeModel, "some_field", default="foobar") + + # Explicit default factory for a list + field_with_explicit_default_factory: list[str] = DjangoModelRef( + SomeModel, "some_other_field", default_factory=list + ) + +When no explicit default is provided, the default is derived from the referenced Django field: + +1. If the Django field has an explicit default, that default will be used. + +2. If no explicit default is set but the field has ``null=True`` set: + + a. The default will be set to ``None`` + b. The field will be optional + +3. If no explicit default is provided and the field is not nullable, but has ``blank=True`` **and** it is a string-type field: + + a. The default will be an empty string + b. The field will be optional + + Create a Configuration Step -^^^^^^^^^^^^^^^^^^^^^^^^^^^ +--------------------------- .. code-block:: python @@ -130,8 +178,8 @@ Create a Configuration Step group = Group.objects.get(name=group_name) group.user_set.add(user) -Configuration File -^^^^^^^^^^^^^^^^^^ +Configuration Source +-------------------- Create a YAML configuration file with your settings: @@ -155,7 +203,7 @@ keys are exclusively used for the steps' ``enable_setting`` key, and the ``names key which encapsulates the configuration model's attributes. Step Registration -^^^^^^^^^^^^^^^^^ +----------------- Register your configuration steps in Django settings: @@ -258,7 +306,7 @@ Using Test Helpers # Add assertions Best Practices --------------- +============== - **Idempotency**: Design steps that can be run multiple times without unintended side effects. - **Validation**: You can use the full range of Pydantic's validation capabilities. diff --git a/testapp/models.py b/testapp/models.py index be3c6d8..dcecc3f 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -10,6 +10,7 @@ class TestModel(models.Model): required_int = models.IntegerField() int_with_default = models.IntegerField(default=42) nullable_int = models.IntegerField(null=True) + nullable_int_with_default = models.IntegerField(default=42) nullable_str = models.CharField(null=True, blank=False, max_length=1) nullable_and_blank_str = models.CharField(null=True, blank=False, max_length=1) blank_str = models.CharField(null=False, blank=True, max_length=1) diff --git a/tests/test_django_model_ref_field.py b/tests/test_django_model_ref_field.py index 3ff1871..c64af66 100644 --- a/tests/test_django_model_ref_field.py +++ b/tests/test_django_model_ref_field.py @@ -77,6 +77,34 @@ class Meta: assert field.is_required() is False +def test_explicit_default_overrides_model_field_default(): + + class Config(ConfigurationModel): + int_with_default = DjangoModelRef(TestModel, "int_with_default") + int_with_overridden_default = DjangoModelRef( + TestModel, "int_with_default", default=1874 + ) + + int_with_default_field = Config.model_fields["int_with_default"] + int_with_overridden_default_field = Config.model_fields[ + "int_with_overridden_default" + ] + + assert int_with_default_field.default == 42 + assert int_with_overridden_default_field.default == 1874 + + assert ( + int_with_default_field.annotation + == int_with_overridden_default_field.annotation + == int + ) + assert ( + int_with_default_field.is_required() + is int_with_overridden_default_field.is_required() + is False + ) + + def test_null_is_true_sets_default_to_none(): class Config(ConfigurationModel): @@ -92,6 +120,21 @@ class Meta: assert field.is_required() is False +def test_null_prefers_explicit_default(): + + class Config(ConfigurationModel): + class Meta: + django_model_refs = {TestModel: ["nullable_int_with_default"]} + + field = Config.model_fields["nullable_int_with_default"] + + assert field.title == "nullable int with default" + assert field.description is None + assert field.annotation == int + assert field.default == 42 + assert field.is_required() is False + + def test_null_is_true_sets_default_to_none_for_str_fields(): class Config(ConfigurationModel): @@ -107,7 +150,7 @@ class Meta: assert field.is_required() is False -def test_blank_is_true_null_is_false_sets_default_to_none_for_str_fields(): +def test_blank_is_true_null_is_false_sets_default_to_empty_str_for_str_fields(): class Config(ConfigurationModel): class Meta: django_model_refs = {TestModel: ["blank_str"]}