diff --git a/demo/forms.py b/demo/forms.py index 886ae77e..40895ccb 100644 --- a/demo/forms.py +++ b/demo/forms.py @@ -2,7 +2,7 @@ import enum from collections import defaultdict -from datetime import date +from datetime import date, timedelta from typing import Annotated, Literal, TypeAlias from fastapi import APIRouter, Request, UploadFile @@ -160,6 +160,7 @@ class BigModel(BaseModel): Annotated[int, Field(description='X Coordinate')], Annotated[int, Field(description='Y Coordinate')], ] + start_data: date = Field(default_factory=lambda: date.today() + timedelta(days=1), title='Start Date') @field_validator('name') def name_validator(cls, v: str | None) -> str: diff --git a/src/python-fastui/fastui/json_schema.py b/src/python-fastui/fastui/json_schema.py index 53ee526d..051dd6d4 100644 --- a/src/python-fastui/fastui/json_schema.py +++ b/src/python-fastui/fastui/json_schema.py @@ -2,8 +2,11 @@ import re import typing as _t +import pydantic_core import typing_extensions as _ta from pydantic import BaseModel +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue +from pydantic_core import core_schema from .components.forms import ( FormField, @@ -24,8 +27,49 @@ __all__ = 'model_json_schema_to_fields', 'SchemeLocation' +class GenerateJsonSchemaWithDefaultFactory(GenerateJsonSchema): + """Custom JSON schema including default_factory value as in + https://github.com/pydantic/pydantic/blob/main/pydantic/json_schema.py#L1046 + """ + + def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue: + """Generates a JSON schema that matches a schema with a default value. + + Args: + schema: The core schema. + + Returns: + The generated JSON schema. + """ + json_schema = self.generate_inner(schema['schema']) + + if 'default' in schema: + default = schema['default'] + elif 'default_factory' in schema: + default = schema['default_factory']() + else: + return json_schema + + try: + encoded_default = self.encode_default(default) + except pydantic_core.PydanticSerializationError: + self.emit_warning( + 'non-serializable-default', + f'Default value {default} is not JSON serializable; excluding default from JSON schema', + ) + # Return the inner schema, as though there was no default + return json_schema + + if '$ref' in json_schema: + # Since reference schemas do not support child keys, we wrap the reference schema in a single-case allOf: + return {'allOf': [json_schema], 'default': encoded_default} + else: + json_schema['default'] = encoded_default + return json_schema + + def model_json_schema_to_fields(model: _t.Type[BaseModel]) -> _t.List[FormField]: - schema = _t.cast(JsonSchemaObject, model.model_json_schema()) + schema = _t.cast(JsonSchemaObject, model.model_json_schema(schema_generator=GenerateJsonSchemaWithDefaultFactory)) defs = schema.get('$defs', {}) return list(json_schema_obj_to_fields(schema, [], [], defs)) diff --git a/src/python-fastui/tests/test_forms.py b/src/python-fastui/tests/test_forms.py index d73e3c10..7c953236 100644 --- a/src/python-fastui/tests/test_forms.py +++ b/src/python-fastui/tests/test_forms.py @@ -1,9 +1,11 @@ import enum from contextlib import asynccontextmanager +from datetime import date from io import BytesIO from typing import List, Tuple, Union import pytest +from dirty_equals import IsDate from fastapi import HTTPException from fastui import components from fastui.forms import FormFile, Textarea, fastui_form @@ -522,3 +524,28 @@ def test_form_description_leakage(): 'submitUrl': '/foobar/', 'type': 'ModelForm', } + + +class FormFieldsDefaultFactory(BaseModel): + start_date: date = Field(title='Start Date', default_factory=lambda: date.today()) + + +def test_form_fields_default_factory(): + m = components.ModelForm(model=FormFieldsDefaultFactory, submit_url='/foobar/') + + assert m.model_dump(by_alias=True, exclude_none=True) == { + 'formFields': [ + { + 'htmlType': 'date', + 'initial': IsDate(approx=date.today(), iso_string=True), + 'locked': False, + 'name': 'start_date', + 'required': False, + 'title': ['Start Date'], + 'type': 'FormFieldInput', + } + ], + 'method': 'POST', + 'submitUrl': '/foobar/', + 'type': 'ModelForm', + }