diff --git a/src/npm-fastui/src/models.d.ts b/src/npm-fastui/src/models.d.ts index 3a826768..6a73b4d8 100644 --- a/src/npm-fastui/src/models.d.ts +++ b/src/npm-fastui/src/models.d.ts @@ -4,7 +4,7 @@ * `fastui generate `. */ -export type FastProps = +export type AnyComponent = | Text | Paragraph | PageTitle @@ -24,7 +24,6 @@ export type FastProps = | Iframe | Video | FireEvent - | Custom | Table | Pagination | Display @@ -63,6 +62,7 @@ export type DisplayMode = | 'json' | 'inline_code' export type SelectOptions = SelectOption[] | SelectGroup[] +export type FastUI = AnyComponent[] export interface Text { text: string @@ -81,7 +81,7 @@ export interface PageTitle { type: 'PageTitle' } export interface Div { - components: FastProps[] + components: AnyComponent[] className?: ClassName type: 'Div' } @@ -89,7 +89,7 @@ export interface Div { * Similar to `container` in many UI frameworks, this should be a reasonable root component for most pages. */ export interface Page { - components: FastProps[] + components: AnyComponent[] className?: ClassName type: 'Page' } @@ -151,8 +151,8 @@ export interface AuthEvent { type: 'auth' } export interface Link { - components: FastProps[] - onClick?: PageEvent | GoToEvent | BackEvent | AuthEvent + components: AnyComponent[] + onClick?: AnyEvent mode?: 'navbar' | 'tabs' | 'vertical' | 'pagination' active?: string | boolean locked?: boolean @@ -167,15 +167,15 @@ export interface LinkList { } export interface Navbar { title?: string - titleEvent?: PageEvent | GoToEvent | BackEvent | AuthEvent + titleEvent?: AnyEvent links: Link[] className?: ClassName type: 'Navbar' } export interface Modal { title: string - body: FastProps[] - footer?: FastProps[] + body: AnyComponent[] + footer?: AnyComponent[] openTrigger?: PageEvent openContext?: ContextType className?: ClassName @@ -187,7 +187,7 @@ export interface Modal { export interface ServerLoad { path: string loadTrigger?: PageEvent - components?: FastProps[] + components?: AnyComponent[] sse?: boolean type: 'ServerLoad' } @@ -235,13 +235,6 @@ export interface FireEvent { message?: string type: 'FireEvent' } -export interface Custom { - data: JsonData - subType: string - library?: string - className?: ClassName - type: 'Custom' -} export interface Table { data: DataModel[] columns: DisplayLookup[] @@ -258,7 +251,7 @@ export interface DataModel { export interface DisplayLookup { mode?: DisplayMode title?: string - onClick?: PageEvent | GoToEvent | BackEvent | AuthEvent + onClick?: AnyEvent field: string tableWidthPercent?: number } @@ -276,7 +269,7 @@ export interface Pagination { export interface Display { mode?: DisplayMode title?: string - onClick?: PageEvent | GoToEvent | BackEvent | AuthEvent + onClick?: AnyEvent value: JsonData type: 'Display' } @@ -295,7 +288,7 @@ export interface Form { displayMode?: 'default' | 'inline' submitOnChange?: boolean submitTrigger?: PageEvent - footer?: FastProps[] + footer?: AnyComponent[] className?: ClassName formFields: (FormFieldInput | FormFieldBoolean | FormFieldFile | FormFieldSelect | FormFieldSelectSearch)[] type: 'Form' @@ -389,7 +382,7 @@ export interface ModelForm { displayMode?: 'default' | 'inline' submitOnChange?: boolean submitTrigger?: PageEvent - footer?: FastProps[] + footer?: AnyComponent[] className?: ClassName type: 'ModelForm' formFields: (FormFieldInput | FormFieldBoolean | FormFieldFile | FormFieldSelect | FormFieldSelectSearch)[] diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index 0fb50862..18405afa 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -47,7 +47,6 @@ 'Image', 'Iframe', 'FireEvent', - 'Custom', 'Table', 'Pagination', 'Display', @@ -65,6 +64,11 @@ 'FormFieldSelectSearch', ) +ComponentT = _t.TypeVar('ComponentT') +# hack to make pydantic recognize `AnyComponent` as the default type for `ComponentT` without needing to use the +# backport of default `_te.TypeVar`, which doesn't work properly with PyCharm yet: +ComponentT.__default__ = 'AnyComponent' # type: ignore + class Text(_p.BaseModel, extra='forbid'): text: str @@ -86,18 +90,18 @@ class PageTitle(_p.BaseModel, extra='forbid'): type: _t.Literal['PageTitle'] = 'PageTitle' -class Div(_p.BaseModel, extra='forbid'): - components: '_t.List[AnyComponent]' +class Div(_p.BaseModel, _t.Generic[ComponentT], extra='forbid'): + components: '_t.List[ComponentT]' class_name: _class_name.ClassNameField = None type: _t.Literal['Div'] = 'Div' -class Page(_p.BaseModel, extra='forbid'): +class Page(_p.BaseModel, _t.Generic[ComponentT], extra='forbid'): """ Similar to `container` in many UI frameworks, this should be a reasonable root component for most pages. """ - components: '_t.List[AnyComponent]' + components: '_t.List[ComponentT]' class_name: _class_name.ClassNameField = None type: _t.Literal['Page'] = 'Page' @@ -155,8 +159,8 @@ class Button(_p.BaseModel, extra='forbid'): type: _t.Literal['Button'] = 'Button' -class Link(_p.BaseModel, extra='forbid'): - components: '_t.List[AnyComponent]' +class Link(_p.BaseModel, _t.Generic[ComponentT], extra='forbid'): + components: '_t.List[ComponentT]' on_click: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='onClick') mode: _t.Union[_t.Literal['navbar', 'tabs', 'vertical', 'pagination'], None] = None active: _t.Union[str, bool, None] = None @@ -165,17 +169,17 @@ class Link(_p.BaseModel, extra='forbid'): type: _t.Literal['Link'] = 'Link' -class LinkList(_p.BaseModel, extra='forbid'): - links: _t.List[Link] +class LinkList(_p.BaseModel, _t.Generic[ComponentT], extra='forbid'): + links: _t.List[Link[ComponentT]] mode: _t.Union[_t.Literal['tabs', 'vertical', 'pagination'], None] = None class_name: _class_name.ClassNameField = None type: _t.Literal['LinkList'] = 'LinkList' -class Navbar(_p.BaseModel, extra='forbid'): +class Navbar(_p.BaseModel, _t.Generic[ComponentT], extra='forbid'): title: _t.Union[str, None] = None title_event: _t.Union[events.AnyEvent, None] = _p.Field(default=None, serialization_alias='titleEvent') - links: _t.List[Link] = _p.Field(default=[]) + links: _t.List[Link[ComponentT]] = _p.Field(default=[]) class_name: _class_name.ClassNameField = None type: _t.Literal['Navbar'] = 'Navbar' @@ -185,28 +189,28 @@ 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('links') + json_schema.setdefault('required', []).append('links') return json_schema -class Modal(_p.BaseModel, extra='forbid'): +class Modal(_p.BaseModel, _t.Generic[ComponentT], extra='forbid'): title: str - body: '_t.List[AnyComponent]' - footer: '_t.Union[_t.List[AnyComponent], None]' = None + body: '_t.List[ComponentT]' + footer: '_t.Union[_t.List[ComponentT], None]' = None open_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='openTrigger') open_context: _t.Union[events.ContextType, None] = _p.Field(default=None, serialization_alias='openContext') class_name: _class_name.ClassNameField = None type: _t.Literal['Modal'] = 'Modal' -class ServerLoad(_p.BaseModel, extra='forbid'): +class ServerLoad(_p.BaseModel, _t.Generic[ComponentT], extra='forbid'): """ A component that will be replaced by the server with the component returned by the given URL. """ path: str load_trigger: _t.Union[events.PageEvent, None] = _p.Field(default=None, serialization_alias='loadTrigger') - components: '_t.Union[_t.List[AnyComponent], None]' = None + components: '_t.Union[_t.List[ComponentT], None]' = None sse: _t.Union[bool, None] = None type: _t.Literal['ServerLoad'] = 'ServerLoad' @@ -263,43 +267,37 @@ class FireEvent(_p.BaseModel, extra='forbid'): type: _t.Literal['FireEvent'] = 'FireEvent' -class Custom(_p.BaseModel, extra='forbid'): - data: _types.JsonData - sub_type: str = _p.Field(serialization_alias='subType') - library: _t.Union[str, None] = None - class_name: _class_name.ClassNameField = None - type: _t.Literal['Custom'] = 'Custom' - - -AnyComponent = _te.Annotated[ - _t.Union[ - Text, - Paragraph, - PageTitle, - Div, - Page, - Heading, - Markdown, - Code, - Json, - Button, - Link, - LinkList, - Navbar, - Modal, - ServerLoad, - Image, - Iframe, - Video, - FireEvent, - Custom, - Table, - Pagination, - Display, - Details, - Form, - FormField, - ModelForm, +AnyComponent = _te.TypeAliasType( + 'AnyComponent', + _te.Annotated[ + _t.Union[ + Text, + Paragraph, + PageTitle, + Div, + Page, + Heading, + Markdown, + Code, + Json, + Button, + Link, + LinkList, + Navbar, + Modal, + ServerLoad, + Image, + Iframe, + Video, + FireEvent, + Table, + Pagination, + Display, + Details, + Form, + FormField, + ModelForm, + ], + _p.Field(discriminator='type'), ], - _p.Field(discriminator='type'), -] +) diff --git a/src/python-fastui/fastui/custom1.py b/src/python-fastui/fastui/custom1.py new file mode 100644 index 00000000..43e3b9d7 --- /dev/null +++ b/src/python-fastui/fastui/custom1.py @@ -0,0 +1,75 @@ +""" +Example usage a user might use with custom components. + +Note that it's not working with discriminator uncommented, which we need to fix, I think that's a bug in Pydantic. + +(I got the same issue even if I dropped the TypeAliasType and just used the Union directly.) +""" +import typing as _t + +import pydantic as _p +import typing_extensions as _te + +import fastui.components as c +from fastui.class_name import ClassNameField +from fastui.types import JsonData + + +class Custom(_p.BaseModel, extra='forbid'): + type: _t.Literal['Custom'] = 'Custom' + + data: JsonData + sub_type: str = _p.Field(serialization_alias='subType') + library: _t.Union[str, None] = None + class_name: ClassNameField = None + + +CustomAnyComponent = _te.TypeAliasType( + 'CustomAnyComponent', + _te.Annotated[ + _t.Union[ + Custom, + # Non-recursive components + c.Text, + c.Paragraph, + c.PageTitle, + c.Heading, + c.Markdown, + c.Code, + c.Json, + c.Button, + c.Image, + c.Iframe, + c.Video, + c.FireEvent, + c.Table, + c.Pagination, + c.Display, + c.Details, + c.Form, + c.FormField, + c.ModelForm, + # Recursive components + 'c.Div[CustomAnyComponent]', + 'c.Page[CustomAnyComponent]', + 'c.Link[CustomAnyComponent]', + 'c.LinkList[CustomAnyComponent]', + 'c.Modal[CustomAnyComponent]', + 'c.ServerLoad[CustomAnyComponent]', + 'c.Navbar[CustomAnyComponent]', + ], + ..., + # _p.Field(discriminator='type'), + ], +) + + +class FastUI(_p.RootModel): + """ + The root component of a FastUI application. + """ + + root: _t.List[CustomAnyComponent] + + +print(FastUI.model_json_schema()) diff --git a/src/python-fastui/fastui/custom2.py b/src/python-fastui/fastui/custom2.py new file mode 100644 index 00000000..c88d4341 --- /dev/null +++ b/src/python-fastui/fastui/custom2.py @@ -0,0 +1,92 @@ +""" +Example usage a user might use with custom components, using type aliases that we might move into +the fastui/__init__.py. + +Note that it's not working with discriminator uncommented, which we need to fix, I think that's a bug in Pydantic. + +(I got the same issue even if I dropped the use of TypeAliasType.) +""" +import typing as _t + +import pydantic as _p +import typing_extensions as _te + +import fastui.components as c +from fastui.class_name import ClassNameField +from fastui.types import JsonData + + +class Custom(_p.BaseModel, extra='forbid'): + type: _t.Literal['Custom'] = 'Custom' + + data: JsonData + sub_type: str = _p.Field(serialization_alias='subType') + library: _t.Union[str, None] = None + class_name: ClassNameField = None + + +T = _t.TypeVar('T') + +ConcreteFastUIComponent = _te.TypeAliasType( + 'ConcreteFastUIComponent', + _te.Annotated[ + _t.Union[ + c.Text, + c.Paragraph, + c.PageTitle, + c.Heading, + c.Markdown, + c.Code, + c.Json, + c.Button, + c.Image, + c.Iframe, + c.Video, + c.FireEvent, + c.Table, + c.Pagination, + c.Display, + c.Details, + c.Form, + c.FormField, + c.ModelForm, + ], + ..., + # _p.Field(discriminator='type'), + ], + type_params=(T,), +) + +GenericFastUIComponent = _te.TypeAliasType( + 'GenericFastUIComponent', + _te.Annotated[ + _t.Union[c.Div[T], c.Page[T], c.Link[T], c.LinkList[T], c.Modal[T], c.ServerLoad[T], c.Navbar[T]], + ..., + # _p.Field(discriminator='type'), + ], + type_params=(T,), +) + +CustomAnyComponent = _te.TypeAliasType( + 'CustomAnyComponent', + _te.Annotated[ + _t.Union[ + Custom, + ConcreteFastUIComponent, + 'GenericFastUIComponent[CustomAnyComponent]', + ], + _p.Field(discriminator='type'), + ], + type_params=(T,), +) + + +class FastUI(_p.RootModel): + """ + The root component of a FastUI application. + """ + + root: _t.List[CustomAnyComponent] + + +print(FastUI.model_json_schema()) diff --git a/src/python-fastui/fastui/events.py b/src/python-fastui/fastui/events.py index f4a91c33..1586275f 100644 --- a/src/python-fastui/fastui/events.py +++ b/src/python-fastui/fastui/events.py @@ -1,5 +1,6 @@ from typing import Dict, Literal, Union +import typing_extensions from pydantic import BaseModel, Field from typing_extensions import Annotated, TypeAliasType @@ -32,4 +33,6 @@ class AuthEvent(BaseModel): type: Literal['auth'] = 'auth' -AnyEvent = Annotated[Union[PageEvent, GoToEvent, BackEvent, AuthEvent], Field(discriminator='type')] +AnyEvent = typing_extensions.TypeAliasType( + 'AnyEvent', Annotated[Union[PageEvent, GoToEvent, BackEvent, AuthEvent], Field(discriminator='type')] +)