Skip to content

Commit 362d9cc

Browse files
authored
Fix FormPage frozen field handling + Add more pydantic versions to test matrix (#47)
* Update test matrix * Update matrix * Fix unittest for older pydantic version * Change unittests to have different expected schema depending on the version of pydantic * Fix #39: rebuild model in init class + access model_fields through class * Fix #39: rebuild model in init class + access model_fields through class * Bump version to 2.1.2
1 parent fce50f3 commit 362d9cc

File tree

12 files changed

+194
-15
lines changed

12 files changed

+194
-15
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 2.1.1
2+
current_version = 2.1.2
33
commit = False
44
tag = False
55
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)((\-rc)(?P<build>\d+))?

.github/workflows/run-unit-tests.yml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,41 @@ on:
55

66
jobs:
77
container_job:
8-
name: Unit tests
8+
name: Unit tests (Python ${{ matrix.python-version }}, Pydantic ${{ matrix.pydantic-version }}, FastAPI ${{ matrix.fastapi-version }})
99
runs-on: ubuntu-latest
1010
strategy:
1111
matrix:
1212
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
1313
pydantic-version: ['lockfile']
14-
fastapi-version: ['0.103.2', 'lockfile']
14+
fastapi-version: ['lockfile']
15+
# Test specific older releases to avoid an extremely large test matrix
16+
include:
17+
# Older pydantic releases with the oldest + latest supported python version
18+
- python-version: '3.13'
19+
pydantic-version: '2.10.6'
20+
fastapi-version: 'lockfile'
21+
- python-version: '3.9'
22+
pydantic-version: '2.10.6'
23+
fastapi-version: 'lockfile'
24+
- python-version: '3.13'
25+
pydantic-version: '2.9.2'
26+
fastapi-version: 'lockfile'
27+
- python-version: '3.9'
28+
pydantic-version: '2.9.2'
29+
fastapi-version: 'lockfile'
30+
- python-version: '3.13'
31+
pydantic-version: '2.8.2'
32+
fastapi-version: 'lockfile'
33+
- python-version: '3.9'
34+
pydantic-version: '2.8.2'
35+
fastapi-version: 'lockfile'
36+
# Older fastapi releases with the oldest + latest supported python version
37+
- python-version: '3.13'
38+
pydantic-version: 'lockfile'
39+
fastapi-version: '0.103.2'
40+
- python-version: '3.9'
41+
pydantic-version: 'lockfile'
42+
fastapi-version: '0.103.2'
1543
fail-fast: false
1644
container: python:${{ matrix.python-version }}-slim
1745
steps:

pydantic_forms/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313

1414
"""Pydantic-forms engine."""
1515

16-
__version__ = "2.1.1"
16+
__version__ = "2.1.2"

pydantic_forms/core/shared.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from typing import Any, Callable
1515

1616
import structlog
17-
from pydantic import BaseModel, ConfigDict
17+
from pydantic import BaseModel, ConfigDict, PydanticUndefinedAnnotation
1818

1919
logger = structlog.get_logger(__name__)
2020

@@ -32,7 +32,7 @@ class FormPage(BaseModel):
3232
)
3333

3434
def __init__(self, **data: Any):
35-
frozen_fields = {k: v for k, v in self.model_fields.items() if v.frozen}
35+
frozen_fields = {k: v for k, v in self.__class__.model_fields.items() if v.frozen}
3636

3737
def get_value(k: str, v: Any) -> Any:
3838
if k in frozen_fields:
@@ -47,10 +47,30 @@ def __pydantic_init_subclass__(cls, /, **kwargs: Any) -> None:
4747
# The default and requiredness of a field is not a property of a field
4848
# In the case of DisplayOnlyFieldTypes, we do kind of want that.
4949
# Using this method we set the right properties after the form is created
50+
needs_rebuild = False
5051

5152
for field in cls.model_fields.values():
5253
if field.frozen:
5354
field.validate_default = False
55+
needs_rebuild = True
56+
57+
if needs_rebuild:
58+
try:
59+
# Fix for #39:
60+
# Core schema used during validation is constructed before __pydantic_init_subclass__ which means
61+
# that the field.validate_default change above doesn't take effect.
62+
# As a workaround, we explicitly rebuild the model to reconstruct the core schema.
63+
#
64+
# Downside is that unresolved forward-refs trigger an exception; for now we catch/log/ignore it.
65+
# From pydantic 2.12 a new hook __pydantic_on_complete__ can be used to perform the rebuild instead.
66+
# https://github.com/pydantic/pydantic/pull/11762
67+
cls.model_rebuild(force=True)
68+
except PydanticUndefinedAnnotation as exc:
69+
logger.warning(
70+
"Failed to rebuild model due to undefined annotation, frozen fields may not work as expected",
71+
undefined_annotation=exc.name,
72+
model=cls.__name__,
73+
)
5474

5575

5676
FORMS: dict[str, Callable] = {}

tests/unit_tests/helpers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import pydantic.version
2+
3+
PYDANTIC_VERSION = pydantic.version.version_short()
4+
5+
16
def assert_equal_ignore_key(expected, actual, ignore_keys):
27
def deep_remove_keys(d, keys_to_ignore):
38
if isinstance(d, dict):

tests/unit_tests/test_choice_list.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from pydantic_forms.core import FormPage
55
from pydantic_forms.validators import Choice, choice_list
66

7+
from tests.unit_tests.helpers import PYDANTIC_VERSION
8+
79

810
def test_choice_list():
911
class LegChoice(Choice):
@@ -149,13 +151,17 @@ def test_choice_list_constraint_at_least_one_item(Form):
149151
Form(choice=[])
150152

151153
errors = exc_info.value.errors(include_url=False, include_context=False)
154+
155+
if PYDANTIC_VERSION == "2.8":
156+
message = "List should have at least 1 item after validation, not 0"
157+
else:
158+
message = "Value should have at least 1 item after validation, not 0"
152159
expected = [
153160
{
154161
"input": [],
155162
"loc": ("choice",),
156-
"msg": "Value should have at least 1 item after validation, not 0",
163+
"msg": message,
157164
"type": "too_short",
158-
# "ctx": {"limit_value": 1},
159165
}
160166
]
161167
assert errors == expected

tests/unit_tests/test_constrained_list.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
from pydantic_forms.core import FormPage
55
from pydantic_forms.validators import unique_conlist
6+
from pydantic.version import version_short as pydantic_version_short
7+
8+
from tests.unit_tests.helpers import PYDANTIC_VERSION
69

710

811
def test_constrained_list_good():
@@ -74,12 +77,15 @@ def test_constrained_list_too_short():
7477

7578
errors = exc_info.value.errors(include_url=False, include_context=False)
7679

80+
if PYDANTIC_VERSION == "2.8":
81+
message = "List should have at least 1 item after validation, not 0"
82+
else:
83+
message = "Value should have at least 1 item after validation, not 0"
7784
expected = [
7885
{
79-
# "ctx": {"error": ListMinLengthError(limit_value=1)},
8086
"input": [],
8187
"loc": ("v",),
82-
"msg": "Value should have at least 1 item after validation, not 0",
88+
"msg": message,
8389
"type": "too_short",
8490
}
8591
]
@@ -111,11 +117,15 @@ class UniqueConListModel(FormPage):
111117
UniqueConListModel(v=[])
112118

113119
errors = exc_info.value.errors(include_url=False, include_context=False)
120+
if PYDANTIC_VERSION == "2.8":
121+
message = "List should have at least 1 item after validation, not 0"
122+
else:
123+
message = "Value should have at least 1 item after validation, not 0"
114124
assert errors == [
115125
{
116126
"input": [],
117127
"loc": ("v",),
118-
"msg": "Value should have at least 1 item after validation, not 0",
128+
"msg": message,
119129
"type": "too_short",
120130
# "ctx": {"limit_value": 1},
121131
}

tests/unit_tests/test_display_subscription.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pydantic_forms.core import FormPage
44
from pydantic_forms.validators import DisplaySubscription, Label, migration_summary
5+
from tests.unit_tests.helpers import PYDANTIC_VERSION
56

67

78
def test_display_subscription():
@@ -35,6 +36,11 @@ class Form(FormPage):
3536
label: Label
3637
summary: Summary
3738

39+
if PYDANTIC_VERSION == "2.8":
40+
summary_ref = {"allOf": [{"$ref": "#/$defs/MigrationSummaryValue"}]}
41+
else:
42+
summary_ref = {"$ref": "#/$defs/MigrationSummaryValue"}
43+
3844
expected = {
3945
"$defs": {"MigrationSummaryValue": {"properties": {}, "title": "MigrationSummaryValue", "type": "object"}},
4046
"additionalProperties": False,
@@ -53,7 +59,7 @@ class Form(FormPage):
5359
"type": "string",
5460
},
5561
"summary": {
56-
"$ref": "#/$defs/MigrationSummaryValue",
62+
**summary_ref,
5763
"default": None,
5864
"format": "summary",
5965
"type": "string",

tests/unit_tests/test_list_of_two.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from pydantic_forms.core import FormPage
55
from pydantic_forms.validators import ListOfTwo
6+
from tests.unit_tests.helpers import PYDANTIC_VERSION
67

78

89
@pytest.fixture(name="Form")
@@ -22,11 +23,15 @@ def test_list_of_two_min_items(Form):
2223
assert Form(two=[1])
2324

2425
errors = error_info.value.errors(include_url=False, include_context=False)
26+
if PYDANTIC_VERSION == "2.8":
27+
message = "List should have at least 2 items after validation, not 1"
28+
else:
29+
message = "Value should have at least 2 items after validation, not 1"
2530
expected = [
2631
{
2732
"input": [1],
2833
"loc": ("two",),
29-
"msg": "Value should have at least 2 items after validation, not 1",
34+
"msg": message,
3035
"type": "too_short",
3136
}
3237
]
@@ -38,11 +43,15 @@ def test_list_of_two_max_items(Form):
3843
assert Form(two=[1, 2, 3])
3944

4045
errors = error_info.value.errors(include_url=False, include_context=False)
46+
if PYDANTIC_VERSION == "2.8":
47+
message = "List should have at most 2 items after validation, not 3"
48+
else:
49+
message = "Value should have at most 2 items after validation, not 3"
4150
expected = [
4251
{
4352
"input": [1, 2, 3],
4453
"loc": ("two",),
45-
"msg": "Value should have at most 2 items after validation, not 3",
54+
"msg": message,
4655
"type": "too_long",
4756
},
4857
]

tests/unit_tests/test_migration_summary.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pydantic_forms.core import FormPage
44
from pydantic_forms.validators import DisplaySubscription, Label, migration_summary
5+
from tests.unit_tests.helpers import PYDANTIC_VERSION
56

67

78
def test_display_default():
@@ -33,12 +34,17 @@ def test_migration_summary_schema():
3334
class Form(FormPage):
3435
ms: Summary
3536

37+
if PYDANTIC_VERSION == "2.8":
38+
ms_ref = {"allOf": [{"$ref": "#/$defs/MigrationSummaryValue"}]}
39+
else:
40+
ms_ref = {"$ref": "#/$defs/MigrationSummaryValue"}
41+
3642
expected = {
3743
"$defs": {"MigrationSummaryValue": {"properties": {}, "title": "MigrationSummaryValue", "type": "object"}},
3844
"additionalProperties": False,
3945
"properties": {
4046
"ms": {
41-
"$ref": "#/$defs/MigrationSummaryValue",
47+
**ms_ref,
4248
"default": None,
4349
"format": "summary",
4450
"type": "string",

tests/unit_tests/test_read_only_field.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
import pytest
66
from pydantic import BaseModel, ValidationError
77

8+
from pydantic.version import version_short as pydantic_version_short
9+
810
from pydantic_forms.core import FormPage
911
from pydantic_forms.types import strEnum
1012
from pydantic_forms.validators import read_only_field, read_only_list, LongText, OrganisationId
1113
from pydantic_forms.utils.schema import merge_json_schema
14+
from tests.unit_tests.helpers import PYDANTIC_VERSION
1215

1316

1417
class TestEnum(strEnum):
@@ -33,11 +36,19 @@ def test_read_only_field_schema(read_only_value, schema_value, schema_type, othe
3336
class Form(FormPage):
3437
read_only: read_only_field(read_only_value)
3538

39+
if PYDANTIC_VERSION in ("2.8", "2.9"):
40+
# Field that was removed in 2.10 https://github.com/pydantic/pydantic/pull/10692
41+
enum_value = str(read_only_value) if isinstance(read_only_value, UUID) else read_only_value
42+
enum = {"enum": [enum_value]}
43+
else:
44+
enum = {}
45+
3646
expected = {
3747
"title": "unknown",
3848
"type": "object",
3949
"properties": {
4050
"read_only": {
51+
**enum,
4152
"const": schema_value,
4253
"default": schema_value,
4354
"title": "Read Only",

tests/unit_tests/test_shared.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from typing import Annotated
2+
3+
import pytest
4+
from pydantic import Field, BaseModel
5+
from pydantic_core import ValidationError
6+
7+
from pydantic_forms.core import FormPage
8+
9+
10+
def regex_field_should_be_int(field_name: str) -> str:
11+
# Multiline regex to match pydantic's validation error for an int field
12+
return rf"(?m)\n{field_name}\n\s+Input should be a valid integer"
13+
14+
15+
def test_formpage_field_defaults_are_validated():
16+
"""Test that default values in the FormPage class are validated."""
17+
18+
class TestForm(FormPage):
19+
int_field: int = "foo"
20+
21+
with pytest.raises(ValidationError, match=regex_field_should_be_int("int_field")):
22+
_ = TestForm().model_dump()
23+
24+
25+
def test_formpage_frozen_field_default_not_validated():
26+
"""Test that default values in the FormPage class are not validated when the field is frozen."""
27+
28+
class TestForm(FormPage):
29+
int_field: int = Field("foo", frozen=True)
30+
31+
assert TestForm().model_dump() == {"int_field": "foo"}
32+
33+
34+
class TestFormWithForwardRef(FormPage):
35+
# Needs to be at module level for the forward-ref stuff to work
36+
bar_field: "Bar"
37+
int_field: int = "foo"
38+
39+
40+
class TestFormWithForwardRefAndFrozenField(FormPage):
41+
# Needs to be at module level for the forward-ref stuff to work
42+
bar_field: "Bar"
43+
int_field: int = Field("foo", frozen=True)
44+
45+
46+
class Bar(FormPage):
47+
sub_int_field: int
48+
49+
50+
def test_formpage_with_forward_ref():
51+
"""Test that default values in the FormPage class are validated when there is a forward-ref field."""
52+
with pytest.raises(ValidationError, match=regex_field_should_be_int("int_field")):
53+
_ = TestFormWithForwardRef(bar_field={"sub_int_field": 3}).model_dump()
54+
55+
56+
def test_formpage_with_forward_ref_and_frozen_field():
57+
"""Test that default values in the FormPage class are not validated when one field is frozen and another is an
58+
unresolved forward-ref.
59+
60+
Relates to issue #39 which revealed that the way we previously changed `validate_default=False` for frozen
61+
fields did not work. The workaround implemented was to call `model_rebuild()` in __pydantic_init_subclass__, but the
62+
problem with that is it raises a PydanticUndefinedAnnotation for unresolved forward-refs.
63+
64+
In that case, the FormPage should catch the exception and proceed.
65+
"""
66+
assert TestFormWithForwardRefAndFrozenField(bar_field={"sub_int_field": 3}).model_dump() == {
67+
"bar_field": {"sub_int_field": 3},
68+
"int_field": "foo",
69+
}
70+
71+
72+
def test_formpage_frozen_field_uses_default():
73+
"""Test that default values in the FormPage class take precedence over the input value."""
74+
75+
class TestForm(FormPage):
76+
int_field: int = Field(1, frozen=True)
77+
78+
assert TestForm(int_field=2).model_dump() == {"int_field": 1}

0 commit comments

Comments
 (0)