Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clarify behavior around field defaults in the docs #22

Merged
merged 1 commit into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 57 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,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 <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 +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:

Expand All @@ -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:

Expand Down Expand Up @@ -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.
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