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.
@@ -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,2 @@
1
+ # niceforms
2
+ A dynamic form generator for NiceGUI that automatically creates interactive UI forms from Pydantic models.
@@ -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,7 @@
1
+ from typing import Protocol
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class OnSubmit(Protocol):
7
+ async def __call__(self, model: BaseModel) -> None: ...
@@ -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,10 @@
1
+ """Компоненты пользовательского интерфейса."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class UIComponent(ABC):
7
+
8
+ @abstractmethod
9
+ def render(self) -> None:
10
+ raise NotImplementedError()
@@ -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