niceforms 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ import logging
2
+ from pprint import pprint
3
+ from typing import Type, Optional
4
+
5
+ from nicegui import ui
6
+ from pydantic import BaseModel
7
+ from pydantic.fields import FieldInfo
8
+
9
+ from actions import OnSubmit
10
+ from constants import *
11
+ from ui import UIComponent
12
+ from ui.body import Body
13
+ from ui.footer import Footer
14
+ from ui.header import Header
15
+ from utils import get_nested_models
16
+ from widget_factory import WidgetFactory
17
+
18
+ logger = logging.getLogger(__name__)
19
+ factory = WidgetFactory()
20
+
21
+
22
+ class BaseModelForm(UIComponent):
23
+ def __init__(
24
+ self,
25
+ model: Type[BaseModel],
26
+ on_submit: Optional[OnSubmit] = None,
27
+ title: Optional[str] = None,
28
+ header_bg_color: Optional[str] = None,
29
+ view_annotation_type: bool = True,
30
+ ) -> None:
31
+ """Initialize universal form.
32
+
33
+ Args:
34
+ model: Pydantic model class
35
+ on_submit: Callback function for form submission
36
+ title: Form title (if None, uses model name)
37
+ """
38
+ self.model = model
39
+ self.on_submit = on_submit
40
+ self.title = title or model.__name__
41
+ self.header_bg_color = header_bg_color
42
+ self.view_annotation_type = view_annotation_type
43
+
44
+ # style
45
+ self._card = None # тело всей формы
46
+ self._is_nested = False
47
+
48
+ def render(self) -> None:
49
+ """Render the form UI."""
50
+ nested_models = get_nested_models(self.model)
51
+ fields: dict[str, FieldInfo] = self.model.model_fields # type: ignore
52
+
53
+ for n_model in nested_models:
54
+ try:
55
+ del fields[n_model.field_name]
56
+ except KeyError:
57
+ logger.debug(f'Field "{n_model.field_name}" is not defined')
58
+
59
+ widgets = factory.build(
60
+ model_fields=fields, view_annotation_type=self.view_annotation_type
61
+ )
62
+
63
+ with ui.card().classes(
64
+ f"w-full {DEFAULT_FORM_WIDTH} mx-auto shadow-lg rounded-xl overflow-hidden"
65
+ ) as self._card:
66
+ Header(
67
+ title=self.title,
68
+ description=self.model.__doc__,
69
+ bg_color=self.header_bg_color,
70
+ parent_card=self._card,
71
+ is_nested=self._is_nested,
72
+ ).render()
73
+
74
+ elements = Body(widgets).render()
75
+
76
+ for n_model in nested_models:
77
+ if not self._is_nested:
78
+ nested_form = BaseModelForm(n_model.model, header_bg_color='#2eeead')
79
+ nested_form._is_nested = True
80
+ nested_form.render()
81
+ nested_form._card.style('height: 100px')
82
+
83
+ Footer(
84
+ elements=elements,
85
+ is_nested=self._is_nested,
86
+ model=self.model,
87
+ on_submit=self.on_submit,
88
+ ).render()
niceforms/actions.py ADDED
@@ -0,0 +1,8 @@
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: ...
8
+
niceforms/constants.py ADDED
@@ -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,13 @@
1
+ """Компоненты пользовательского интерфейса."""
2
+ from abc import ABC, abstractmethod
3
+ # from .header import Header
4
+ # from .body import Body
5
+
6
+ class UIComponent(ABC):
7
+
8
+ @abstractmethod
9
+ def render(self) -> None:
10
+ raise NotImplementedError()
11
+
12
+
13
+
niceforms/ui/body.py ADDED
@@ -0,0 +1,22 @@
1
+ from nicegui import ui
2
+ from nicegui.elements.mixins.value_element import ValueElement
3
+
4
+ from niceforms import UIComponent
5
+ from widget import BaseWidget, RenderedWidget
6
+
7
+
8
+ class Body:
9
+ def __init__(self, widgets: list[BaseWidget]) -> None:
10
+ self.widgets = widgets
11
+
12
+ def render(self) -> list[RenderedWidget]:
13
+ elements = []
14
+
15
+ with ui.column().classes(f"w-full p-4 space-y-3"):
16
+ for w in self.widgets:
17
+ with ui.element().classes(f"w-full"):
18
+ w.render_label()
19
+ el = w.render()
20
+ elements.append(el)
21
+
22
+ return elements
niceforms/ui/footer.py ADDED
@@ -0,0 +1,76 @@
1
+ import logging
2
+ from typing import Optional, Any
3
+
4
+ from nicegui import ui
5
+ from nicegui.elements.button import Button
6
+ from pydantic import BaseModel
7
+
8
+ from actions import OnSubmit
9
+ from niceforms import UIComponent, PRIMARY_COLOR_GRADIENT
10
+ from ui.json_viewer import JsonDialog
11
+ from widget import RenderedWidget
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Footer(UIComponent):
17
+ def __init__(self, elements: list[RenderedWidget], is_nested: bool, model: type[BaseModel],
18
+ on_submit: Optional[OnSubmit]) -> None:
19
+ self.elements = elements
20
+ self.is_nested = is_nested
21
+ self.model = model
22
+ self.on_submit = on_submit
23
+
24
+ self._write_to_form_button: Optional[Button] = None
25
+ self._submit_button: Optional[Button] = None
26
+
27
+ def init_base_model(self) -> BaseModel:
28
+ data: dict[str, Any] = {}
29
+
30
+ for element in self.elements:
31
+ data[element.widget.field_name] = element.collect()
32
+
33
+ return self.model(**data)
34
+
35
+ def clear_form(self) -> None:
36
+ logger.debug('Cleared form')
37
+ for element in self.elements:
38
+ element.clear()
39
+
40
+ def render_json_viewer_dialog(self) -> None:
41
+ JsonDialog(model=self.init_base_model()).render()
42
+
43
+ def _write_nested_form_to_main_form(self) -> None:
44
+ pass
45
+
46
+ async def submit(self) -> None:
47
+
48
+ if self.on_submit is None:
49
+ logger.warning(f"on_submit function do not provided")
50
+ return None
51
+
52
+ await self.on_submit(self.init_base_model())
53
+ return None
54
+
55
+ def render(self) -> None:
56
+ with ui.row().classes("w-full justify-end gap-3"):
57
+ ui.button("Очистить", on_click=self.clear_form).props(
58
+ "outlined flat"
59
+ ).classes("px-6 py-2")
60
+
61
+ ui.button("Показать json", on_click=self.render_json_viewer_dialog).props(
62
+ "outlined flat"
63
+ ).classes("px-6 py-2")
64
+
65
+ if self.is_nested:
66
+ self._write_to_form_button = (
67
+ ui.button("Сохранить в форму", on_click=self._write_nested_form_to_main_form)
68
+ .props("unelevated")
69
+ .classes(f"{PRIMARY_COLOR_GRADIENT} text-white px-8 py-2")
70
+ )
71
+ else:
72
+ self._submit_button = (
73
+ ui.button("Отправить", on_click=self.submit, icon="send")
74
+ .props("unelevated")
75
+ .classes(f"{PRIMARY_COLOR_GRADIENT} text-white px-8 py-2")
76
+ )
niceforms/ui/header.py ADDED
@@ -0,0 +1,54 @@
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
+
29
+ def toggle_expand_parent(self) -> None:
30
+ if self._is_expanded:
31
+ self.parent_card.style('height: 100px')
32
+ self._is_expanded = False
33
+ self._button.text = 'Развернуть'
34
+ else:
35
+ self.parent_card.style('height: 100%')
36
+ self._is_expanded = True
37
+ self._button.text = 'Свернуть'
38
+
39
+ def render(self) -> None:
40
+ with ui.element().classes(f"w-full {DEFAULT_PADDING} rounded-lg").style(
41
+ f'background: {self.bg_color}'
42
+ ):
43
+ # Контейнер для заголовка и кнопки
44
+ with ui.element().classes("flex justify-between items-start"):
45
+ ui.label(self.title).classes("text-2xl font-bold text-white")
46
+
47
+ if self.is_nested:
48
+ self._button = ui.button('Развернуть', on_click=self.toggle_expand_parent).classes(
49
+ "px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-white "
50
+ "transition-all duration-200 text-sm font-medium"
51
+ ).props("outlined flat")
52
+
53
+ if self.description:
54
+ ui.label(self.description).classes("text-blue-100 mt-2")
@@ -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()
niceforms/utils.py ADDED
@@ -0,0 +1,95 @@
1
+ import logging
2
+ from enum import Enum
3
+ from types import NoneType, UnionType
4
+ from typing import Type, Any, get_type_hints
5
+ from typing import Union, get_origin, get_args
6
+
7
+ from pydantic import BaseModel
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class NormalizedType(BaseModel):
13
+ is_nullable: bool
14
+ origin_type: Union[type, Any]
15
+
16
+
17
+ def normalize_type(field_type: type | UnionType) -> NormalizedType:
18
+ origin = get_origin(field_type)
19
+ args = get_args(field_type)
20
+
21
+ if origin is UnionType:
22
+
23
+ if NoneType in args:
24
+ return NormalizedType(is_nullable=True, origin_type=field_type)
25
+ else:
26
+ return NormalizedType(is_nullable=False, origin_type=field_type)
27
+
28
+ if origin is None:
29
+ return NormalizedType(is_nullable=False, origin_type=field_type)
30
+
31
+ if origin is Union:
32
+
33
+ if NoneType in args:
34
+ non_nullable = [x for x in args if x is not NoneType]
35
+ if len(non_nullable) == 1:
36
+ return NormalizedType(is_nullable=True, origin_type=non_nullable[0])
37
+ return NormalizedType(is_nullable=True, origin_type=field_type)
38
+ else:
39
+ return NormalizedType(is_nullable=False, origin_type=field_type)
40
+
41
+ return NormalizedType(is_nullable=False, origin_type=field_type)
42
+
43
+
44
+ def is_enum_type(field_type: type) -> bool:
45
+ return isinstance(field_type, type) and issubclass(field_type, Enum)
46
+
47
+
48
+ class NestedModel(BaseModel):
49
+ model: type[BaseModel]
50
+ field_name: str
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,104 @@
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
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class BaseWidget(UIComponent, ABC):
19
+ def __init__(
20
+ self,
21
+ field_info: FieldInfo,
22
+ field_name: str,
23
+ is_nullable: bool,
24
+ view_annotation_type: bool
25
+ ):
26
+ self.field = field_info
27
+ self.field_name = field_name
28
+ self.is_nullable = is_nullable
29
+ self.view_annotation_type = view_annotation_type
30
+
31
+ self.set_none_button: Optional[Button] = None
32
+
33
+ @property
34
+ def placeholder(self) -> str:
35
+ return (
36
+ f"Введите {self.field.title.lower()}"
37
+ if self.field.title
38
+ else "Введите значение"
39
+ )
40
+
41
+ @property
42
+ def default_value(self) -> Optional[Any]:
43
+ return (
44
+ self.field.default if self.field.default is not PydanticUndefined else None
45
+ )
46
+
47
+ def render_label(self) -> None:
48
+ text = self.field.title if self.field.title else self.field_name.title()
49
+ logger.debug(f"render label: {text}")
50
+
51
+ with ui.row().classes('mb-1 items-baseline justify-between w-full gap-1'):
52
+ with ui.row().classes('items-baseline gap-1'):
53
+ ui.label(text=text).classes('font-bold text-lg')
54
+ if self.field.is_required():
55
+ ui.label(text='*').classes('text-gray-400 text-md font-normal')
56
+ if self.view_annotation_type:
57
+ ui.label(text=f' [{self.field.annotation}]').classes(
58
+ 'text-gray-400 text-md font-normal'
59
+ )
60
+
61
+ if self.is_nullable:
62
+
63
+ self.set_none_button = ui.button('Set None', color='secondary').classes(
64
+ 'text-xs opacity-70 hover:opacity-100 transition-opacity py-0.7 px-2 min-h-0 h-auto'
65
+ )
66
+
67
+ if self.field.description:
68
+ ui.label(text=self.field.description).classes("mb-1 text-gray-500")
69
+
70
+ @abstractmethod
71
+ def render(
72
+ self,
73
+ ) -> (
74
+ "RenderedWidget"
75
+ ): # TODO: возможно достаточно будет в RenderedWidget передавать в место BaseWidget "field_name"
76
+ raise NotImplementedError
77
+
78
+
79
+ class RenderedWidget(ABC):
80
+ def __init__(self, widget: BaseWidget, element: ValueElement) -> None:
81
+ self.widget = widget
82
+ self.element = element
83
+
84
+ self._none_is_set = False
85
+
86
+ if self.widget.set_none_button:
87
+
88
+ def on_click_set_none() -> None:
89
+ if self._none_is_set:
90
+ self.widget.set_none_button.text = 'Set None'
91
+ self._none_is_set = False
92
+ else:
93
+ self.element.value = None
94
+ self.widget.set_none_button.text = 'None is set'
95
+ self._none_is_set = True
96
+
97
+ self.widget.set_none_button.on_click(on_click_set_none)
98
+
99
+ def clear(self) -> None:
100
+ self.element.set_value(None)
101
+
102
+ @abstractmethod
103
+ def collect(self) -> Optional[Any]:
104
+ 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,28 @@
1
+ from typing import Any, Optional
2
+ from enum import EnumMeta, Enum
3
+
4
+ from nicegui import ui
5
+ from pydantic_core import PydanticUndefinedType
6
+
7
+ from niceforms.widget import BaseWidget, RenderedWidget
8
+
9
+
10
+ class RenderedEnumWidget(RenderedWidget):
11
+
12
+ def collect(self) -> Optional[Any]:
13
+ return self.element.value
14
+
15
+
16
+ class EnumWidget(BaseWidget):
17
+ """Виджет для полей типа Enum с выплывающим списком"""
18
+
19
+ def render(self) -> RenderedEnumWidget:
20
+ options = list(self.field.annotation) # type: ignore
21
+
22
+ s = ui.select(
23
+ label='Выберите значение',
24
+ value=self.default_value,
25
+ options=options,
26
+ ).props("outlined dense").classes("w-full")
27
+
28
+ 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,25 @@
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 = ui.number(
21
+ value=self.default_value,
22
+ placeholder=self.placeholder,
23
+ ).props("outlined dense").classes("w-full")
24
+
25
+ return RenderedIntegerWidget(self, el)
@@ -0,0 +1,48 @@
1
+ from typing import Any, Optional, Union, List
2
+ from datetime import datetime
3
+ import json
4
+ from json import JSONDecodeError
5
+ from nicegui import ui
6
+
7
+ from niceforms.widget import BaseWidget, RenderedWidget
8
+ from utils import normalize_type
9
+
10
+
11
+ class RenderedListWidget(RenderedWidget):
12
+
13
+ def collect(self) -> Optional[Union[list, tuple]]:
14
+ if self.element.value is not None:
15
+ return json.loads(self.element.value)
16
+
17
+ return None
18
+
19
+
20
+ class ListWidget(BaseWidget):
21
+ type_tip_map: dict[type, str] = {
22
+ List[str]: '["яблоко", "банан", "апельсин"]',
23
+ List[int]: '[423, 324, 983]',
24
+ list[str]: '["яблоко", "банан", "апельсин"]',
25
+ list[int]: '[423, 324, 983]',
26
+ }
27
+
28
+ def render(self) -> RenderedWidget:
29
+ default_value = json.dumps(self.default_value) if self.default_value is not None else None
30
+
31
+ el = (
32
+ ui.textarea(value=default_value, placeholder=self.placeholder)
33
+ .props("outlined dense")
34
+ .classes("w-full font-mono")
35
+ )
36
+ # Контейнер для лейбла и иконки
37
+ with ui.row().classes('items-center gap-1'):
38
+ ui.label(text=f'Строка парсится как JSON').classes('text-xs mt-1')
39
+ normalized_type = normalize_type(self.field.annotation)
40
+
41
+ example = self.type_tip_map.get(normalized_type.origin_type)
42
+ if example is not None:
43
+ # Иконка с подсказкой при наведении
44
+ ui.icon('info', size='xs').classes('cursor-help mt-1').tooltip(
45
+ f'Пример ввода: {example}'
46
+ ).style('color: #8989ff')
47
+
48
+ return RenderedListWidget(self, el)
@@ -0,0 +1,22 @@
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 = ui.input(value=self.default_value, placeholder=self.placeholder).props("outlined dense").classes("w-full")
22
+ 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,68 @@
1
+ import logging
2
+ from enum import Enum
3
+ from typing import List
4
+
5
+ from pydantic.fields import FieldInfo
6
+
7
+ from utils import normalize_type, is_enum_type
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 import BaseWidget
15
+ from widget.unknown_type import UnknownTypeWidget
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class WidgetFactory:
21
+ def __init__(self) -> None:
22
+ self._widgets: dict[type, type[BaseWidget]] = {
23
+ str: StringWidget,
24
+ int: IntegerWidget,
25
+ int | None: IntegerWidget,
26
+ Enum: EnumWidget,
27
+ bool: BoolWidget,
28
+ float: FloatWidget,
29
+ list[str]: ListWidget,
30
+ List[str]: ListWidget,
31
+ list[int]: ListWidget,
32
+ List[int]: ListWidget,
33
+ }
34
+
35
+ def insert_new_widget(
36
+ self, field_type: type, widget_type: type[BaseWidget]
37
+ ) -> None:
38
+ self._widgets[field_type] = widget_type
39
+
40
+ def build(self, model_fields: dict[str, FieldInfo], view_annotation_type: bool) -> list[BaseWidget]:
41
+ widgets: list[BaseWidget] = []
42
+
43
+ for field_name, field_type in model_fields.items():
44
+ normalized_type = normalize_type(field_type.annotation)
45
+ try:
46
+ widget = self._widgets[field_type.annotation]
47
+ except KeyError:
48
+ widget = self._widgets.get(normalized_type.origin_type)
49
+
50
+ if is_enum_type(normalized_type.origin_type):
51
+ widget = self._widgets[Enum]
52
+
53
+ if widget is None:
54
+ logger.warning(
55
+ f'No widget for field "{field_name}". Type {field_type.annotation}. Creating default <UnknownTypeWidget>'
56
+ )
57
+ widget = UnknownTypeWidget
58
+
59
+ widgets.append(
60
+ widget(
61
+ field_info=field_type,
62
+ field_name=field_name,
63
+ is_nullable=normalized_type.is_nullable,
64
+ view_annotation_type=view_annotation_type
65
+ )
66
+ )
67
+
68
+ return widgets
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: niceforms
3
+ Version: 0.1.0
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,21 @@
1
+ niceforms/__init__.py,sha256=CrTbKZAwPT2u5CH-YMkzkIPFzSDvHsqtmZnoi50bx8w,2803
2
+ niceforms/actions.py,sha256=4TajbnXcfvb24yAaS85foOns3wyTyTQE1ea3pO4hets,149
3
+ niceforms/constants.py,sha256=Vzyj_lII5t9pNp6NDN3QL48a28LR5NpmBDb6E2AD4kU,410
4
+ niceforms/ui/__init__.py,sha256=3SixxiIXIqOr3ZPfTLzdPCG51pnZ0Tc9uvoolzT1RBE,289
5
+ niceforms/ui/body.py,sha256=5rmOIWj6fk-AINYAZe83SnaKnb3EeEHRRC340CSXCMU,626
6
+ niceforms/ui/footer.py,sha256=CX8UxnEwLPo20JjUcbC9m9CX1xZUPpEuwXa_48PxNnw,2547
7
+ niceforms/ui/header.py,sha256=pVt45FCttZbSC9fERyC7g3jR2DOTRrVUX35YFZGVYqs,1991
8
+ niceforms/ui/json_viewer.py,sha256=hUhaf34Ip0eN7BIH9uTej6KBScFI0OiVwh99f024sh0,797
9
+ niceforms/utils.py,sha256=KzZNnmXvFmrF_S0BifNA0e7s7pYSuLdtjGCymuHblKA,3342
10
+ niceforms/widget/__init__.py,sha256=H-rT7V1ZcjufUh2zUnlyEaq_-wXltYmWD5LI7iAYqGU,3406
11
+ niceforms/widget/bool.py,sha256=1ofP2sHfCLVx2ciYtZCZS1I9EkSrEkOZm0-GZqRVBos,594
12
+ niceforms/widget/enum.py,sha256=jN11XocYOL186FFOIU57clIMrzyZ5ZchyD1zHY7IwIQ,789
13
+ niceforms/widget/float.py,sha256=93ZHHnFk1YtR9vElTQpqH6S46vlaIS_fZfK3zdRAqpg,632
14
+ niceforms/widget/integer.py,sha256=Eh5KsYfg2_mHR8IgclQAhGrvk5DEQHcdkhHQhDKmvCg,627
15
+ niceforms/widget/list.py,sha256=LRLSuLjCM1_JnDKuEIV6wwYOkFzro4nV5C-JaNMgx5Q,1763
16
+ niceforms/widget/string.py,sha256=hEDVFfr4cynb4a9Jr5wyaJwr04L-gJDfMa1RRtvVRPM,586
17
+ niceforms/widget/unknown_type.py,sha256=Qbob229ylW1XQW_WQioh5TQdfMjykPhQsfjauJVgU9c,1044
18
+ niceforms/widget_factory.py,sha256=2Ux4QtwN7M2gV5TwW4GKL7DPqyJ3aHz_WnVnXfa13Io,2223
19
+ niceforms-0.1.0.dist-info/METADATA,sha256=7u6fmgykJBba54IBxEgnBVKSA3fTuSQw3-5auEWyx8c,808
20
+ niceforms-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
21
+ niceforms-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any