Skip to content

Commit

Permalink
Clarify behavior around field defaults in the docs
Browse files Browse the repository at this point in the history
  • Loading branch information
swrichards committed Nov 28, 2024
1 parent c3cb480 commit 26fe036
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 10 deletions.
67 changes: 58 additions & 9 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,17 @@ Key Concepts
- **Configuration Model**: A `Pydantic <https://docs.pydantic.dev/>`_ 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
from pydantic import Field
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"
Expand All @@ -95,8 +93,59 @@ 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 <https://docs.pydantic.dev/2.10/concepts/fields/#default-values>`_.

**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
Expand Down Expand Up @@ -130,8 +179,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:

Expand All @@ -155,7 +204,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:

Expand Down Expand Up @@ -258,7 +307,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.
Expand Down
1 change: 1 addition & 0 deletions testapp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 44 additions & 1 deletion tests/test_django_model_ref_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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"]}
Expand Down

0 comments on commit 26fe036

Please sign in to comment.