niceforms 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- niceforms-0.1.1/PKG-INFO +20 -0
- niceforms-0.1.1/README.md +2 -0
- niceforms-0.1.1/niceforms/__init__.py +104 -0
- niceforms-0.1.1/niceforms/actions.py +7 -0
- niceforms-0.1.1/niceforms/constants.py +11 -0
- niceforms-0.1.1/niceforms/ui/__init__.py +10 -0
- niceforms-0.1.1/niceforms/ui/body.py +19 -0
- niceforms-0.1.1/niceforms/ui/footer.py +78 -0
- niceforms-0.1.1/niceforms/ui/header.py +67 -0
- niceforms-0.1.1/niceforms/ui/json_viewer.py +25 -0
- niceforms-0.1.1/niceforms/utils.py +95 -0
- niceforms-0.1.1/niceforms/widget/__init__.py +93 -0
- niceforms-0.1.1/niceforms/widget/bool.py +27 -0
- niceforms-0.1.1/niceforms/widget/enum.py +31 -0
- niceforms-0.1.1/niceforms/widget/float.py +28 -0
- niceforms-0.1.1/niceforms/widget/integer.py +29 -0
- niceforms-0.1.1/niceforms/widget/list.py +51 -0
- niceforms-0.1.1/niceforms/widget/string.py +26 -0
- niceforms-0.1.1/niceforms/widget/unknown_type.py +33 -0
- niceforms-0.1.1/niceforms/widget_factory.py +69 -0
- niceforms-0.1.1/pyproject.toml +35 -0
niceforms-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: niceforms
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A dynamic form generator for NiceGUI that automatically creates interactive UI forms from Pydantic models
|
|
5
|
+
Author: pasha_danilevich
|
|
6
|
+
Author-email: danilevitch.pasha@yandex.ru
|
|
7
|
+
Requires-Python: >=3.10,<4
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: nicegui (>=3.6.1,<4.0.0)
|
|
15
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# niceforms
|
|
19
|
+
A dynamic form generator for NiceGUI that automatically creates interactive UI forms from Pydantic models.
|
|
20
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pprint import pprint
|
|
3
|
+
from typing import Optional, Type
|
|
4
|
+
|
|
5
|
+
from actions import OnSubmit
|
|
6
|
+
from constants import *
|
|
7
|
+
from nicegui import ui
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from pydantic.fields import FieldInfo
|
|
10
|
+
from ui import UIComponent
|
|
11
|
+
from ui.body import Body
|
|
12
|
+
from ui.footer import Footer
|
|
13
|
+
from ui.header import Header
|
|
14
|
+
from utils import get_nested_models
|
|
15
|
+
from widget_factory import WidgetFactory
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
factory = WidgetFactory()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseModelForm(UIComponent):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
model: Type[BaseModel],
|
|
25
|
+
on_submit: Optional[OnSubmit] = None,
|
|
26
|
+
title: Optional[str] = None,
|
|
27
|
+
header_bg_color: Optional[str] = None,
|
|
28
|
+
view_annotation_type: bool = True,
|
|
29
|
+
view_clear_button: bool = True,
|
|
30
|
+
view_json_button: bool = True,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize universal form.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
model: Pydantic model class
|
|
36
|
+
on_submit: Callback function for form submission
|
|
37
|
+
title: Form title (if None, uses model name)
|
|
38
|
+
"""
|
|
39
|
+
self.model = model
|
|
40
|
+
self.on_submit = on_submit
|
|
41
|
+
self.title = title or model.__name__
|
|
42
|
+
self.header_bg_color = header_bg_color
|
|
43
|
+
self.view_annotation_type = view_annotation_type
|
|
44
|
+
self.view_clear_button = view_clear_button
|
|
45
|
+
self.view_json_button = view_json_button
|
|
46
|
+
|
|
47
|
+
# style
|
|
48
|
+
self._card = None # тело всей формы
|
|
49
|
+
self._is_nested = False
|
|
50
|
+
|
|
51
|
+
def render(self) -> None:
|
|
52
|
+
"""Render the form UI."""
|
|
53
|
+
logger.debug(f"Rendering form {self.model.__name__}")
|
|
54
|
+
nested_models = get_nested_models(self.model)
|
|
55
|
+
print(f'{nested_models=}')
|
|
56
|
+
fields: dict[str, FieldInfo] = self.model.model_fields # type: ignore
|
|
57
|
+
print(f'{fields=}')
|
|
58
|
+
|
|
59
|
+
for n_model in nested_models:
|
|
60
|
+
try:
|
|
61
|
+
del fields[n_model.field_name]
|
|
62
|
+
logger.debug(f'Field "{n_model.field_name}" is deleted')
|
|
63
|
+
except KeyError:
|
|
64
|
+
logger.debug(f'Field "{n_model.field_name}" is not defined')
|
|
65
|
+
|
|
66
|
+
widgets = factory.build(
|
|
67
|
+
model_fields=fields, view_annotation_type=self.view_annotation_type
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
with ui.card().classes(
|
|
71
|
+
f"p-2 w-full {DEFAULT_FORM_WIDTH} mx-auto shadow-lg rounded-xl overflow-hidden sm:p-4"
|
|
72
|
+
) as self._card:
|
|
73
|
+
Header(
|
|
74
|
+
title=self.title,
|
|
75
|
+
description=self.model.__doc__,
|
|
76
|
+
bg_color=self.header_bg_color,
|
|
77
|
+
parent_card=self._card,
|
|
78
|
+
is_nested=self._is_nested,
|
|
79
|
+
).render()
|
|
80
|
+
|
|
81
|
+
elements = Body(widgets).render()
|
|
82
|
+
|
|
83
|
+
for n_model in nested_models:
|
|
84
|
+
|
|
85
|
+
nested_form = BaseModelForm(
|
|
86
|
+
model=n_model.model,
|
|
87
|
+
header_bg_color='#2eeead',
|
|
88
|
+
on_submit=None,
|
|
89
|
+
view_json_button=False,
|
|
90
|
+
view_annotation_type=self.view_annotation_type,
|
|
91
|
+
view_clear_button=False,
|
|
92
|
+
)
|
|
93
|
+
nested_form._is_nested = True
|
|
94
|
+
nested_form.render()
|
|
95
|
+
nested_form._card.style('height: 100px')
|
|
96
|
+
|
|
97
|
+
if not self._is_nested:
|
|
98
|
+
Footer(
|
|
99
|
+
elements=elements,
|
|
100
|
+
model=self.model,
|
|
101
|
+
on_submit=self.on_submit,
|
|
102
|
+
view_clear_button=self.view_clear_button,
|
|
103
|
+
view_json_button=self.view_json_button,
|
|
104
|
+
).render()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Константы приложения."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_FORM_WIDTH = "max-w-2xl"
|
|
4
|
+
PRIMARY_COLOR_GRADIENT = "linear-gradient(to right, #3b82f6, #9333ea)"
|
|
5
|
+
ERROR_BACKGROUND_COLOR = "bg-red-50"
|
|
6
|
+
ERROR_BORDER_COLOR = "border-red-200"
|
|
7
|
+
SUCCESS_BACKGROUND_COLOR = "bg-green-50"
|
|
8
|
+
SUCCESS_BORDER_COLOR = "border-green-200"
|
|
9
|
+
NULL_OPTION_VALUE = "__null__"
|
|
10
|
+
NULL_OPTION_LABEL = "<Не указано>"
|
|
11
|
+
DEFAULT_PADDING = "p-4"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from nicegui import ui
|
|
2
|
+
from widget import BaseWidget, RenderedWidget
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Body:
|
|
6
|
+
def __init__(self, widgets: list[BaseWidget]) -> None:
|
|
7
|
+
self.widgets = widgets
|
|
8
|
+
|
|
9
|
+
def render(self) -> list[RenderedWidget]:
|
|
10
|
+
elements = []
|
|
11
|
+
|
|
12
|
+
with ui.column().classes(f"w-full space-y-3 p-1 sm:p-4"):
|
|
13
|
+
for w in self.widgets:
|
|
14
|
+
with ui.element().classes(f"w-full"):
|
|
15
|
+
w.render_label()
|
|
16
|
+
el = w.render()
|
|
17
|
+
elements.append(el)
|
|
18
|
+
|
|
19
|
+
return elements
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from actions import OnSubmit
|
|
5
|
+
from nicegui import ui
|
|
6
|
+
from nicegui.elements.button import Button
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from ui.json_viewer import JsonDialog
|
|
9
|
+
from widget import RenderedWidget
|
|
10
|
+
|
|
11
|
+
from niceforms import PRIMARY_COLOR_GRADIENT, UIComponent
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Footer(UIComponent):
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
elements: list[RenderedWidget],
|
|
20
|
+
model: type[BaseModel],
|
|
21
|
+
on_submit: Optional[OnSubmit],
|
|
22
|
+
view_clear_button: bool = True,
|
|
23
|
+
view_json_button: bool = True,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.elements = elements
|
|
26
|
+
self.model = model
|
|
27
|
+
self.on_submit = on_submit
|
|
28
|
+
self.view_clear_button = view_clear_button
|
|
29
|
+
self.view_json_button = view_json_button
|
|
30
|
+
|
|
31
|
+
self._write_to_form_button: Optional[Button] = None
|
|
32
|
+
self._submit_button: Optional[Button] = None
|
|
33
|
+
|
|
34
|
+
def init_base_model(self) -> BaseModel:
|
|
35
|
+
data: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
for element in self.elements:
|
|
38
|
+
data[element.widget.field_name] = element.collect()
|
|
39
|
+
|
|
40
|
+
return self.model(**data)
|
|
41
|
+
|
|
42
|
+
def clear_form(self) -> None:
|
|
43
|
+
logger.debug('Cleared form')
|
|
44
|
+
for element in self.elements:
|
|
45
|
+
element.clear()
|
|
46
|
+
|
|
47
|
+
def render_json_viewer_dialog(self) -> None:
|
|
48
|
+
JsonDialog(model=self.init_base_model()).render()
|
|
49
|
+
|
|
50
|
+
def _write_nested_form_to_main_form(self) -> None:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
async def submit(self) -> None:
|
|
54
|
+
|
|
55
|
+
if self.on_submit is None:
|
|
56
|
+
logger.warning(f"on_submit function do not provided")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
await self.on_submit(self.init_base_model())
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def render(self) -> None:
|
|
63
|
+
with ui.row().classes("w-full justify-end gap-3"):
|
|
64
|
+
if self.view_clear_button:
|
|
65
|
+
ui.button("Очистить", on_click=self.clear_form).props(
|
|
66
|
+
"outlined flat"
|
|
67
|
+
).classes("px-6 py-2")
|
|
68
|
+
|
|
69
|
+
if self.view_json_button:
|
|
70
|
+
ui.button(
|
|
71
|
+
"Показать json", on_click=self.render_json_viewer_dialog
|
|
72
|
+
).props("outlined flat").classes("px-6 py-2")
|
|
73
|
+
|
|
74
|
+
self._submit_button = (
|
|
75
|
+
ui.button("Отправить", on_click=self.submit, icon="send")
|
|
76
|
+
.props("unelevated")
|
|
77
|
+
.classes(f"{PRIMARY_COLOR_GRADIENT} text-white px-8 py-2")
|
|
78
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
from nicegui.element import Element
|
|
5
|
+
from nicegui.elements.button import Button
|
|
6
|
+
|
|
7
|
+
from niceforms import UIComponent
|
|
8
|
+
from niceforms.constants import DEFAULT_PADDING, PRIMARY_COLOR_GRADIENT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Header(UIComponent):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
title: str,
|
|
15
|
+
description: Optional[str],
|
|
16
|
+
parent_card: Element,
|
|
17
|
+
is_nested: bool,
|
|
18
|
+
bg_color: Optional[str] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.title = title
|
|
21
|
+
self.description = description
|
|
22
|
+
self.parent_card = parent_card
|
|
23
|
+
self.is_nested = is_nested
|
|
24
|
+
self.bg_color = bg_color if bg_color else PRIMARY_COLOR_GRADIENT
|
|
25
|
+
|
|
26
|
+
self._is_expanded = False
|
|
27
|
+
self._button: Optional[Button] = None
|
|
28
|
+
self._description: Optional[Element] = None
|
|
29
|
+
|
|
30
|
+
def toggle_expand_parent(self) -> None:
|
|
31
|
+
if self._is_expanded:
|
|
32
|
+
self.parent_card.style('height: 100px')
|
|
33
|
+
self._is_expanded = False
|
|
34
|
+
self._button.text = 'Развернуть'
|
|
35
|
+
if self._description:
|
|
36
|
+
self._description.set_visibility(False)
|
|
37
|
+
else:
|
|
38
|
+
self.parent_card.style('height: 100%')
|
|
39
|
+
self._is_expanded = True
|
|
40
|
+
self._button.text = 'Свернуть'
|
|
41
|
+
if self._description:
|
|
42
|
+
self._description.set_visibility(True)
|
|
43
|
+
|
|
44
|
+
def render(self) -> None:
|
|
45
|
+
with ui.element().classes(f"w-full {DEFAULT_PADDING} rounded-lg").style(
|
|
46
|
+
f'background: {self.bg_color}'
|
|
47
|
+
):
|
|
48
|
+
# Контейнер для заголовка и кнопки
|
|
49
|
+
with ui.element().classes("flex justify-between items-start"):
|
|
50
|
+
ui.label(self.title).classes("text-2xl font-bold text-white")
|
|
51
|
+
|
|
52
|
+
if self.is_nested:
|
|
53
|
+
self._button = (
|
|
54
|
+
ui.button('Развернуть', on_click=self.toggle_expand_parent)
|
|
55
|
+
.classes(
|
|
56
|
+
"px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-white "
|
|
57
|
+
"transition-all duration-200 text-sm font-medium"
|
|
58
|
+
)
|
|
59
|
+
.props("outlined flat")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if self.description:
|
|
63
|
+
self._description = (
|
|
64
|
+
ui.label(self.description).classes("mt-2").style('color: #ffffff')
|
|
65
|
+
)
|
|
66
|
+
if self.is_nested:
|
|
67
|
+
self._description.set_visibility(False)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from nicegui import ui
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from niceforms import UIComponent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JsonDialog(UIComponent):
|
|
8
|
+
def __init__(self, model: BaseModel):
|
|
9
|
+
self.model = model
|
|
10
|
+
|
|
11
|
+
def render(self) -> None:
|
|
12
|
+
|
|
13
|
+
json_data = {'content': {'json': self.model.model_dump()}}
|
|
14
|
+
|
|
15
|
+
with ui.dialog() as dialog, ui.card():
|
|
16
|
+
ui.label('Результирующий JSON объект').classes('text-h5 font-bold')
|
|
17
|
+
ui.label('(только чтение)')
|
|
18
|
+
|
|
19
|
+
# ui.json автоматически красиво форматирует JSON
|
|
20
|
+
ui.json_editor(json_data).classes('w-full')
|
|
21
|
+
|
|
22
|
+
with ui.row().classes('justify-end w-full mt-4'):
|
|
23
|
+
ui.button('Close', on_click=dialog.close).props('flat')
|
|
24
|
+
|
|
25
|
+
dialog.open()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from types import NoneType, UnionType
|
|
4
|
+
from typing import Any, Type, Union, get_args, get_origin, get_type_hints
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NormalizedType(BaseModel):
|
|
12
|
+
is_nullable: bool
|
|
13
|
+
origin_type: Union[type, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_type(field_type: type | UnionType) -> NormalizedType:
|
|
17
|
+
origin = get_origin(field_type)
|
|
18
|
+
args = get_args(field_type)
|
|
19
|
+
|
|
20
|
+
if origin is UnionType:
|
|
21
|
+
|
|
22
|
+
if NoneType in args:
|
|
23
|
+
return NormalizedType(is_nullable=True, origin_type=field_type)
|
|
24
|
+
else:
|
|
25
|
+
return NormalizedType(is_nullable=False, origin_type=field_type)
|
|
26
|
+
|
|
27
|
+
if origin is None:
|
|
28
|
+
return NormalizedType(is_nullable=False, origin_type=field_type)
|
|
29
|
+
|
|
30
|
+
if origin is Union:
|
|
31
|
+
|
|
32
|
+
if NoneType in args:
|
|
33
|
+
non_nullable = [x for x in args if x is not NoneType]
|
|
34
|
+
if len(non_nullable) == 1:
|
|
35
|
+
return NormalizedType(is_nullable=True, origin_type=non_nullable[0])
|
|
36
|
+
return NormalizedType(is_nullable=True, origin_type=field_type)
|
|
37
|
+
else:
|
|
38
|
+
return NormalizedType(is_nullable=False, origin_type=field_type)
|
|
39
|
+
|
|
40
|
+
return NormalizedType(is_nullable=False, origin_type=field_type)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_enum_type(field_type: type) -> bool:
|
|
44
|
+
return isinstance(field_type, type) and issubclass(field_type, Enum)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NestedModel(BaseModel):
|
|
48
|
+
model: type[BaseModel]
|
|
49
|
+
field_name: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_model_from_type(attr_type) -> list[tuple[type[BaseModel], str]]:
|
|
53
|
+
"""
|
|
54
|
+
Рекурсивно извлекает модели BaseModel из типа, обрабатывая Union/Optional.
|
|
55
|
+
Возвращает список кортежей (модель, field_name) - field_name будет использован позже.
|
|
56
|
+
"""
|
|
57
|
+
result = []
|
|
58
|
+
|
|
59
|
+
# Получаем оригинальный тип и аргументы
|
|
60
|
+
origin = get_origin(attr_type)
|
|
61
|
+
|
|
62
|
+
if origin is Union or origin is UnionType:
|
|
63
|
+
# Это Union/Optional тип, обрабатываем каждый аргумент
|
|
64
|
+
for arg in get_args(attr_type):
|
|
65
|
+
result.extend(extract_model_from_type(arg))
|
|
66
|
+
elif (
|
|
67
|
+
isinstance(attr_type, type)
|
|
68
|
+
and issubclass(attr_type, BaseModel)
|
|
69
|
+
and attr_type != BaseModel
|
|
70
|
+
):
|
|
71
|
+
# Это непосредственно класс BaseModel
|
|
72
|
+
result.append(attr_type)
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_nested_models(model_class: Type[BaseModel]) -> list[NestedModel]:
|
|
78
|
+
"""
|
|
79
|
+
Находит все атрибуты переданной модели, которые являются подклассами BaseModel.
|
|
80
|
+
Работает с Optional и Union типами.
|
|
81
|
+
"""
|
|
82
|
+
result: list[NestedModel] = []
|
|
83
|
+
|
|
84
|
+
# Получаем аннотации типов для всех атрибутов модели
|
|
85
|
+
type_hints = get_type_hints(model_class)
|
|
86
|
+
|
|
87
|
+
for attr_name, attr_type in type_hints.items():
|
|
88
|
+
# Извлекаем модели из типа (обрабатывая Optional/Union)
|
|
89
|
+
models = extract_model_from_type(attr_type)
|
|
90
|
+
|
|
91
|
+
# Добавляем каждую найденную модель в результат
|
|
92
|
+
for model in models:
|
|
93
|
+
result.append(NestedModel(model=model, field_name=attr_name))
|
|
94
|
+
|
|
95
|
+
return result
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Виджеты для полей формы."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from nicegui import ui
|
|
8
|
+
from nicegui.elements.button import Button
|
|
9
|
+
from nicegui.elements.mixins.value_element import ValueElement
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
11
|
+
from pydantic_core import PydanticUndefined
|
|
12
|
+
|
|
13
|
+
from niceforms import UIComponent
|
|
14
|
+
from utils import NormalizedType
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseWidget(UIComponent, ABC):
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
field_info: FieldInfo,
|
|
23
|
+
field_name: str,
|
|
24
|
+
normalized_type: NormalizedType,
|
|
25
|
+
view_annotation_type: bool,
|
|
26
|
+
):
|
|
27
|
+
self.field = field_info
|
|
28
|
+
self.field_name = field_name
|
|
29
|
+
self.normalized_type = normalized_type
|
|
30
|
+
self.view_annotation_type = view_annotation_type
|
|
31
|
+
|
|
32
|
+
self.clear_button: Optional[Button] = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def placeholder(self) -> str:
|
|
36
|
+
return (
|
|
37
|
+
f"Введите {self.field.title.lower()}"
|
|
38
|
+
if self.field.title
|
|
39
|
+
else "Введите значение"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def default_value(self) -> Optional[Any]:
|
|
44
|
+
return (
|
|
45
|
+
self.field.default if self.field.default is not PydanticUndefined else None
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def render_label(self) -> None:
|
|
49
|
+
text = self.field.title if self.field.title else self.field_name.title()
|
|
50
|
+
logger.debug(f"render label: {text}")
|
|
51
|
+
|
|
52
|
+
with ui.row().classes('mb-1 items-baseline justify-between w-full gap-1'):
|
|
53
|
+
with ui.row().classes('items-baseline gap-1'):
|
|
54
|
+
ui.label(text=text).classes('font-bold text-lg')
|
|
55
|
+
if self.field.is_required():
|
|
56
|
+
ui.label(text='*').classes('text-gray-400 text-md font-normal')
|
|
57
|
+
if self.view_annotation_type:
|
|
58
|
+
ui.label(text=f' [{self.field.annotation}]').classes(
|
|
59
|
+
'text-gray-400 text-md font-normal'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self.clear_button = (
|
|
63
|
+
ui.button(icon='close', color='secondary')
|
|
64
|
+
.props('flat dense round')
|
|
65
|
+
.classes('text-xs opacity-30 hover:opacity-80 transition-opacity')
|
|
66
|
+
.tooltip('Очистить')
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if self.field.description:
|
|
70
|
+
ui.label(text=self.field.description).classes("mb-1 text-gray-500")
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def render(
|
|
74
|
+
self,
|
|
75
|
+
) -> (
|
|
76
|
+
"RenderedWidget"
|
|
77
|
+
): # TODO: возможно достаточно будет в RenderedWidget передавать в место BaseWidget "field_name"
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RenderedWidget(ABC):
|
|
82
|
+
def __init__(self, widget: BaseWidget, element: ValueElement) -> None:
|
|
83
|
+
self.widget = widget
|
|
84
|
+
self.element = element
|
|
85
|
+
|
|
86
|
+
self.widget.clear_button.on_click(self.clear)
|
|
87
|
+
|
|
88
|
+
def clear(self) -> None:
|
|
89
|
+
self.element.set_value(None)
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def collect(self) -> Optional[Any]:
|
|
93
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
|
|
5
|
+
from niceforms.widget import BaseWidget, RenderedWidget
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RenderedBoolWidget(RenderedWidget):
|
|
9
|
+
|
|
10
|
+
def collect(self) -> Optional[Any]:
|
|
11
|
+
return self.element.value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BoolWidget(BaseWidget):
|
|
15
|
+
|
|
16
|
+
def render(self) -> RenderedBoolWidget:
|
|
17
|
+
|
|
18
|
+
checkbox = (
|
|
19
|
+
ui.checkbox(
|
|
20
|
+
text='Выставьте checkbox',
|
|
21
|
+
value=self.default_value,
|
|
22
|
+
)
|
|
23
|
+
.props("outlined dense")
|
|
24
|
+
.classes("w-full")
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return RenderedBoolWidget(self, checkbox)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
|
|
5
|
+
from niceforms.widget import BaseWidget, RenderedWidget
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RenderedEnumWidget(RenderedWidget):
|
|
9
|
+
|
|
10
|
+
def collect(self) -> Optional[Any]:
|
|
11
|
+
return self.element.value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EnumWidget(BaseWidget):
|
|
15
|
+
"""Виджет для полей типа Enum с выплывающим списком"""
|
|
16
|
+
|
|
17
|
+
def render(self) -> RenderedEnumWidget:
|
|
18
|
+
print(f'{self.field_name}: {self.normalized_type.origin_type}')
|
|
19
|
+
options = list(self.normalized_type.origin_type)
|
|
20
|
+
|
|
21
|
+
s = (
|
|
22
|
+
ui.select(
|
|
23
|
+
label='Выберите значение',
|
|
24
|
+
value=self.default_value,
|
|
25
|
+
options=options,
|
|
26
|
+
)
|
|
27
|
+
.props("outlined dense")
|
|
28
|
+
.classes("w-full")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return RenderedEnumWidget(self, s)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
|
|
5
|
+
from niceforms.widget import BaseWidget, RenderedWidget
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RenderedFloatWidget(RenderedWidget):
|
|
9
|
+
|
|
10
|
+
def collect(self) -> Optional[float]:
|
|
11
|
+
if self.element.value is None:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
return self.element.value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FloatWidget(BaseWidget):
|
|
18
|
+
def render(self) -> RenderedWidget:
|
|
19
|
+
el = (
|
|
20
|
+
ui.number(
|
|
21
|
+
value=self.default_value,
|
|
22
|
+
placeholder=self.placeholder,
|
|
23
|
+
)
|
|
24
|
+
.props("outlined dense")
|
|
25
|
+
.classes("w-full")
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return RenderedFloatWidget(self, el)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
from pydantic_core import PydanticUndefinedType
|
|
5
|
+
|
|
6
|
+
from niceforms.widget import BaseWidget, RenderedWidget
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RenderedIntegerWidget(RenderedWidget):
|
|
10
|
+
|
|
11
|
+
def collect(self) -> Optional[int]:
|
|
12
|
+
if self.element.value is None:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
return int(self.element.value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IntegerWidget(BaseWidget):
|
|
19
|
+
def render(self) -> RenderedWidget:
|
|
20
|
+
el = (
|
|
21
|
+
ui.number(
|
|
22
|
+
value=self.default_value,
|
|
23
|
+
placeholder=self.placeholder,
|
|
24
|
+
)
|
|
25
|
+
.props("outlined dense")
|
|
26
|
+
.classes("w-full")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return RenderedIntegerWidget(self, el)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from json import JSONDecodeError
|
|
4
|
+
from typing import Any, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from nicegui import ui
|
|
7
|
+
from utils import normalize_type
|
|
8
|
+
|
|
9
|
+
from niceforms.widget import BaseWidget, RenderedWidget
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RenderedListWidget(RenderedWidget):
|
|
13
|
+
|
|
14
|
+
def collect(self) -> Optional[Union[list, tuple]]:
|
|
15
|
+
if self.element.value is not None:
|
|
16
|
+
return json.loads(self.element.value)
|
|
17
|
+
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ListWidget(BaseWidget):
|
|
22
|
+
type_tip_map: dict[type, str] = {
|
|
23
|
+
List[str]: '["яблоко", "банан", "апельсин"]',
|
|
24
|
+
List[int]: '[423, 324, 983]',
|
|
25
|
+
list[str]: '["яблоко", "банан", "апельсин"]',
|
|
26
|
+
list[int]: '[423, 324, 983]',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def render(self) -> RenderedWidget:
|
|
30
|
+
default_value = (
|
|
31
|
+
json.dumps(self.default_value) if self.default_value is not None else None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
el = (
|
|
35
|
+
ui.textarea(value=default_value, placeholder=self.placeholder)
|
|
36
|
+
.props("outlined dense")
|
|
37
|
+
.classes("w-full font-mono")
|
|
38
|
+
)
|
|
39
|
+
# Контейнер для лейбла и иконки
|
|
40
|
+
with ui.row().classes('items-center gap-1'):
|
|
41
|
+
ui.label(text=f'Строка парсится как JSON').classes('text-xs mt-1')
|
|
42
|
+
normalized_type = normalize_type(self.field.annotation)
|
|
43
|
+
|
|
44
|
+
example = self.type_tip_map.get(normalized_type.origin_type)
|
|
45
|
+
if example is not None:
|
|
46
|
+
# Иконка с подсказкой при наведении
|
|
47
|
+
ui.icon('info', size='xs').classes('cursor-help mt-1').tooltip(
|
|
48
|
+
f'Пример ввода: {example}'
|
|
49
|
+
).style('color: #8989ff')
|
|
50
|
+
|
|
51
|
+
return RenderedListWidget(self, el)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
from pydantic_core import PydanticUndefinedType
|
|
5
|
+
|
|
6
|
+
from niceforms.widget import BaseWidget, RenderedWidget
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RenderedStringWidget(RenderedWidget):
|
|
10
|
+
|
|
11
|
+
def collect(self) -> Optional[str]:
|
|
12
|
+
if self.element.value == '':
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
return self.element.value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StringWidget(BaseWidget):
|
|
19
|
+
|
|
20
|
+
def render(self) -> RenderedWidget:
|
|
21
|
+
el = (
|
|
22
|
+
ui.input(value=self.default_value, placeholder=self.placeholder)
|
|
23
|
+
.props("outlined dense")
|
|
24
|
+
.classes("w-full")
|
|
25
|
+
)
|
|
26
|
+
return RenderedStringWidget(self, el)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
from pydantic_core import PydanticUndefinedType
|
|
5
|
+
|
|
6
|
+
from niceforms.widget import BaseWidget, RenderedWidget
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RenderedUnknownTypeWidget(RenderedWidget):
|
|
10
|
+
|
|
11
|
+
def collect(self) -> Optional[int]:
|
|
12
|
+
return self.element.value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnknownTypeWidget(BaseWidget):
|
|
16
|
+
"""Для неизвестных типов предлагается вводить JSON строку"""
|
|
17
|
+
|
|
18
|
+
def render(self) -> RenderedWidget:
|
|
19
|
+
el = (
|
|
20
|
+
ui.input(
|
|
21
|
+
value=self.default_value,
|
|
22
|
+
placeholder=self.placeholder,
|
|
23
|
+
# on_change=self._validate_json,
|
|
24
|
+
)
|
|
25
|
+
.classes('w-full font-mono')
|
|
26
|
+
.props("outlined dense")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
ui.label(
|
|
30
|
+
text=f'Для типа "{self.field.annotation}" не существует виджета. Предоставлен обычный ввод строки.'
|
|
31
|
+
).classes('text-xs mt-1').style('color: #ff3a3a')
|
|
32
|
+
|
|
33
|
+
return RenderedUnknownTypeWidget(self, el)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from pydantic.fields import FieldInfo
|
|
6
|
+
from utils import is_enum_type, normalize_type
|
|
7
|
+
from widget import BaseWidget
|
|
8
|
+
from widget.bool import BoolWidget
|
|
9
|
+
from widget.enum import EnumWidget
|
|
10
|
+
from widget.float import FloatWidget
|
|
11
|
+
from widget.integer import IntegerWidget
|
|
12
|
+
from widget.list import ListWidget
|
|
13
|
+
from widget.string import StringWidget
|
|
14
|
+
from widget.unknown_type import UnknownTypeWidget
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WidgetFactory:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._widgets: dict[type, type[BaseWidget]] = {
|
|
22
|
+
str: StringWidget,
|
|
23
|
+
int: IntegerWidget,
|
|
24
|
+
int | None: IntegerWidget,
|
|
25
|
+
Enum: EnumWidget,
|
|
26
|
+
bool: BoolWidget,
|
|
27
|
+
float: FloatWidget,
|
|
28
|
+
list[str]: ListWidget,
|
|
29
|
+
List[str]: ListWidget,
|
|
30
|
+
list[int]: ListWidget,
|
|
31
|
+
List[int]: ListWidget,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def insert_new_widget(
|
|
35
|
+
self, field_type: type, widget_type: type[BaseWidget]
|
|
36
|
+
) -> None:
|
|
37
|
+
self._widgets[field_type] = widget_type
|
|
38
|
+
|
|
39
|
+
def build(
|
|
40
|
+
self, model_fields: dict[str, FieldInfo], view_annotation_type: bool
|
|
41
|
+
) -> list[BaseWidget]:
|
|
42
|
+
widgets: list[BaseWidget] = []
|
|
43
|
+
|
|
44
|
+
for field_name, field_type in model_fields.items():
|
|
45
|
+
normalized_type = normalize_type(field_type.annotation)
|
|
46
|
+
try:
|
|
47
|
+
widget = self._widgets[field_type.annotation]
|
|
48
|
+
except KeyError:
|
|
49
|
+
widget = self._widgets.get(normalized_type.origin_type)
|
|
50
|
+
|
|
51
|
+
if is_enum_type(normalized_type.origin_type):
|
|
52
|
+
widget = self._widgets[Enum]
|
|
53
|
+
|
|
54
|
+
if widget is None:
|
|
55
|
+
logger.warning(
|
|
56
|
+
f'No widget for field "{field_name}". Type {field_type.annotation}. Creating default <UnknownTypeWidget>'
|
|
57
|
+
)
|
|
58
|
+
widget = UnknownTypeWidget
|
|
59
|
+
|
|
60
|
+
widgets.append(
|
|
61
|
+
widget(
|
|
62
|
+
field_info=field_type,
|
|
63
|
+
field_name=field_name,
|
|
64
|
+
normalized_type=normalized_type,
|
|
65
|
+
view_annotation_type=view_annotation_type,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return widgets
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "niceforms"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "A dynamic form generator for NiceGUI that automatically creates interactive UI forms from Pydantic models"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "pasha_danilevich", email = "danilevitch.pasha@yandex.ru"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
|
|
10
|
+
packages = [
|
|
11
|
+
{include = "niceforms"}
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.poetry.dependencies]
|
|
15
|
+
python = ">=3.10, <4"
|
|
16
|
+
pydantic = "^2.12.5"
|
|
17
|
+
nicegui = "^3.6.1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
23
|
+
build-backend = "poetry.core.masonry.api"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest (>=9.0.2,<10.0.0)",
|
|
28
|
+
"black (>=26.3.1,<27.0.0)",
|
|
29
|
+
"gitprompter (>=0.2.0,<0.3.0)"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.black]
|
|
33
|
+
line-length = 88
|
|
34
|
+
skip-string-normalization = true
|
|
35
|
+
preview = true
|