From 3f90dfb6dd62ad47e3dc6128dcf5cac6dacff5d3 Mon Sep 17 00:00:00 2001 From: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com> Date: Thu, 2 May 2024 07:49:43 -0500 Subject: [PATCH] Use `AliasGenerator` for `camelCase` serialization aliases (#296) --- src/python-fastui/fastui/base.py | 7 ++ .../fastui/components/__init__.py | 87 +++++++++---------- .../fastui/components/display.py | 11 ++- src/python-fastui/fastui/components/forms.py | 25 +++--- src/python-fastui/fastui/components/tables.py | 11 +-- src/python-fastui/fastui/events.py | 8 +- 6 files changed, 77 insertions(+), 72 deletions(-) create mode 100644 src/python-fastui/fastui/base.py diff --git a/src/python-fastui/fastui/base.py b/src/python-fastui/fastui/base.py new file mode 100644 index 00000000..a0eb9c35 --- /dev/null +++ b/src/python-fastui/fastui/base.py @@ -0,0 +1,7 @@ +from pydantic import AliasGenerator, ConfigDict +from pydantic import BaseModel as _BaseModel +from pydantic.alias_generators import to_camel + + +class BaseModel(_BaseModel): + model_config = ConfigDict(alias_generator=AliasGenerator(serialization_alias=to_camel)) diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index 622a02c5..f74fafc2 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -12,6 +12,7 @@ from .. import class_name as _class_name from .. import events from .. import types as _types +from ..base import BaseModel from .display import Details, Display from .forms import ( Form, @@ -69,7 +70,7 @@ ) -class Text(_p.BaseModel, extra='forbid'): +class Text(BaseModel, extra='forbid'): """Text component that displays a string.""" text: str @@ -79,7 +80,7 @@ class Text(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Text'.""" -class Paragraph(_p.BaseModel, extra='forbid'): +class Paragraph(BaseModel, extra='forbid'): """Paragraph component that displays a string as a paragraph.""" text: str @@ -92,7 +93,7 @@ class Paragraph(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Paragraph'.""" -class PageTitle(_p.BaseModel, extra='forbid'): +class PageTitle(BaseModel, extra='forbid'): """Sets the title of the HTML page via the `document.title` property.""" text: str @@ -102,7 +103,7 @@ class PageTitle(_p.BaseModel, extra='forbid'): """The type of the component. Always 'PageTitle'.""" -class Div(_p.BaseModel, extra='forbid'): +class Div(BaseModel, extra='forbid'): """A generic container component.""" components: '_t.List[AnyComponent]' @@ -115,7 +116,7 @@ class Div(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Div'.""" -class Page(_p.BaseModel, extra='forbid'): +class Page(BaseModel, extra='forbid'): """Similar to `container` in many UI frameworks, this acts as a root component for most pages.""" components: '_t.List[AnyComponent]' @@ -128,7 +129,7 @@ class Page(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Page'.""" -class Heading(_p.BaseModel, extra='forbid'): +class Heading(BaseModel, extra='forbid'): """Heading component.""" text: str @@ -137,7 +138,7 @@ class Heading(_p.BaseModel, extra='forbid'): level: _t.Literal[1, 2, 3, 4, 5, 6] = 1 """The level of the heading. 1 is the largest, 6 is the smallest.""" - html_id: _t.Union[str, None] = _p.Field(default=None, serialization_alias='htmlId') + html_id: _t.Union[str, None] = None """Optional HTML ID to apply to the heading's HTML component.""" class_name: _class_name.ClassNameField = None @@ -169,7 +170,7 @@ def __get_pydantic_json_schema__( """ -class Markdown(_p.BaseModel, extra='forbid'): +class Markdown(BaseModel, extra='forbid'): """Markdown component that renders markdown text.""" text: str @@ -185,7 +186,7 @@ class Markdown(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Markdown'.""" -class Code(_p.BaseModel, extra='forbid'): +class Code(BaseModel, extra='forbid'): """Code component that renders code with syntax highlighting.""" text: str @@ -204,7 +205,7 @@ class Code(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Code'.""" -class Json(_p.BaseModel, extra='forbid'): +class Json(BaseModel, extra='forbid'): """JSON component that renders JSON data.""" value: _types.JsonData @@ -217,18 +218,16 @@ class Json(_p.BaseModel, extra='forbid'): """The type of the component. Always 'JSON'.""" -class Button(_p.BaseModel, extra='forbid'): +class Button(BaseModel, extra='forbid'): """Button component.""" text: str """The text to display on the button.""" - on_click: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='onClick') + on_click: _t.Union[events.AnyEvent, None] = None """Optional event to trigger when the button is clicked.""" - html_type: _t.Union[_t.Literal['button', 'reset', 'submit'], None] = _p.Field( - default=None, serialization_alias='htmlType' - ) + html_type: _t.Union[_t.Literal['button', 'reset', 'submit'], None] = None """Optional HTML type of the button. If None, defaults to 'button'.""" named_style: _class_name.NamedStyleField = None @@ -241,13 +240,13 @@ class Button(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Button'.""" -class Link(_p.BaseModel, extra='forbid'): +class Link(BaseModel, extra='forbid'): """Link component.""" components: '_t.List[AnyComponent]' """List of components to render attached to the link.""" - on_click: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='onClick') + on_click: _t.Union[events.AnyEvent, None] = None """Optional event to trigger when the link is clicked.""" mode: _t.Union[_t.Literal['navbar', 'footer', 'tabs', 'vertical', 'pagination'], None] = None @@ -266,7 +265,7 @@ class Link(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Link'.""" -class LinkList(_p.BaseModel, extra='forbid'): +class LinkList(BaseModel, extra='forbid'): """List of Link components.""" links: _t.List[Link] @@ -282,19 +281,19 @@ class LinkList(_p.BaseModel, extra='forbid'): """The type of the component. Always 'LinkList'.""" -class Navbar(_p.BaseModel, extra='forbid'): +class Navbar(BaseModel, extra='forbid'): """Navbar component used for moving between pages.""" title: _t.Union[str, None] = None """Optional title to display in the navbar.""" - title_event: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='titleEvent') + title_event: _t.Union[events.AnyEvent, None] = None """Optional event to trigger when the title is clicked. Often used to navigate to the home page.""" - start_links: _t.List[Link] = _p.Field(default=[], serialization_alias='startLinks') + start_links: _t.List[Link] = [] """List of links to render at the start of the navbar.""" - end_links: _t.List[Link] = _p.Field(default=[], serialization_alias='endLinks') + end_links: _t.List[Link] = [] """List of links to render at the end of the navbar.""" class_name: _class_name.ClassNameField = None @@ -313,13 +312,13 @@ def __get_pydantic_json_schema__( return json_schema -class Footer(_p.BaseModel, extra='forbid'): +class Footer(BaseModel, extra='forbid'): """Footer component.""" links: _t.List[Link] """List of links to render in the footer.""" - extra_text: _t.Union[str, None] = _p.Field(default=None, serialization_alias='extraText') + extra_text: _t.Union[str, None] = None """Optional extra text to display in the footer.""" class_name: _class_name.ClassNameField = None @@ -329,7 +328,7 @@ class Footer(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Footer'.""" -class Modal(_p.BaseModel, extra='forbid'): +class Modal(BaseModel, extra='forbid'): """Modal component that displays a modal dialog.""" title: str @@ -341,10 +340,10 @@ class Modal(_p.BaseModel, extra='forbid'): footer: '_t.Union[_t.List[AnyComponent], None]' = None """Optional list of components to render in the modal footer.""" - open_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='openTrigger') + open_trigger: _t.Union[events.PageEvent, None] = None """Optional event to trigger when the modal is opened.""" - open_context: _t.Union[events.ContextType, None] = _p.Field(default=None, serialization_alias='openContext') + open_context: _t.Union[events.ContextType, None] = None """Optional context to pass to the open trigger event.""" class_name: _class_name.ClassNameField = None @@ -354,13 +353,13 @@ class Modal(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Modal'.""" -class ServerLoad(_p.BaseModel, extra='forbid'): +class ServerLoad(BaseModel, extra='forbid'): """A component that will be replaced by the server with the component returned by the given URL.""" path: str """The URL to load the component from.""" - load_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='loadTrigger') + load_trigger: _t.Union[events.PageEvent, None] = None """Optional event to trigger when the component is loaded.""" components: '_t.Union[_t.List[AnyComponent], None]' = None @@ -369,7 +368,7 @@ class ServerLoad(_p.BaseModel, extra='forbid'): sse: _t.Union[bool, None] = None """Optional flag to enable server-sent events (SSE) for the server load.""" - sse_retry: _t.Union[int, None] = _p.Field(default=None, serialization_alias='sseRetry') + sse_retry: _t.Union[int, None] = None """Optional time in milliseconds to retry the SSE connection.""" method: _t.Union[_t.Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], None] = None @@ -379,7 +378,7 @@ class ServerLoad(_p.BaseModel, extra='forbid'): """The type of the component. Always 'ServerLoad'.""" -class Image(_p.BaseModel, extra='forbid'): +class Image(BaseModel, extra='forbid'): """Image container component.""" src: str @@ -406,7 +405,7 @@ class Image(_p.BaseModel, extra='forbid'): 'unsafe-url', ], None, - ] = _p.Field(None, serialization_alias='referrerPolicy') + ] = None """Optional referrer policy for the image. Specifies what information to send when fetching the image. For more info, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy.""" @@ -414,7 +413,7 @@ class Image(_p.BaseModel, extra='forbid'): loading: _t.Union[_t.Literal['eager', 'lazy'], None] = None """Optional loading strategy for the image.""" - on_click: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='onClick') + on_click: _t.Union[events.AnyEvent, None] = None """Optional event to trigger when the image is clicked.""" class_name: _class_name.ClassNameField = None @@ -424,7 +423,7 @@ class Image(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Image'.""" -class Iframe(_p.BaseModel, extra='forbid'): +class Iframe(BaseModel, extra='forbid'): """Iframe component that displays content from a URL.""" src: _p.HttpUrl @@ -452,7 +451,7 @@ class Iframe(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Iframe'.""" -class Video(_p.BaseModel, extra='forbid'): +class Video(BaseModel, extra='forbid'): """Video component that displays a video or multiple videos.""" sources: _t.List[_p.AnyUrl] @@ -486,7 +485,7 @@ class Video(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Video'.""" -class FireEvent(_p.BaseModel, extra='forbid'): +class FireEvent(BaseModel, extra='forbid'): """Fire an event.""" event: events.AnyEvent @@ -499,7 +498,7 @@ class FireEvent(_p.BaseModel, extra='forbid'): """The type of the component. Always 'FireEvent'.""" -class Error(_p.BaseModel, extra='forbid'): +class Error(BaseModel, extra='forbid'): """Utility component used to display an error.""" title: str @@ -508,7 +507,7 @@ class Error(_p.BaseModel, extra='forbid'): description: str """The description of the error.""" - status_code: _t.Union[int, None] = _p.Field(None, serialization_alias='statusCode') + status_code: _t.Union[int, None] = None """Optional status code of the error.""" class_name: _class_name.ClassNameField = None @@ -527,7 +526,7 @@ def __get_pydantic_json_schema__( return json_schema -class Spinner(_p.BaseModel, extra='forbid'): +class Spinner(BaseModel, extra='forbid'): """Spinner component that displays a loading spinner.""" text: _t.Union[str, None] = None @@ -540,7 +539,7 @@ class Spinner(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Spinner'.""" -class Toast(_p.BaseModel, extra='forbid'): +class Toast(BaseModel, extra='forbid'): """Toast component that displays a toast message (small temporary message).""" title: str @@ -566,10 +565,10 @@ class Toast(_p.BaseModel, extra='forbid'): ] = None """Optional position of the toast.""" - open_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='openTrigger') + open_trigger: _t.Union[events.PageEvent, None] = None """Optional event to trigger when the toast is opened.""" - open_context: _t.Union[events.ContextType, None] = _p.Field(default=None, serialization_alias='openContext') + open_context: _t.Union[events.ContextType, None] = None """Optional context to pass to the open trigger event.""" class_name: _class_name.ClassNameField = None @@ -579,13 +578,13 @@ class Toast(_p.BaseModel, extra='forbid'): """The type of the component. Always 'Toast'.""" -class Custom(_p.BaseModel, extra='forbid'): +class Custom(BaseModel, extra='forbid'): """Custom component that allows for special data to be rendered.""" data: _types.JsonData """The data to render in the custom component.""" - sub_type: str = _p.Field(serialization_alias='subType') + sub_type: str """The sub-type of the custom component.""" library: _t.Union[str, None] = None diff --git a/src/python-fastui/fastui/components/display.py b/src/python-fastui/fastui/components/display.py index 3afd0347..5f147def 100644 --- a/src/python-fastui/fastui/components/display.py +++ b/src/python-fastui/fastui/components/display.py @@ -10,6 +10,7 @@ from .. import class_name as _class_name from .. import events from .. import types as _types +from ..base import BaseModel __all__ = 'DisplayMode', 'DisplayLookup', 'Display', 'Details' @@ -28,7 +29,7 @@ class DisplayMode(str, enum.Enum): inline_code = 'inline_code' -class DisplayBase(pydantic.BaseModel, ABC, defer_build=True): +class DisplayBase(BaseModel, ABC, defer_build=True): """Base class for display components.""" mode: _t.Union[DisplayMode, None] = None @@ -37,7 +38,7 @@ class DisplayBase(pydantic.BaseModel, ABC, defer_build=True): title: _t.Union[str, None] = None """Title to display for the value.""" - on_click: _t.Union[events.AnyEvent, None] = pydantic.Field(default=None, serialization_alias='onClick') + on_click: _t.Union[events.AnyEvent, None] = None """Event to trigger when the value is clicked.""" @@ -47,9 +48,7 @@ class DisplayLookup(DisplayBase, extra='forbid'): field: str """Field to display.""" - table_width_percent: _t.Union[_te.Annotated[int, _at.Interval(ge=0, le=100)], None] = pydantic.Field( - default=None, serialization_alias='tableWidthPercent' - ) + table_width_percent: _t.Union[_te.Annotated[int, _at.Interval(ge=0, le=100)], None] = None """Percentage width - 0 to 100, specific to tables.""" @@ -63,7 +62,7 @@ class Display(DisplayBase, extra='forbid'): """The type of the component. Always 'Display'.""" -class Details(pydantic.BaseModel, extra='forbid'): +class Details(BaseModel, extra='forbid'): """Details associated with displaying a data model.""" data: pydantic.SerializeAsAny[_types.DataModel] diff --git a/src/python-fastui/fastui/components/forms.py b/src/python-fastui/fastui/components/forms.py index d607de12..b4f2f2d9 100644 --- a/src/python-fastui/fastui/components/forms.py +++ b/src/python-fastui/fastui/components/forms.py @@ -7,6 +7,7 @@ from .. import class_name as _class_name from .. import events, forms from .. import types as _types +from ..base import BaseModel if _t.TYPE_CHECKING: from . import AnyComponent @@ -14,7 +15,7 @@ InputHtmlType = _t.Literal['text', 'date', 'datetime-local', 'time', 'email', 'url', 'number', 'password', 'hidden'] -class BaseFormField(pydantic.BaseModel, ABC, defer_build=True): +class BaseFormField(BaseModel, ABC, defer_build=True): """Base class for form fields.""" name: str @@ -35,9 +36,7 @@ class BaseFormField(pydantic.BaseModel, ABC, defer_build=True): description: _t.Union[str, None] = None """Description of the field.""" - display_mode: _t.Union[_t.Literal['default', 'inline'], None] = pydantic.Field( - default=None, serialization_alias='displayMode' - ) + display_mode: _t.Union[_t.Literal['default', 'inline'], None] = None """Display mode for the field.""" class_name: _class_name.ClassNameField = None @@ -47,7 +46,7 @@ class BaseFormField(pydantic.BaseModel, ABC, defer_build=True): class FormFieldInput(BaseFormField): """Form field for basic input.""" - html_type: InputHtmlType = pydantic.Field(default='text', serialization_alias='htmlType') + html_type: InputHtmlType = 'text' """HTML input type for the field.""" initial: _t.Union[str, float, None] = None @@ -139,7 +138,7 @@ class FormFieldSelect(BaseFormField): class FormFieldSelectSearch(BaseFormField): """Form field for searchable select input.""" - search_url: str = pydantic.Field(serialization_alias='searchUrl') + search_url: str """URL to search for options.""" multiple: _t.Union[bool, None] = None @@ -164,10 +163,10 @@ class FormFieldSelectSearch(BaseFormField): """Union of all form field types.""" -class BaseForm(pydantic.BaseModel, ABC, defer_build=True, extra='forbid'): +class BaseForm(BaseModel, ABC, defer_build=True, extra='forbid'): """Base class for forms.""" - submit_url: str = pydantic.Field(serialization_alias='submitUrl') + submit_url: str """URL to submit the form data to.""" initial: _t.Union[_t.Dict[str, _types.JsonData], None] = None @@ -176,15 +175,13 @@ class BaseForm(pydantic.BaseModel, ABC, defer_build=True, extra='forbid'): method: _t.Literal['POST', 'GOTO', 'GET'] = 'POST' """HTTP method to use for the form submission.""" - display_mode: _t.Union[_t.Literal['default', 'page', 'inline'], None] = pydantic.Field( - default=None, serialization_alias='displayMode' - ) + display_mode: _t.Union[_t.Literal['default', 'page', 'inline'], None] = None """Display mode for the form.""" - submit_on_change: _t.Union[bool, None] = pydantic.Field(default=None, serialization_alias='submitOnChange') + submit_on_change: _t.Union[bool, None] = None """Whether to submit the form on change.""" - submit_trigger: _t.Union[events.PageEvent, None] = pydantic.Field(default=None, serialization_alias='submitTrigger') + submit_trigger: _t.Union[events.PageEvent, None] = None """Event to trigger form submission.""" loading: '_t.Union[_t.List[AnyComponent], None]' = None @@ -206,7 +203,7 @@ def default_footer(self) -> _te.Self: class Form(BaseForm): """Form component.""" - form_fields: _t.List[FormField] = pydantic.Field(serialization_alias='formFields') + form_fields: _t.List[FormField] """List of form fields.""" type: _t.Literal['Form'] = 'Form' diff --git a/src/python-fastui/fastui/components/tables.py b/src/python-fastui/fastui/components/tables.py index 53d35ce7..9044ef3d 100644 --- a/src/python-fastui/fastui/components/tables.py +++ b/src/python-fastui/fastui/components/tables.py @@ -6,12 +6,13 @@ from .. import class_name as _class_name from .. import types as _types +from ..base import BaseModel from . import display # TODO allow dataclasses and typed dicts here too -class Table(pydantic.BaseModel, extra='forbid'): +class Table(BaseModel, extra='forbid'): """Table component.""" data: _t.Sequence[pydantic.SerializeAsAny[_types.DataModel]] @@ -23,7 +24,7 @@ class Table(pydantic.BaseModel, extra='forbid'): data_model: _t.Union[_t.Type[pydantic.BaseModel], None] = pydantic.Field(default=None, exclude=True) """Data model to use for the table. If not provided, the model will be inferred from the first data item.""" - no_data_message: _t.Union[str, None] = pydantic.Field(default=None, serialization_alias='noDataMessage') + no_data_message: _t.Union[str, None] = None """Message to display when there is no data.""" class_name: _class_name.ClassNameField = None @@ -66,19 +67,19 @@ def __get_pydantic_json_schema__( return json_schema -class Pagination(pydantic.BaseModel): +class Pagination(BaseModel): """Pagination component to use with tables.""" page: int """The current page number.""" - page_size: int = pydantic.Field(serialization_alias='pageSize') + page_size: int """The number of items per page.""" total: int """The total number of items.""" - page_query_param: str = pydantic.Field('page', serialization_alias='pageQueryParam') + page_query_param: str = 'page' """The query parameter to use for the page number.""" class_name: _class_name.ClassNameField = None diff --git a/src/python-fastui/fastui/events.py b/src/python-fastui/fastui/events.py index 1c5c491d..9b5970dd 100644 --- a/src/python-fastui/fastui/events.py +++ b/src/python-fastui/fastui/events.py @@ -1,17 +1,19 @@ from typing import Dict, Literal, Union -from pydantic import BaseModel, Field +from pydantic import Field from typing_extensions import Annotated, TypeAliasType +from .base import BaseModel + ContextType = TypeAliasType('ContextType', Dict[str, Union[str, int]]) class PageEvent(BaseModel): name: str - push_path: Union[str, None] = Field(default=None, serialization_alias='pushPath') + push_path: Union[str, None] = None context: Union[ContextType, None] = None clear: Union[bool, None] = None - next_event: 'Union[AnyEvent, None]' = Field(default=None, serialization_alias='nextEvent') + next_event: 'Union[AnyEvent, None]' = None type: Literal['page'] = 'page'