Skip to content
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

Demonstrate how users might be able to work custom types into generated JSON schema type-safely #114

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
35 changes: 14 additions & 21 deletions src/npm-fastui/src/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* `fastui generate <python-object> <typescript-output-file>`.
*/

export type FastProps =
export type AnyComponent =
| Text
| Paragraph
| PageTitle
Expand All @@ -24,7 +24,6 @@ export type FastProps =
| Iframe
| Video
| FireEvent
| Custom
| Table
| Pagination
| Display
Expand Down Expand Up @@ -63,6 +62,7 @@ export type DisplayMode =
| 'json'
| 'inline_code'
export type SelectOptions = SelectOption[] | SelectGroup[]
export type FastUI = AnyComponent[]

export interface Text {
text: string
Expand All @@ -81,15 +81,15 @@ export interface PageTitle {
type: 'PageTitle'
}
export interface Div {
components: FastProps[]
components: AnyComponent[]
className?: ClassName
type: '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'
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -187,7 +187,7 @@ export interface Modal {
export interface ServerLoad {
path: string
loadTrigger?: PageEvent
components?: FastProps[]
components?: AnyComponent[]
sse?: boolean
type: 'ServerLoad'
}
Expand Down Expand Up @@ -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[]
Expand All @@ -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
}
Expand All @@ -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'
}
Expand All @@ -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'
Expand Down Expand Up @@ -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)[]
Expand Down
110 changes: 54 additions & 56 deletions src/python-fastui/fastui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
'Image',
'Iframe',
'FireEvent',
'Custom',
'Table',
'Pagination',
'Display',
Expand All @@ -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
Expand All @@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -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'

Expand All @@ -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'

Expand Down Expand Up @@ -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'),
]
)
75 changes: 75 additions & 0 deletions src/python-fastui/fastui/custom1.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading