From 31d6c8bbbad0f98d3607cf1936990682599904fc Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:31:08 +0100 Subject: [PATCH 1/2] Rebuild models if necessary --- .../fastui/components/__init__.py | 24 ++++++++++++++----- src/python-fastui/fastui/components/forms.py | 4 ++-- src/python-fastui/fastui/events.py | 4 +++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index f74fafc2..7ffbb85b 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -15,6 +15,7 @@ from ..base import BaseModel from .display import Details, Display from .forms import ( + BaseForm, Form, FormField, FormFieldBoolean, @@ -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]' @@ -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]' @@ -240,7 +241,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]' @@ -328,7 +329,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 @@ -353,7 +354,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 @@ -539,7 +540,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 @@ -636,3 +637,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}) diff --git a/src/python-fastui/fastui/components/forms.py b/src/python-fastui/fastui/components/forms.py index b4f2f2d9..17bcb6f6 100644 --- a/src/python-fastui/fastui/components/forms.py +++ b/src/python-fastui/fastui/components/forms.py @@ -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] @@ -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) diff --git a/src/python-fastui/fastui/events.py b/src/python-fastui/fastui/events.py index 9b5970dd..cb443444 100644 --- a/src/python-fastui/fastui/events.py +++ b/src/python-fastui/fastui/events.py @@ -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 @@ -37,3 +37,5 @@ class AuthEvent(BaseModel): AnyEvent = Annotated[Union[PageEvent, GoToEvent, BackEvent, AuthEvent], Field(discriminator='type')] + +PageEvent.model_rebuild(_types_namespace={'AnyEvent': AnyEvent}) From 3e62956e040828e25120c86da857d762773da2c9 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:59:56 +0100 Subject: [PATCH 2/2] More compat --- .../fastui/components/__init__.py | 9 ++- src/python-fastui/fastui/json_schema.py | 55 ++++++++++++------- src/python-fastui/requirements/pyproject.txt | 9 ++- src/python-fastui/tests/test_components.py | 4 +- src/python-fastui/tests/test_forms.py | 1 - 5 files changed, 47 insertions(+), 31 deletions(-) diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index 7ffbb85b..8c2188d6 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -154,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 @@ -309,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 @@ -523,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 diff --git a/src/python-fastui/fastui/json_schema.py b/src/python-fastui/fastui/json_schema.py index c822f180..3abd1e14 100644 --- a/src/python-fastui/fastui/json_schema.py +++ b/src/python-fastui/fastui/json_schema.py @@ -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': @@ -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( @@ -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 @@ -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': @@ -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( @@ -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'): @@ -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'): @@ -282,7 +297,7 @@ def special_string_field( required=required, multiple=multiple, initial=schema.get('initial'), - description=schema.get('description'), + description=description, ) diff --git a/src/python-fastui/requirements/pyproject.txt b/src/python-fastui/requirements/pyproject.txt index 71255820..ad1ed563 100644 --- a/src/python-fastui/requirements/pyproject.txt +++ b/src/python-fastui/requirements/pyproject.txt @@ -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 @@ -18,12 +18,11 @@ 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) @@ -31,7 +30,7 @@ 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 diff --git a/src/python-fastui/tests/test_components.py b/src/python-fastui/tests/test_components.py index 6d3c3d17..053d923b 100644 --- a/src/python-fastui/tests/test_components.py +++ b/src/python-fastui/tests/test_components.py @@ -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(): @@ -56,7 +56,7 @@ def test_root_model_single(): def test_iframe(): iframe = components.Iframe(src='https://www.example.com', srcdoc='

hello world

', 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': '

hello world

', 'sandbox': 'allow-scripts', diff --git a/src/python-fastui/tests/test_forms.py b/src/python-fastui/tests/test_forms.py index ceaa7a5d..26f86080 100644 --- a/src/python-fastui/tests/test_forms.py +++ b/src/python-fastui/tests/test_forms.py @@ -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': [ {