Skip to content

Fix compatibility with Pydantic 2.10 #377

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
33 changes: 24 additions & 9 deletions src/python-fastui/fastui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ..base import BaseModel
from .display import Details, Display
from .forms import (
BaseForm,
Form,
FormField,
FormFieldBoolean,
Expand Down Expand Up @@ -103,7 +104,7 @@ class PageTitle(BaseModel, extra='forbid'):
"""The type of the component. Always 'PageTitle'."""


class Div(BaseModel, extra='forbid'):
class Div(BaseModel, defer_build=True, extra='forbid'):
"""A generic container component."""

components: '_t.List[AnyComponent]'
Expand All @@ -116,7 +117,7 @@ class Div(BaseModel, extra='forbid'):
"""The type of the component. Always 'Div'."""


class Page(BaseModel, extra='forbid'):
class Page(BaseModel, defer_build=True, extra='forbid'):
"""Similar to `container` in many UI frameworks, this acts as a root component for most pages."""

components: '_t.List[AnyComponent]'
Expand Down Expand Up @@ -153,7 +154,8 @@ def __get_pydantic_json_schema__(
) -> _t.Any:
# until https://github.com/pydantic/pydantic/issues/8413 is fixed
json_schema = handler(core_schema)
json_schema['required'].append('level')
schema_def = handler.resolve_ref_schema(json_schema)
schema_def['required'].append('level')
return json_schema


Expand Down Expand Up @@ -240,7 +242,7 @@ class Button(BaseModel, extra='forbid'):
"""The type of the component. Always 'Button'."""


class Link(BaseModel, extra='forbid'):
class Link(BaseModel, defer_build=True, extra='forbid'):
"""Link component."""

components: '_t.List[AnyComponent]'
Expand Down Expand Up @@ -308,7 +310,8 @@ def __get_pydantic_json_schema__(
) -> _t.Any:
# until https://github.com/pydantic/pydantic/issues/8413 is fixed
json_schema = handler(core_schema)
json_schema.setdefault('required', []).extend(['startLinks', 'endLinks'])
schema_def = handler.resolve_ref_schema(json_schema)
schema_def.setdefault('required', []).extend(['startLinks', 'endLinks'])
return json_schema


Expand All @@ -328,7 +331,7 @@ class Footer(BaseModel, extra='forbid'):
"""The type of the component. Always 'Footer'."""


class Modal(BaseModel, extra='forbid'):
class Modal(BaseModel, defer_build=True, extra='forbid'):
"""Modal component that displays a modal dialog."""

title: str
Expand All @@ -353,7 +356,7 @@ class Modal(BaseModel, extra='forbid'):
"""The type of the component. Always 'Modal'."""


class ServerLoad(BaseModel, extra='forbid'):
class ServerLoad(BaseModel, defer_build=True, extra='forbid'):
"""A component that will be replaced by the server with the component returned by the given URL."""

path: str
Expand Down Expand Up @@ -522,7 +525,8 @@ def __get_pydantic_json_schema__(
) -> _t.Any:
# add `children` to the schema so it can be used in the client
json_schema = handler(core_schema)
json_schema['properties']['children'] = {'tsType': 'ReactNode'}
schema_def = handler.resolve_ref_schema(json_schema)
schema_def['properties']['children'] = {'tsType': 'ReactNode'}
return json_schema


Expand All @@ -539,7 +543,7 @@ class Spinner(BaseModel, extra='forbid'):
"""The type of the component. Always 'Spinner'."""


class Toast(BaseModel, extra='forbid'):
class Toast(BaseModel, defer_build=True, extra='forbid'):
"""Toast component that displays a toast message (small temporary message)."""

title: str
Expand Down Expand Up @@ -636,3 +640,14 @@ class Custom(BaseModel, extra='forbid'):
"""Union of all components.

Pydantic discriminator field is set to 'type' to allow for efficient serialization and deserialization of the components."""

# Rebuild models:
BaseForm.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
Form.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
ModelForm.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
Div.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
Page.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
Link.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
Modal.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
ServerLoad.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
Toast.model_rebuild(_types_namespace={'AnyComponent': AnyComponent})
4 changes: 2 additions & 2 deletions src/python-fastui/fastui/components/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def default_footer(self) -> _te.Self:
return self


class Form(BaseForm):
class Form(BaseForm, defer_build=True):
"""Form component."""

form_fields: _t.List[FormField]
Expand All @@ -213,7 +213,7 @@ class Form(BaseForm):
FormFieldsModel = _t.TypeVar('FormFieldsModel', bound=pydantic.BaseModel)


class ModelForm(BaseForm):
class ModelForm(BaseForm, defer_build=True):
"""Form component generated from a Pydantic model."""

model: _t.Type[pydantic.BaseModel] = pydantic.Field(exclude=True)
Expand Down
4 changes: 3 additions & 1 deletion src/python-fastui/fastui/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
ContextType = TypeAliasType('ContextType', Dict[str, Union[str, int]])


class PageEvent(BaseModel):
class PageEvent(BaseModel, defer_build=True):
name: str
push_path: Union[str, None] = None
context: Union[ContextType, None] = None
Expand Down Expand Up @@ -37,3 +37,5 @@ class AuthEvent(BaseModel):


AnyEvent = Annotated[Union[PageEvent, GoToEvent, BackEvent, AuthEvent], Field(discriminator='type')]

PageEvent.model_rebuild(_types_namespace={'AnyEvent': AnyEvent})
55 changes: 35 additions & 20 deletions src/python-fastui/fastui/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,21 +160,26 @@ def json_schema_obj_to_fields(
def json_schema_any_to_fields(
schema: JsonSchemaAny, loc: SchemeLocation, title: _t.List[str], required: bool, defs: JsonSchemaDefs
) -> _t.Iterable[FormField]:
schema, required = deference_json_schema(schema, defs, required)
title = title + [schema.get('title') or loc_to_title(loc)]

if schema_is_field(schema):
yield json_schema_field_to_field(schema, loc, title, required)
elif schema_is_array(schema):
yield from json_schema_array_to_fields(schema, loc, title, required, defs)
dereferenced, required = deference_json_schema(schema, defs, required)
title = title + [schema.get('title', dereferenced.get('title', loc_to_title(loc)))]
description = schema.get('description', dereferenced.get('description'))

if schema_is_field(dereferenced):
yield json_schema_field_to_field(dereferenced, loc, title, description, required)
elif schema_is_array(dereferenced):
yield from json_schema_array_to_fields(dereferenced, loc, title, description, required, defs)
else:
assert schema_is_object(schema), f'Unexpected schema type {schema}'
assert schema_is_object(dereferenced), f'Unexpected schema type {dereferenced}'

yield from json_schema_obj_to_fields(schema, loc, title, defs)
yield from json_schema_obj_to_fields(dereferenced, loc, title, defs)


def json_schema_field_to_field(
schema: JsonSchemaField, loc: SchemeLocation, title: _t.List[str], required: bool
schema: JsonSchemaField,
loc: SchemeLocation,
title: _t.List[str],
description: _t.Union[str, None],
required: bool,
) -> FormField:
name = loc_to_name(loc)
if schema['type'] == 'boolean':
Expand All @@ -183,10 +188,10 @@ def json_schema_field_to_field(
title=title,
required=required,
initial=schema.get('default'),
description=schema.get('description'),
description=description,
mode=schema.get('mode', 'checkbox'),
)
elif field := special_string_field(schema, name, title, required, False):
elif field := special_string_field(schema, name, title, description, required, False):
return field
else:
return FormFieldInput(
Expand All @@ -206,15 +211,20 @@ def loc_to_title(loc: SchemeLocation) -> str:


def json_schema_array_to_fields(
schema: JsonSchemaArray, loc: SchemeLocation, title: _t.List[str], required: bool, defs: JsonSchemaDefs
schema: JsonSchemaArray,
loc: SchemeLocation,
title: _t.List[str],
description: _t.Union[str, None],
required: bool,
defs: JsonSchemaDefs,
) -> _t.Iterable[FormField]:
items_schema = schema.get('items')
if items_schema:
items_schema, required = deference_json_schema(items_schema, defs, required)
for field_name in 'search_url', 'placeholder', 'description':
for field_name in 'search_url', 'placeholder':
if value := schema.get(field_name):
items_schema[field_name] = value # type: ignore
if field := special_string_field(items_schema, loc_to_name(loc), title, required, True):
if field := special_string_field(items_schema, loc_to_name(loc), title, description, required, True):
yield field
return

Expand All @@ -236,7 +246,12 @@ def json_schema_array_to_fields(


def special_string_field(
schema: JsonSchemaConcrete, name: str, title: _t.List[str], required: bool, multiple: bool
schema: JsonSchemaConcrete,
name: str,
title: _t.List[str],
description: _t.Union[str, None],
required: bool,
multiple: bool,
) -> _t.Union[FormField, None]:
if schema['type'] == 'string':
if schema.get('format') == 'binary':
Expand All @@ -246,7 +261,7 @@ def special_string_field(
required=required,
multiple=multiple,
accept=schema.get('accept'),
description=schema.get('description'),
description=description,
)
elif schema.get('format') == 'textarea':
return FormFieldTextarea(
Expand All @@ -257,7 +272,7 @@ def special_string_field(
cols=schema.get('cols'),
placeholder=schema.get('placeholder'),
initial=schema.get('initial'),
description=schema.get('description'),
description=description,
autocomplete=schema.get('autocomplete'),
)
elif enum := schema.get('enum'):
Expand All @@ -270,7 +285,7 @@ def special_string_field(
multiple=multiple,
options=[SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in enum],
initial=schema.get('default'),
description=schema.get('description'),
description=description,
autocomplete=schema.get('autocomplete'),
)
elif search_url := schema.get('search_url'):
Expand All @@ -282,7 +297,7 @@ def special_string_field(
required=required,
multiple=multiple,
initial=schema.get('initial'),
description=schema.get('description'),
description=description,
)


Expand Down
9 changes: 4 additions & 5 deletions src/python-fastui/requirements/pyproject.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --constraint=src/python-fastui/requirements/lint.txt --extra=fastapi --output-file=src/python-fastui/requirements/pyproject.txt --strip-extras src/python-fastui/pyproject.toml
Expand All @@ -18,20 +18,19 @@ idna==3.6
# via
# anyio
# email-validator
pydantic==2.6.1
pydantic==2.10.6
# via
# fastapi
# fastui (src/python-fastui/pyproject.toml)
# pydantic
pydantic-core==2.16.2
pydantic-core==2.27.2
# via pydantic
python-multipart==0.0.7
# via fastui (src/python-fastui/pyproject.toml)
sniffio==1.3.0
# via anyio
starlette==0.36.3
# via fastapi
typing-extensions==4.9.0
typing-extensions==4.12.2
# via
# fastapi
# pydantic
Expand Down
4 changes: 2 additions & 2 deletions src/python-fastui/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
that's just testing pydantic!
"""
from fastui import FastUI, components
from pydantic_core import Url
from pydantic import HttpUrl


def test_div_text():
Expand Down Expand Up @@ -56,7 +56,7 @@ def test_root_model_single():
def test_iframe():
iframe = components.Iframe(src='https://www.example.com', srcdoc='<p>hello world</p>', sandbox='allow-scripts')
assert iframe.model_dump(by_alias=True, exclude_none=True) == {
'src': Url('https://www.example.com'),
'src': HttpUrl('https://www.example.com'),
'type': 'Iframe',
'srcdoc': '<p>hello world</p>',
'sandbox': 'allow-scripts',
Expand Down
1 change: 0 additions & 1 deletion src/python-fastui/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,6 @@ class FormSelectMultiple(BaseModel):

def test_form_description_leakage():
m = components.ModelForm(model=FormSelectMultiple, submit_url='/foobar/')

assert m.model_dump(by_alias=True, exclude_none=True) == {
'formFields': [
{
Expand Down
Loading