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.
tgzr/cuisine/plugin.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Type
3
+
4
+ from tgzr.package_management.plugin_manager import Plugin, PluginManager
5
+
6
+
7
+ if TYPE_CHECKING:
8
+ from .recipe import Recipe
9
+
10
+
11
+ class CuisinePlugin(Plugin):
12
+ @classmethod
13
+ def plugin_type_name(cls) -> str:
14
+ return "PipelinePlugin"
15
+
16
+ def get_recipe_types(self) -> list[Type[Recipe]]:
17
+ return []
18
+
19
+
20
+ class CuisinePluginManager(PluginManager[CuisinePlugin]):
21
+
22
+ EP_GROUP = "tgzr.cuisine.plugin"
23
+
24
+
25
+ # NB: we use a "global" plugin manager so it is automatically
26
+ # picked up by `tgzr plugins ls`:
27
+ cuisine_plugin_manager = CuisinePluginManager()
tgzr/cuisine/recipe.py ADDED
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+ from typing import Type, TypeVar
3
+
4
+ from pathlib import Path
5
+ import dataclasses
6
+ import datetime
7
+ import importlib
8
+
9
+ import toml
10
+
11
+ from tgzr.package_management.pyproject import (
12
+ PyProject,
13
+ save_pyproject,
14
+ BuildSystem,
15
+ Project,
16
+ Tools,
17
+ ToolHatch,
18
+ ToolHatchVersion,
19
+ )
20
+
21
+
22
+ @dataclasses.dataclass
23
+ class RecipeTypeInfo:
24
+ type_name: str | None = None # only needed if it's not the class name
25
+ category: str | None = None # just for UIs (None means hidden category)
26
+ color: str | None = None # html format
27
+ icon: str | None = None # one of the google-font or fontawesome icons
28
+
29
+
30
+ @dataclasses.dataclass
31
+ class RecipeMetadata:
32
+ name: str
33
+ type_name: str
34
+ inputs: dict[str | None, list[str]] = dataclasses.field(default_factory=dict)
35
+ # Note: we should use a set for tags, but a set get converted to a list when savec in toml so:
36
+ tags: list[str] = dataclasses.field(default_factory=list)
37
+
38
+ @classmethod
39
+ def create_default(cls, name: str, type_name: str):
40
+ return cls(
41
+ name=name,
42
+ type_name=type_name,
43
+ )
44
+
45
+ @classmethod
46
+ def from_toml(cls, toml_path: Path):
47
+ asset_data = toml.load(toml_path)
48
+ return cls(**asset_data)
49
+
50
+ def write_toml(self, toml_path: Path):
51
+ toml_path.write_text(toml.dumps(dataclasses.asdict(self)))
52
+ print(f"Recipe {self.name} Metadata saved to {toml_path}")
53
+
54
+
55
+ class RecipeControler:
56
+ """
57
+ This object contains methods that would totally fit in the Recipe interface,
58
+ but are kept separated to keep the Recipe namespace as empty as possible.
59
+
60
+ It makes subclassing a little bit harder, but only for subclasses that
61
+ are changing the relation between the Chef and the Recipe (which should
62
+ not be often outside of tgzr.cuisine, if we designed thing right :p)
63
+ """
64
+
65
+ @classmethod
66
+ def default_dinit_content(cls, RecipeType: Type[Recipe]) -> str:
67
+ cls_module = RecipeType.__module__
68
+ cls_name = RecipeType.__name__
69
+
70
+ return f"""
71
+ from {cls_module} import {cls_name}
72
+
73
+ recipe = {cls_name}(__file__)
74
+
75
+ def cook() -> None:
76
+ recipe.cook()
77
+
78
+ """
79
+
80
+ @classmethod
81
+ def init_recipe_files(cls, recipe: Recipe, dinit_file: Path) -> None:
82
+ """
83
+ This is called by the chef after it created a new
84
+ reciep.
85
+ Subclasses may override this to add more files inside the
86
+ recipe package.
87
+ """
88
+ pass
89
+
90
+ @classmethod
91
+ def make_pyproject(cls, recipe: Recipe) -> PyProject:
92
+ metadata = cls.read_metadata(recipe)
93
+ pyproject = PyProject()
94
+
95
+ name = recipe.name # this is the coninical package name
96
+ pyproject.project = project = Project()
97
+ project.name = name
98
+ project.dynamic = ["version"]
99
+ project.description = f"{metadata.name} - a Recipe created with tgzr.cuisine."
100
+ project.readme = "README.md"
101
+ project.dependencies = ["tgzr.cuisine"] + metadata.inputs.get(None, [])
102
+ # TODO: add extra input groups too!
103
+
104
+ # All Recipe have a cook() method
105
+ project.scripts[name] = f"{name}:cook"
106
+
107
+ project.classifiers = [
108
+ # We use classifiers as searchable hierarchy in private indexes. Must be stable.
109
+ # /!\ when building the package, we need HATCH_METADATA_CLASSIFIERS_NO_VERIFY env var to be set
110
+ # or these custom classifiers will be rejected.
111
+ "Private :: Do Not Upload", # prevents accidental upload to PyPI
112
+ "Framework :: tgzr.cuisine",
113
+ f"RecipeType :: {metadata.type_name}",
114
+ ]
115
+ project.keywords = [
116
+ # We use keywords for fine search in private indexes. Can be arbitrary.
117
+ f"tgzr.cuisine.recipe_name:{metadata.name}",
118
+ f"tgzr.cuisine.recipe_type_name:{metadata.type_name}",
119
+ *[
120
+ f"tgzr.cuisine.tags:{tag}" for tag in metadata.tags
121
+ ], # TODO: ensure tag are alpha+alphanum only ! (or valid identifiers)
122
+ ]
123
+ project.entry_points = {
124
+ # Gives the hability to discover all installed Recipes of a given (final) type in the venv:
125
+ f"tgzr.pipeline.{metadata.type_name}": {"recipe": f"{name}:recipe"},
126
+ #
127
+ # These ep is the source of truth when getting name and type from the Distribution
128
+ # (not the classifers or keywords)
129
+ # Also: it give the hability to discover all installed Recipe names and types:
130
+ "tgzr.pipeline.asset_info_trick": {
131
+ "recipe_name": metadata.name,
132
+ "recipe_type": metadata.type_name,
133
+ },
134
+ }
135
+
136
+ pyproject.build_system = BuildSystem(
137
+ requires=["hatchling"], build_backend="hatchling.build"
138
+ )
139
+
140
+ pyproject.tool = tool = Tools()
141
+ tool.hatch = ToolHatch()
142
+ # does not work in current version,
143
+ # might in next version,
144
+ # for now we still need to have the envvar HATCH_METADATA_CLASSIFIERS_NO_VERIFY set !
145
+ tool.hatch.metadata = {"allow-custom-classifiers": True}
146
+ tool.hatch.envs = {"default": {"installer": "uv"}}
147
+ tool.hatch.version = ToolHatchVersion(path=f"src/{name}/__version__.py")
148
+
149
+ panel_names = recipe.nice_panel_names()
150
+ if panel_names:
151
+ nice_panels_ep = project.entry_points["tgzr.pipeline.asset.nice_panel"] = {}
152
+ for panel_name in panel_names:
153
+ nice_panels_ep[panel_name] = f"{recipe.name}:recipe.{panel_name}"
154
+
155
+ return pyproject
156
+
157
+ @classmethod
158
+ def write_pyproject_to(cls, recipe: Recipe, pyproject_path: Path):
159
+ pyproject = cls.make_pyproject(recipe)
160
+ print("Saving pyproject to:", pyproject_path)
161
+ save_pyproject(pyproject, pyproject_path)
162
+
163
+ @classmethod
164
+ def is_editable(cls, recipe: Recipe) -> bool:
165
+ for parent in recipe._init_file.parents:
166
+ if (parent / "pyproject.toml").exists():
167
+ # FIXME: this could be a uv workspace pyproject !!!
168
+ return True
169
+ return False
170
+
171
+ @classmethod
172
+ def read_metadata(cls, recipe: Recipe) -> RecipeMetadata:
173
+ data = RecipeMetadata.from_toml(recipe._recipe_toml)
174
+ return data
175
+
176
+ @classmethod
177
+ def write_metadata(cls, recipe: Recipe, metadata: RecipeMetadata):
178
+ metadata.write_toml(recipe._recipe_toml)
179
+
180
+ @classmethod
181
+ def create_editable(
182
+ cls, recipe: Recipe, workbench_path: Path | str, force: bool = False
183
+ ):
184
+ if recipe.is_editable:
185
+ raise ValueError(
186
+ f"Are you sure you want to create an editable version of {recipe.name}? \n"
187
+ f"(it is already editable as {recipe._init_file})."
188
+ )
189
+
190
+ from .workbench import Workbench
191
+
192
+ workbench_path = Path(workbench_path)
193
+ wb = Workbench(workbench_path)
194
+ if wb.has_editable_recipe(recipe.name):
195
+ if not force:
196
+ raise ValueError(
197
+ f"The Recipe {recipe.name} is already an editable in workbench {workbench_path}."
198
+ )
199
+ print(
200
+ f"Overridding editable Recipe {recipe.name} in workbench {workbench_path}"
201
+ )
202
+ else:
203
+ print(
204
+ f"Creating editable Recipe {recipe.name} in workbench {workbench_path}"
205
+ )
206
+
207
+ version = recipe.get_version()
208
+ metadata = cls.read_metadata(recipe)
209
+
210
+ wb.chef.create_recipe(
211
+ folder=wb._editable_path,
212
+ recipe_name=recipe.name,
213
+ recipe_metadata=metadata,
214
+ version=version,
215
+ recipe_type_name=metadata.type_name,
216
+ )
217
+
218
+
219
+ RecipeType = TypeVar("RecipeType", bound="Recipe")
220
+
221
+
222
+ class Recipe:
223
+ RECIPE_TYPE_INFO = RecipeTypeInfo(
224
+ type_name=None,
225
+ category="AbstractRecipes",
226
+ color=None,
227
+ icon=None,
228
+ )
229
+ _CONTROLER = RecipeControler
230
+
231
+ def __init__(self, init_file: Path | str):
232
+ self._init_file = Path(init_file)
233
+ self._name = self._init_file.parent.name
234
+ self._recipe_toml = (self._init_file / ".." / "recipe.toml").resolve()
235
+ self._svg_cooking_log_path = self._init_file.parent / "cooking_log.svg"
236
+ self._txt_cooking_log_path = self._init_file.parent / "cooking_log.txt"
237
+
238
+ @property
239
+ def name(self) -> str:
240
+ return self._name
241
+
242
+ @property
243
+ def is_editable(self) -> bool:
244
+ return self._CONTROLER.is_editable(self)
245
+
246
+ @property
247
+ def svg_cooking_log_path(self):
248
+ return self._svg_cooking_log_path
249
+
250
+ @property
251
+ def txt_cooking_log_path(self):
252
+ return self._txt_cooking_log_path
253
+
254
+ def cook(self):
255
+ from rich.console import Console
256
+
257
+ console = Console(record=True)
258
+ try:
259
+ self._cook(print=console.print)
260
+ finally:
261
+ console.save_text(str(self._txt_cooking_log_path), clear=False)
262
+ title = f"{self.name} Cooking Log {datetime.datetime.now().isoformat(sep=' ', timespec='minutes')}"
263
+ console.save_svg(str(self._svg_cooking_log_path), title=title)
264
+
265
+ def _cook(self, print):
266
+ print(
267
+ f"Hello from Recipe {self.name} (version={self.get_version()}, {self.is_editable=})"
268
+ )
269
+ print(self._CONTROLER.read_metadata(self))
270
+
271
+ def get_version(self) -> str:
272
+ version_module = importlib.import_module(self.name + ".__version__")
273
+ return version_module.__version__
274
+
275
+ def nice_panel_names(self) -> list[str]:
276
+ """
277
+ Asset subclasses implementing nice panel guis
278
+ must override this method to return the name of the method
279
+ implementing each panel.
280
+
281
+ NB: You panel method will **NOT** be executed in the asset
282
+ virtual env. That means you CAN NOT do things like cook them
283
+ or even import their input recipes with `self.get_inputs()`.
284
+ But you can access the recipe data with `self._CONTROLER.read_metadata(...)`
285
+ and save them with `self._CONTROLER.write_metadata(...)`.
286
+ If you **really** want to affect the recipe, you need to do so
287
+ using the Workspace provided with the `workspace` argument.
288
+
289
+ NB: GUIs will show your panels by alphabetic order, but you
290
+ can preprend some panel names with some "_" to bring them first.
291
+
292
+ Example:
293
+
294
+ from tgzr.cuisine.workspace import Workspace
295
+
296
+ class MyRecipe(Recipe):
297
+ def nice_panel_name(self)->list[str]:
298
+ return ['preview_panel', 'options_panel']
299
+
300
+ def preview_panel(self, workspace:Workspace)->None:
301
+ ui.label('This is the preview panel.')
302
+
303
+ def options_panel(self, workspace:Workspace)->None:
304
+ ui.label('This is the option panel.')
305
+
306
+ """
307
+ return []
308
+
309
+ def get_inputs(self, group: str | None = None) -> list[Recipe]:
310
+ """
311
+ Returns a dict like {recipe_name:Recipe} with all inputs in
312
+ the group `group`.
313
+ """
314
+ assets = []
315
+ recipe_metadata = self._CONTROLER.read_metadata(self)
316
+ for package_name in recipe_metadata.inputs.get(group, []):
317
+ module = importlib.import_module(package_name)
318
+ try:
319
+ recipe: Recipe = module.recipe
320
+ except AttributeError:
321
+ print(f"!!Warning!! upstream {package_name} is not an Recipe ?!?")
322
+ else:
323
+ assets.append(module.asset)
324
+ return assets
325
+
326
+ def get_unique_input(self, group: str, required: bool = True):
327
+ """
328
+ Return the one and only input in the group `group`.
329
+ If there's not exactly one input in this group, raises a
330
+ Exception.
331
+ """
332
+ inputs = self.get_inputs(group=group)
333
+ if required and not inputs:
334
+ raise Exception(
335
+ f"You need to connect something in {group!r} dependency of asset {self.name!r}!"
336
+ )
337
+
338
+ if len(inputs) != 1:
339
+ raise Exception(
340
+ f"You need one and exactly one {group!r} dependency. (got: {inputs})"
341
+ )
342
+ return inputs[0]
343
+
344
+ def get_typed_inputs(
345
+ self, RecipeType: Type[RecipeType], group: str, raises: bool = True
346
+ ) -> dict[str, RecipeType]:
347
+ """
348
+ Returns a {name:recipe} dict with all recipes in input group `group` of
349
+ type `RecipeType`.
350
+ """
351
+ inputs = self.get_inputs(group="params")
352
+ matching: dict[str, RecipeType] = {}
353
+ for input in inputs:
354
+ if not isinstance(input, RecipeType):
355
+ if raises:
356
+ raise TypeError(
357
+ f"The input {input.name} in group {group!r} of {self.name} is not a {RecipeType} but a {type(input)}!"
358
+ )
359
+ continue
360
+ matching[input.name] = input
361
+ return matching