tgzr.cuisine 0.0.1__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.
@@ -0,0 +1,540 @@
1
+ # TDOD: most of this should be extracted to its own project like tgzr.nice_pydantic
2
+
3
+ from __future__ import annotations
4
+ from typing import TYPE_CHECKING, Union, Type, Literal, Callable, Any
5
+ from typing import get_args, get_origin
6
+
7
+ import sys
8
+ import inspect
9
+ import contextlib
10
+
11
+ import pydantic
12
+ from nicegui import ui
13
+
14
+ if TYPE_CHECKING:
15
+ from tgzr.cuisine.basics.recipe_with_params import RecipeParams
16
+
17
+
18
+ # This is to be compatible with python 3.9
19
+ # because it does not have types.UnionType
20
+ # so we can't just do "xx is UnionType"
21
+ # TODO: trash this when ToonBoom got working and we stop need to support py3.9 anymore.
22
+ def is_union_type(tp):
23
+ # 1. Check for the modern | syntax (Python 3.10+)
24
+ if sys.version_info >= (3, 10):
25
+ from types import UnionType
26
+
27
+ if tp is UnionType:
28
+ # TODO: I'm pretty sure this is wrong, but our usage doesn't work w/o it ¯\_(ツ)_/¯
29
+ return True
30
+
31
+ if isinstance(tp, UnionType):
32
+ return True
33
+
34
+ # 2. Check for the traditional Union[int, str] syntax
35
+ # In 3.9, we check the __origin__ of the type
36
+ return getattr(tp, "__origin__", None) is Union
37
+
38
+
39
+ class FieldRenderer:
40
+ @classmethod
41
+ def handles(cls, value_type) -> bool:
42
+ raise NotImplementedError(f"on {cls}")
43
+
44
+ def __init__(
45
+ self,
46
+ panel: ParamsPanel,
47
+ name: str,
48
+ getter: Callable[[], Any],
49
+ setter: Callable[[Any], None],
50
+ default_factory: Callable[[], Any] | None,
51
+ ):
52
+ self._panel = panel
53
+ self._name = name
54
+ self._getter = getter
55
+ self._setter = setter
56
+ self._default_factory = default_factory
57
+ self._options: dict[str, Any] = {}
58
+
59
+ def set_optional(self, b: bool = False):
60
+ self._options["optional"] = b
61
+
62
+ def set_options(self, *args, **kwargs) -> None:
63
+ if args:
64
+ raise ValueError(
65
+ f"FiedlRenderer default set_option() implementation does not accept *args, but got {args} on field {self._name} ({self}) !"
66
+ )
67
+ self._options.update(kwargs)
68
+
69
+ def render(self):
70
+ # print("RENDER LABEL ON", self)
71
+ self.render_label()
72
+ # print("RENDER FIELD", self)
73
+ self.render_field()
74
+
75
+ def render_label(self):
76
+ with self._panel.field_label_parent():
77
+ if self._panel.editable:
78
+ self._render_label_editable()
79
+ else:
80
+ self._render_label_readonly()
81
+
82
+ def _render_label_editable(self):
83
+ ui.label(self._name.replace("_", " ").title())
84
+
85
+ def _render_label_readonly(self):
86
+ ui.label(self._name.replace("_", " ").title())
87
+
88
+ def render_field(self):
89
+ with self._panel.field_value_parent() as p:
90
+ p.classes("col-span-2")
91
+ if self._panel.editable:
92
+ e = self._render_editable_value()
93
+ if e is not None:
94
+ e.on("mousedown", js_handler="(e)=>{e.stopPropagation()}")
95
+ else:
96
+ self._render_readonly_value()
97
+
98
+ def _render_editable_value(self) -> ui.element | None:
99
+ raise NotImplementedError(f"on {self}")
100
+
101
+ def _render_readonly_value(self):
102
+ raise NotImplementedError(f"on {self}")
103
+
104
+
105
+ class StrField(FieldRenderer):
106
+ @classmethod
107
+ def handles(cls, value_type) -> bool:
108
+ return value_type is str
109
+
110
+ def _render_editable_value(self):
111
+ def on_key(event):
112
+ if event.args["key"] == "Enter":
113
+ self._setter(event.sender.value)
114
+ event.sender.run_method("blur")
115
+
116
+ e = (
117
+ ui.input(value=str(self._getter()))
118
+ .props("dense rounded standout")
119
+ .classes("w-full")
120
+ .on("keydown", on_key)
121
+ )
122
+ return e
123
+
124
+ def _render_readonly_value(self):
125
+ ui.label(self._getter()).classes("w-full")
126
+
127
+
128
+ class BoolField(FieldRenderer):
129
+ @classmethod
130
+ def handles(cls, value_type) -> bool:
131
+ return value_type is bool
132
+
133
+ def _render_editable_value(self):
134
+ e = ui.checkbox(
135
+ value=self._getter(), on_change=lambda e: self._setter(e.value)
136
+ ).classes("w-full")
137
+ return e
138
+
139
+ def _render_readonly_value(self):
140
+ ui.icon(
141
+ self._getter() and "sym_o_check_box" or "sym_o_check_box_outline_blank",
142
+ size="sm",
143
+ )
144
+
145
+
146
+ class ChoiceField(FieldRenderer):
147
+ @classmethod
148
+ def handles(cls, value_type) -> bool:
149
+ return value_type is Literal
150
+
151
+ def set_options(
152
+ self,
153
+ *choices: Any,
154
+ ) -> None:
155
+ super().set_options(choices=choices)
156
+
157
+ def _render_editable_value(self):
158
+ choices = self._options["choices"]
159
+ value = self._getter()
160
+
161
+ selectable = dict([(str(i), i) for i in choices])
162
+ if str(value) not in selectable:
163
+ selectable[str(value)] = value
164
+ e = (
165
+ ui.select(
166
+ selectable,
167
+ value=value,
168
+ new_value_mode="add",
169
+ key_generator=str,
170
+ on_change=lambda e: self._setter(e.value),
171
+ )
172
+ .props("dense rounded standout")
173
+ .classes("w-full")
174
+ )
175
+
176
+ return e
177
+
178
+ def _render_readonly_value(self):
179
+ ui.label(str(self._getter()))
180
+
181
+
182
+ class ListField(FieldRenderer):
183
+ @classmethod
184
+ def handles(cls, value_type) -> bool:
185
+ return value_type is list
186
+
187
+ def set_options(
188
+ self,
189
+ *args,
190
+ allow_reorder: bool = True,
191
+ open: bool = False,
192
+ default_open: bool | None = None,
193
+ ) -> None:
194
+ self._item_type = args[0]
195
+ return super().set_options(
196
+ open=open, default_open=default_open, allow_reorder=allow_reorder
197
+ )
198
+
199
+ def on_add_item(self):
200
+ if self._default_factory is None:
201
+ raise ValueError("Cannot add item in list fiels without a default factory!")
202
+ list_values = self._getter()
203
+ list_values.append(self._item_type())
204
+ # list_values.append(self._default_factory())
205
+ self.render_content.refresh()
206
+
207
+ def move_item_up(self, index: int):
208
+ list_values = self._getter()
209
+ value = list_values.pop(index)
210
+ list_values.insert(index - 1, value)
211
+ self.render_content.refresh()
212
+
213
+ def move_item_down(self, index: int):
214
+ list_values = self._getter()
215
+ value = list_values.pop(index)
216
+ list_values.insert(index + 1, value)
217
+ self.render_content.refresh()
218
+
219
+ def delete_item(self, index: int):
220
+ list_values = self._getter()
221
+ list_values.pop(index)
222
+ self.render_content.refresh()
223
+
224
+ def toggle_open(self):
225
+ if self._content.visible:
226
+ self._open_close_btn.icon = "arrow_right"
227
+ self._content.visible = False
228
+ self._add_item_btn.visible = False
229
+ self._options["open"] = False
230
+ else:
231
+ self._open_close_btn.icon = "arrow_drop_down"
232
+ self._content.visible = True
233
+ if self._panel.editable:
234
+ self._add_item_btn.visible = True
235
+ self._options["open"] = True
236
+
237
+ def render_label(self):
238
+ with ui.row(align_items="center").classes(
239
+ f"gap-0 xmin-h-[{self._panel.min_h}]"
240
+ ) as p:
241
+ p.classes("col-span-3")
242
+ self._render_header()
243
+
244
+ def _render_header(self):
245
+ self._open_close_btn = ui.button(icon="arrow_drop_down").props("flat dense")
246
+ ui.label(self._name.replace("_", " ").title())
247
+
248
+ with ui.row(align_items="center").classes("gap-0 col-span-2"):
249
+ self._add_item_btn = (
250
+ ui.button(icon="sym_o_list_alt_add")
251
+ .tooltip("Add Item")
252
+ .props("flat dense")
253
+ )
254
+ self._add_item_btn.set_visibility(self._panel.editable)
255
+
256
+ self._open_close_btn.on_click(self.toggle_open)
257
+ self._add_item_btn.on_click(self.on_add_item)
258
+
259
+ ui.space()
260
+
261
+ @ui.refreshable_method
262
+ def render_content(self):
263
+ list_values = self._getter()
264
+ with ui.column().classes(
265
+ f"w-full gap-1 col-span-2 pl-[{self._panel.min_h}]"
266
+ ) as self._content:
267
+ for i, value in enumerate(list_values):
268
+ with ui.row(wrap=False).classes(
269
+ "w-full border border-neutral-500/50 gap-0"
270
+ ):
271
+ with ui.column().classes("gap-0"):
272
+ b = (
273
+ ui.button(
274
+ icon="sym_o_arrow_upward_alt",
275
+ on_click=lambda e, i=i: self.move_item_up(i),
276
+ )
277
+ .props("flat dense")
278
+ .tooltip("Move Up")
279
+ )
280
+ b.set_visibility(self._panel.editable)
281
+ b = (
282
+ ui.button(
283
+ icon="sym_o_arrow_downward_alt",
284
+ on_click=lambda e, i=i: self.move_item_down(i),
285
+ )
286
+ .props("flat dense")
287
+ .tooltip("Move Down")
288
+ )
289
+ b.set_visibility(self._panel.editable)
290
+ name = "" # f"#{i}"
291
+ getter = lambda: value
292
+ self._panel.render_field(
293
+ name, getter, setter=None, value_type=self._item_type
294
+ )
295
+ with ui.column():
296
+ b = (
297
+ ui.button(
298
+ icon="sym_o_delete_forever",
299
+ on_click=lambda e, i=i: self.delete_item(i),
300
+ )
301
+ .props("dense flat")
302
+ .tooltip("Delete this Item")
303
+ )
304
+ b.set_visibility(self._panel.editable)
305
+
306
+ def _render_editable_value(self):
307
+ default_open = self._options.get("default_open")
308
+ if default_open is None:
309
+ self._options["open"] = True
310
+ self.render_content()
311
+
312
+ def _render_readonly_value(self):
313
+ default_open = self._options.get("default_open")
314
+ if default_open is None:
315
+ self._options["open"] = False
316
+ self.render_content()
317
+
318
+
319
+ class ModelField(FieldRenderer):
320
+ @classmethod
321
+ def handles(cls, value_type) -> bool:
322
+ return inspect.isclass(value_type) and issubclass(
323
+ value_type, pydantic.BaseModel
324
+ )
325
+
326
+ def _render_editable_value(self):
327
+ self._render_value()
328
+
329
+ def _render_readonly_value(self):
330
+ self._render_value()
331
+
332
+ def _render_value(self):
333
+ model: pydantic.BaseModel = self._getter()
334
+ model_type = type(model)
335
+ with ui.grid(columns="auto 1fr auto").classes(
336
+ "w-full gap-0 gap-x-1 col-span-2"
337
+ ):
338
+ for name, field in model_type.model_fields.items():
339
+ getter = lambda m=model, n=name: getattr(m, n)
340
+ setter = lambda v, m=model, n=name: setattr(m, n, v)
341
+ self._panel.render_field(
342
+ name, getter, setter, value_type=field.annotation
343
+ )
344
+ # ui.label(str(model_type))
345
+
346
+
347
+ class DefaultField(FieldRenderer):
348
+ @classmethod
349
+ def handles(cls, value_type) -> bool:
350
+ return True
351
+
352
+ def set_options(self, *args, **kwargs) -> None:
353
+ kwargs["*args"] = args
354
+ self._options.update(kwargs)
355
+
356
+ def _render_editable_value(self):
357
+ self._render_value()
358
+
359
+ def _render_readonly_value(self):
360
+ self._render_value()
361
+
362
+ def _render_value(self):
363
+ value = self._getter()
364
+ ui.label(repr(value)).classes("w-full")
365
+ # ui.label(f"(!! {type(value)})")
366
+
367
+
368
+ class FieldRendererFactory:
369
+ def __init__(self):
370
+ self._field_renderers: list[Type[FieldRenderer]] = []
371
+ self._default_field_renderer = DefaultField
372
+
373
+ def add_renderer(self, FieldRendererType: Type[FieldRenderer]):
374
+ self._field_renderers.insert(0, FieldRendererType)
375
+
376
+ def register_renderers(self, *FieldRendererType: Type[FieldRenderer]):
377
+ self._field_renderers.extend(FieldRendererType)
378
+
379
+ def get_field_renderer(
380
+ self, panel, value_type, name, getter, setter
381
+ ) -> FieldRenderer:
382
+
383
+ # Amazing trick from this comment:
384
+ # https://stackoverflow.com/questions/56832881/check-if-a-field-is-typing-optional/62641842#62641842
385
+ # optional = Union[value_type, None] == Union[value_type]
386
+ # (not using it tho bc I still need to find out the non-optional type, but you gotta read this! <3)
387
+
388
+ # Remove the optional None type and assert there's only one other valid type, then use it:
389
+ # if get_origin(value_type) is Union or get_origin(value_type) is UnionType:
390
+ if get_origin(value_type) is Union or is_union_type(get_origin(value_type)):
391
+ accepted_types = list(get_args(value_type))
392
+ if type(None) in accepted_types:
393
+ optional = True
394
+ accepted_types.remove(type(None))
395
+ if len(accepted_types) > 1:
396
+ raise TypeError(
397
+ f"Multiple type for field {name} ({value_type}) is not supported!"
398
+ )
399
+ value_type = accepted_types[0]
400
+
401
+ field_type = value_type
402
+ sub_types = ()
403
+
404
+ origin = get_origin(value_type)
405
+ if origin is not None:
406
+ field_type = origin
407
+ sub_types = get_args(value_type)
408
+
409
+ optional = False
410
+ args = get_args(field_type)
411
+ if args:
412
+ if type(None) in args:
413
+ optional = True
414
+ args = [i for i in args if i is not type(None)]
415
+
416
+ # print(" --->>", args)
417
+ if args:
418
+ if len(args) > 1:
419
+ raise ValueError("Unsuported dual-type field :/")
420
+ field_type = args[0] # type: ignore - if args + if len(args) >1 => we know args[0] exists!
421
+
422
+ # print(
423
+ # ">>>>>>>>",
424
+ # field_type,
425
+ # is_union_type(field_type),
426
+ # )
427
+ # if is_union_type(field_type):
428
+ # print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
429
+ # print(" >>", value_type)
430
+ # print(" >>", origin, get_args(origin))
431
+ # print(" >>", field_type)
432
+ # print(" >>", sub_types)
433
+ # print(" >>", optional)
434
+
435
+ for FieldRendererType in self._field_renderers:
436
+ if FieldRendererType.handles(field_type):
437
+ field_renderer = FieldRendererType(
438
+ panel, name, getter, setter, lambda: field_type()
439
+ )
440
+ field_renderer.set_optional(optional)
441
+ field_renderer.set_options(*sub_types)
442
+ # print(" Created field", field_renderer)
443
+ return field_renderer
444
+ print(f"!!!! No Field Renderer found for {value_type} ({name}), using default")
445
+ return self._default_field_renderer(panel, name, getter, setter, None)
446
+
447
+
448
+ class ParamsPanel:
449
+ def __init__(
450
+ self, params: RecipeParams, allow_edit: bool = True, editable: bool = False
451
+ ):
452
+ self.params = params
453
+ self.min_h = "4em"
454
+ self.allow_edit = allow_edit
455
+ self.editable = editable
456
+ self._on_save = None
457
+
458
+ self._field_renderer_factory = FieldRendererFactory()
459
+ self._field_renderer_factory.register_renderers(
460
+ StrField, BoolField, ChoiceField, ListField, ModelField, DefaultField
461
+ )
462
+
463
+ def add_field_renderer(self, FieldRendererType: Type[FieldRenderer]):
464
+ self._field_renderer_factory.add_renderer(FieldRendererType)
465
+
466
+ def set_on_save(self, cb: Callable[[RecipeParams], None]):
467
+ self._on_save = cb
468
+
469
+ def save(self):
470
+ print("Saving:", self.params)
471
+ if self._on_save is not None:
472
+ self._on_save(self.params)
473
+ self.stop_edit()
474
+
475
+ def stop_edit(self):
476
+ self.editable = False
477
+ print("Edit mode", self.editable)
478
+ self.edit_save_btn.icon = "sym_o_edit"
479
+ self.render_all.refresh()
480
+
481
+ def start_edit(self):
482
+ if not self.allow_edit:
483
+ return
484
+ self.editable = True
485
+ print("Edit mode", self.editable)
486
+ self.edit_save_btn.icon = "sym_o_save"
487
+ self.render_all.refresh()
488
+
489
+ def _on_edit_save_btn(self):
490
+ if self.editable:
491
+ self.save()
492
+ else:
493
+ self.start_edit()
494
+
495
+ def render(self):
496
+ with ui.column().classes("gap-0"):
497
+ with (
498
+ ui.fab(icon="sym_o_menu", direction="left")
499
+ .props("padding=sm")
500
+ .classes("absolute right-3")
501
+ ):
502
+ if self.editable:
503
+ icon = "sym_o_save"
504
+ else:
505
+ icon = "sym_o_edit"
506
+ self.edit_save_btn = ui.fab_action(
507
+ icon, on_click=self._on_edit_save_btn
508
+ ).tooltip("Edit")
509
+ ui.fab_action("sym_o_content_copy").tooltip("Copy")
510
+ self.render_all()
511
+
512
+ @ui.refreshable_method
513
+ def render_all(self):
514
+ with ui.grid(columns="auto 1fr auto").classes("w-full gap-0 gap-x-1"):
515
+ self.render_field("", lambda: self.params, None, type(self.params))
516
+
517
+ @contextlib.contextmanager
518
+ def field_label_parent(self):
519
+ with ui.column(align_items="end"):
520
+ with ui.row(align_items="center").classes(
521
+ f"h-full xmin-h-[{self.min_h}]"
522
+ ) as p:
523
+ yield p
524
+
525
+ @contextlib.contextmanager
526
+ def field_value_parent(self):
527
+ with ui.row(align_items="center").classes(
528
+ f"w-full py-1 xmin-h-[{self.min_h}]"
529
+ ) as p:
530
+ yield p
531
+
532
+ def render_field_label(self, name: str):
533
+ with self.field_label_parent():
534
+ ui.label(name.replace("_", " ").title())
535
+
536
+ def render_field(self, name, getter, setter, value_type):
537
+ field_renderer = self._field_renderer_factory.get_field_renderer(
538
+ self, value_type, name, getter, setter
539
+ )
540
+ field_renderer.render()
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from tgzr.cuisine.basics.buildable import BuildContext, RecipeTypeInfo
4
+ from tgzr.cuisine.basics.viewable import Viewable
5
+
6
+
7
+ class Product(Viewable):
8
+ """
9
+ A Viewable Recipe which include itself in the current scene when cooked in
10
+ a build context.
11
+ All dependency Products are cooked too.
12
+ """
13
+
14
+ RECIPE_TYPE_INFO = RecipeTypeInfo(
15
+ category="Product",
16
+ color="#0000FF",
17
+ icon="sym_o_stockpot",
18
+ )
19
+
20
+ def cook(self):
21
+ ctx = BuildContext.current(raises=False)
22
+ if ctx is None:
23
+ raise Exception("Cannot cook a Product outside of a build context !")
24
+
25
+ inputs = self.get_inputs()
26
+ if inputs:
27
+ with ctx.log_group(f"Cooking inputs of {self.name!r}"):
28
+ for input in inputs:
29
+ input.cook()
30
+
31
+ print("!!! WARNING !!! Product.cook IS STILL A MOCKUP !!!")
32
+ ctx.log(f"Adding Product {self.name} to {ctx.to_build.file_path()}")
@@ -0,0 +1,10 @@
1
+ from tgzr.context import Context, LoggableContext
2
+ from tgzr.cuisine.recipe import Recipe
3
+
4
+
5
+ @Context.context_type
6
+ class CookContext(LoggableContext):
7
+ recipe: Recipe
8
+
9
+ def _log_section_summary(self) -> str:
10
+ return f"{self.__class__.__name__} recipe={self.recipe.name!r} ({self.recipe.__class__.__name__})"
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any
3
+ from pathlib import Path
4
+
5
+ try:
6
+ from types import get_original_bases
7
+ except ImportError:
8
+ # Py 3.9 need that:
9
+ def get_original_bases(cls):
10
+ return getattr(cls, "__orig_bases__", cls.__bases__)
11
+
12
+
13
+ import pydantic
14
+ import json
15
+
16
+ from ..recipe import Recipe, RecipeControler
17
+
18
+ if TYPE_CHECKING:
19
+ from tgzr.cuisine.workbench import Workbench
20
+
21
+
22
+ class RecipeParams(pydantic.BaseModel):
23
+ """
24
+ A pydantic model defining the params for an RecipeWithParams.
25
+ Subclass it and use it as generic base for you RecipeWithParams subclass.
26
+
27
+ !!! All field type **MUST** be json dumpable !!!
28
+
29
+ !!! If subclass has field of type list or sub-models, these submodels
30
+ need to have default values for all fields or the the Params panel
31
+ will not be able to create them.
32
+ """
33
+
34
+ pass
35
+
36
+
37
+ RecipeParamsType = TypeVar("RecipeParamsType", bound=RecipeParams)
38
+
39
+
40
+ class RecipeWithParamControler(RecipeControler):
41
+ @classmethod
42
+ def init_recipe_files(cls, recipe: RecipeWithParams, dinit_file: Path) -> None:
43
+ params_file = dinit_file.parent / "params.json"
44
+ defaults = recipe.get_default_params()
45
+ cls.write_recipe_params(defaults, params_file)
46
+
47
+ @classmethod
48
+ def params_type(
49
+ cls, RecipeType: Type[RecipeWithParams[RecipeParamsType]]
50
+ ) -> Type[RecipeParamsType]:
51
+ # Dont asks. That python generic class dark magic...
52
+ # (But if you know a better way w/o __args__ please tell me! ^_^)
53
+ return get_original_bases(RecipeType)[-1].__args__[0]
54
+
55
+ @classmethod
56
+ def read_recipe_params(
57
+ cls, recipe: RecipeWithParams[RecipeParamsType], params_file: Path
58
+ ) -> RecipeParamsType:
59
+ with open(params_file, "r") as f:
60
+ data = json.load(f)
61
+ return cls.params_type(type(recipe))(**data)
62
+
63
+ @classmethod
64
+ def write_recipe_params(cls, params: RecipeParams, params_file: Path) -> None:
65
+ json_str = (
66
+ params.model_dump_json()
67
+ ) # exclude_unset=True, exclude_defaults=True)
68
+ print("Saving RecipeParam to json:", json_str)
69
+ with open(params_file, "w") as fp:
70
+ fp.write(json_str)
71
+
72
+
73
+ class RecipeWithParams(Recipe, Generic[RecipeParamsType]):
74
+ _CONTROLER = RecipeWithParamControler
75
+
76
+ def get_default_params(self) -> RecipeParamsType:
77
+ return self._CONTROLER.params_type(type(self))()
78
+
79
+ @property
80
+ def params_file(self) -> Path:
81
+ return self._init_file.parent / "params.json"
82
+
83
+ def save_params(self, params: RecipeParamsType):
84
+ self._CONTROLER.write_recipe_params(params, self.params_file)
85
+
86
+ @property
87
+ def params(self) -> RecipeParamsType:
88
+ # TODO: maybe cache the param ?
89
+ return self._CONTROLER.read_recipe_params(self, self.params_file)
90
+
91
+ def get_params_dict(self) -> dict[str, Any]:
92
+ return self.params.model_dump()
93
+
94
+ def nice_panel_names(self) -> list[str]:
95
+ return ["params_panel"]
96
+
97
+ def _get_params_panel(self) -> Any:
98
+ """
99
+ Subclasses can override this to fine tune the created ParamsPanel.
100
+ The returned value must be an instance of
101
+ .panels.params_panel.ParamsPanel
102
+
103
+ (We cant annotate it here bc it must be a lazy import...)
104
+ """
105
+
106
+ from .panels.params_panel import ParamsPanel
107
+
108
+ panel = ParamsPanel(self.params)
109
+ return panel
110
+
111
+ def params_panel(self, workbench: Workbench) -> None:
112
+
113
+ def save_params(params):
114
+ self.save_params(params)
115
+ workbench.bump_recipe(self.name, bump="micro")
116
+
117
+ panel = self._get_params_panel()
118
+ panel.set_on_save(save_params)
119
+ panel.render()