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,33 @@
1
+ from __future__ import annotations
2
+
3
+ from tgzr.cuisine.basics.buildable import Buildable, RecipeTypeInfo
4
+ from tgzr.cuisine.basics.recipe_contexts import CookContext
5
+
6
+
7
+ @CookContext.context_type
8
+ class ViewContext(CookContext):
9
+ to_view: Viewable
10
+
11
+
12
+ class Viewable(Buildable):
13
+ """
14
+ A Buildable Recipe which cooks its 'viewer' input in its context after building
15
+ if if needed.
16
+ """
17
+
18
+ RECIPE_TYPE_INFO = RecipeTypeInfo(
19
+ category="View",
20
+ color="#0000FF",
21
+ icon="sym_o_play_pause",
22
+ )
23
+
24
+ def view(self):
25
+ current_context = CookContext.current()
26
+ current_context.log(f"Opening viewer for {self.file_path()}")
27
+ viewer = self.get_unique_input("view")
28
+ with current_context.get(ViewContext, asset_to_view=self):
29
+ viewer.cook()
30
+
31
+ def cook(self):
32
+ super().cook()
33
+ self.view()
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from tgzr.cuisine.recipe import Recipe, RecipeTypeInfo
6
+ from tgzr.cuisine.basics.recipe_contexts import CookContext
7
+ from tgzr.cuisine.basics.viewable import ViewContext
8
+
9
+
10
+ class Viewer(Recipe):
11
+ """
12
+ An Recipe which will open the 'file' from the current Viewable asset with
13
+ a defined viewer when cooked.
14
+ """
15
+
16
+ RECIPE_TYPE_INFO = RecipeTypeInfo(
17
+ category="View",
18
+ color="#0000FF",
19
+ icon="sym_o_play_circle",
20
+ )
21
+
22
+ def launch_viewer(self, file_path: Path):
23
+ ViewContext.current().log(f"Launching viewer for {file_path}")
24
+
25
+ def cook(self):
26
+ ctx = ViewContext.current(raises=False)
27
+ if ctx is None:
28
+ raise Exception("Cannot view an asset outside of a ViewContext.")
29
+ ctx.log(f"Viewer cooking: {self.name}")
30
+ self.launch_viewer(ctx.to_view.file_path())
@@ -0,0 +1,5 @@
1
+ from tgzr.cuisine.basics.editable import Editable
2
+
3
+
4
+ class WorkScene(Editable):
5
+ pass
tgzr/cuisine/chef.py ADDED
@@ -0,0 +1,339 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Type, Callable, Any
3
+
4
+ import os
5
+ from pathlib import Path
6
+ from importlib_metadata import Distribution
7
+ import dataclasses
8
+ import datetime
9
+
10
+ import rich
11
+ import packaging.utils
12
+ import packaging.requirements
13
+
14
+ from tgzr.package_management.venv import Venv
15
+ from .recipe import Recipe, RecipeMetadata, RecipeTypeInfo
16
+
17
+ if TYPE_CHECKING:
18
+ from .recipe import RecipeControler
19
+
20
+
21
+ @dataclasses.dataclass
22
+ class RecipeInfo:
23
+ """
24
+ Recipe metadata extracted from
25
+ a `importlib.metadata.Distribution()`.
26
+
27
+ These are created by Workbench.get_recipe_info
28
+ """
29
+
30
+ dist: Distribution
31
+ is_asset: bool
32
+ asset_name: str
33
+ asset_type: str | None
34
+ tags: set[str]
35
+ is_editable: bool
36
+ editable_path: Path | None
37
+ nice_panel_names: list[str]
38
+
39
+
40
+ class Chef:
41
+ """
42
+ Chef is doing things with recipes
43
+ """
44
+
45
+ def __init__(self, venv_path: Path):
46
+ self._venv_path = venv_path
47
+ self._venv = Venv(venv_path)
48
+ self._recipe_types: list[Type[Recipe]] = []
49
+
50
+ def register_recipe_types(self, *recipe_types):
51
+ # fill empty 'type_name' with default:
52
+ for RecipeType in recipe_types:
53
+ if RecipeType.RECIPE_TYPE_INFO.type_name is None:
54
+ RecipeType.RECIPE_TYPE_INFO.type_name = RecipeType.__name__
55
+ self._recipe_types.extend(recipe_types)
56
+
57
+ def get_recipe_type(self, recipe_type_name: str) -> Type[Recipe]:
58
+ for RecipeType in self._recipe_types:
59
+ if RecipeType.RECIPE_TYPE_INFO.type_name == recipe_type_name:
60
+ return RecipeType
61
+ raise KeyError(f"No Recipe registered with type named {recipe_type_name!r}!")
62
+
63
+ def get_recipe_types(self) -> list[Type[Recipe]]:
64
+ return list(self._recipe_types)
65
+
66
+ def get_recipe_id(self, recipe_name: str) -> str:
67
+ """
68
+ Return the recipe id for this recipe_name (the canonical name of the
69
+ recipe package).
70
+
71
+ if validate is True:
72
+ Raises a ValueError if name cannot be used as a Recipe name.
73
+ """
74
+ try:
75
+ recipe_id = packaging.utils.canonicalize_name(recipe_name, validate=True)
76
+ except packaging.utils.InvalidName as err:
77
+ raise ValueError(f"Invalid Recipe name {recipe_name}.")
78
+
79
+ if not recipe_id.replace(".", "").replace("-", "").isidentifier():
80
+ suffix = ""
81
+ if recipe_id != recipe_name:
82
+ suffix = f" (canonicalized to {recipe_id}"
83
+ raise ValueError(f"Invalid Recipe name {recipe_name!r}{suffix}.")
84
+
85
+ return recipe_id
86
+
87
+ def create_recipe(
88
+ self,
89
+ folder: Path,
90
+ recipe_name: str,
91
+ recipe_metadata: RecipeMetadata | None,
92
+ version: str | None,
93
+ recipe_type_name: str,
94
+ ):
95
+ """
96
+ Creates an editable recipe in folder and returns
97
+ the id of the created recipe (the canonical name of the package).
98
+ """
99
+ RecipeType = self.get_recipe_type(recipe_type_name)
100
+
101
+ recipe_metadata = recipe_metadata or RecipeMetadata.create_default(
102
+ name=recipe_name,
103
+ type_name=recipe_type_name,
104
+ )
105
+ version = version or "0.1.0"
106
+ package_name = self.get_recipe_id(recipe_name)
107
+
108
+ recipe_path = folder / package_name
109
+ rich.print(f"Creating Recipe {recipe_name!r} ({package_name=})")
110
+ rich.print(f"ReceipMetadata: {recipe_metadata}")
111
+
112
+ recipe_path.mkdir(exist_ok=True)
113
+
114
+ src = (
115
+ recipe_path / "src" / package_name
116
+ ) # FIXME: support . here (package namespaces)
117
+ src.mkdir(parents=True, exist_ok=True)
118
+
119
+ recipe_toml = src / "recipe.toml"
120
+ recipe_metadata.write_toml(recipe_toml)
121
+
122
+ dinit = src / "__init__.py"
123
+ dinit_content = RecipeType._CONTROLER.default_dinit_content(RecipeType)
124
+ dinit.write_text(dinit_content)
125
+
126
+ dversion = src / "__version__.py"
127
+ dversion_content = f'__version__ = "{version}"'
128
+ dversion.write_text(dversion_content)
129
+
130
+ readme = recipe_path / "README.md"
131
+ readme_content = f"# tgzr.cuisine Recipe: {recipe_name}\n\nCreated on {datetime.datetime.now().strftime('%c')}\n"
132
+ readme.write_text(readme_content)
133
+
134
+ recipe = RecipeType(dinit)
135
+ recipe._CONTROLER.init_recipe_files(recipe, dinit)
136
+
137
+ pyproject = recipe_path / "pyproject.toml"
138
+ recipe._CONTROLER.write_pyproject_to(recipe, pyproject)
139
+ return package_name
140
+
141
+ def get_recipe_from_toml(self, toml_path) -> Recipe:
142
+ recipe_metadata = RecipeMetadata.from_toml(toml_path)
143
+ recipe_type_name = recipe_metadata.type_name
144
+ RecipeType = self.get_recipe_type(recipe_type_name)
145
+ dinit = toml_path.parent / "__init__.py"
146
+ return RecipeType(dinit)
147
+
148
+ def _edit_recipe_metadata(
149
+ self,
150
+ folder: Path,
151
+ recipe_name: str,
152
+ modifier: Callable[[RecipeMetadata], RecipeMetadata | None],
153
+ rebuild_pyproject: bool,
154
+ bump: str | None = "patch,a",
155
+ ):
156
+ recipe_path = folder / recipe_name
157
+ if not recipe_path.exists():
158
+ raise ValueError(
159
+ f"Cannot edit recipe metadata for {recipe_name!r}: it was not found in {folder})."
160
+ )
161
+
162
+ toml_path = recipe_path / "src" / recipe_name / "recipe.toml"
163
+ recipe = self.get_recipe_from_toml(toml_path)
164
+ recipe_metadata = recipe._CONTROLER.read_metadata(recipe)
165
+
166
+ recipe_metadata = modifier(recipe_metadata)
167
+
168
+ if recipe_metadata is None:
169
+ print("Nothing to save.")
170
+ return
171
+
172
+ # rich.print(recipe_metadata)
173
+
174
+ recipe._CONTROLER.write_metadata(recipe, recipe_metadata)
175
+
176
+ if rebuild_pyproject:
177
+ pyproject = recipe_path / "pyproject.toml"
178
+ recipe._CONTROLER.write_pyproject_to(recipe, pyproject)
179
+
180
+ if bump is not None:
181
+ self.bump_recipe(folder, recipe_name, "patch,a")
182
+
183
+ def rebuild_pyproject(self, folder: Path, recipe_name: str):
184
+ recipe_path = folder / recipe_name
185
+ if not recipe_path.exists():
186
+ raise ValueError(
187
+ f"Cannot rebuild pyproject for {recipe_name!r}: it was not found in {folder})."
188
+ )
189
+
190
+ toml_path = recipe_path / "src" / recipe_name / "recipe.toml"
191
+ recipe = self.get_recipe_from_toml(toml_path)
192
+ pyproject = recipe_path / "pyproject.toml"
193
+ recipe._CONTROLER.write_pyproject_to(recipe, pyproject)
194
+
195
+ def rebuild_dinit(self, folder: Path, recipe_name: str):
196
+ recipe_path = folder / recipe_name
197
+ if not recipe_path.exists():
198
+ raise ValueError(
199
+ f"Cannot rebuild pyproject for {recipe_name!r}: it was not found in {folder})."
200
+ )
201
+
202
+ src = recipe_path / "src" / recipe_name
203
+ toml_path = src / "recipe.toml"
204
+ recipe = self.get_recipe_from_toml(toml_path)
205
+ dinit = src / "__init__.py"
206
+ dinit_content = recipe._CONTROLER.default_dinit_content(type(recipe))
207
+ dinit.write_text(dinit_content)
208
+ recipe._CONTROLER.init_recipe_files(recipe, dinit)
209
+
210
+ def add_tags(self, folder: Path, recipe_name: str, tags: set[str]):
211
+
212
+ def add_tags(recipe_metadata: RecipeMetadata) -> RecipeMetadata | None:
213
+ rich.print(f"Adding tags {tags!r} to {recipe_name!r}.")
214
+ old_tags = set(recipe_metadata.tags)
215
+ recipe_metadata.tags.extend([t for t in tags if t not in old_tags])
216
+ if set(recipe_metadata.tags) == set(old_tags):
217
+ print("No change in tags, no need to save.")
218
+ return
219
+ return recipe_metadata
220
+
221
+ self._edit_recipe_metadata(
222
+ folder, recipe_name, add_tags, rebuild_pyproject=True, bump="patch,a"
223
+ )
224
+
225
+ def add_inputs(
226
+ self,
227
+ folder: Path,
228
+ recipe_name: str,
229
+ group: str = "",
230
+ *input_requirements: str,
231
+ ):
232
+ rich.print(f"Adding inputs {input_requirements!r} to {recipe_name!r}.")
233
+
234
+ def add_inputs(recipe_metadata: RecipeMetadata) -> RecipeMetadata | None:
235
+ existing_requirements = [
236
+ packaging.requirements.Requirement(i)
237
+ for i in recipe_metadata.inputs.get(group, [])
238
+ ]
239
+ existing_named_requirements = dict(
240
+ [(req.name, req) for req in existing_requirements]
241
+ )
242
+ reqs_to_remove = []
243
+ reqs_to_add = []
244
+ for input_requirement in input_requirements:
245
+ input_req = packaging.requirements.Requirement(input_requirement)
246
+ if input_req.name in existing_named_requirements:
247
+ existing_req = existing_named_requirements[input_req.name]
248
+ if input_req == existing_req:
249
+ # this requirement is already there
250
+ pass
251
+ else:
252
+ reqs_to_remove.append(existing_req)
253
+ reqs_to_add.append(input_req)
254
+ else:
255
+ reqs_to_add.append(input_req)
256
+
257
+ new_dependencies = []
258
+ for req in existing_requirements:
259
+ if req in reqs_to_remove:
260
+ continue
261
+ new_dependencies.append(str(req))
262
+
263
+ new_dependencies += [str(req) for req in reqs_to_add]
264
+ if recipe_metadata.inputs.get(group, []) == new_dependencies:
265
+ # nothing changed, no need to save
266
+ return None
267
+
268
+ recipe_metadata.inputs[group] = new_dependencies
269
+ rich.print(recipe_metadata)
270
+ return recipe_metadata
271
+
272
+ self._edit_recipe_metadata(
273
+ folder, recipe_name, add_inputs, rebuild_pyproject=True, bump="patch,a"
274
+ )
275
+
276
+ def bump_recipe(self, folder: Path, recipe_name: str, bump: str = "minor"):
277
+ recipe_path = folder / recipe_name
278
+ self._venv.hatch_version_bump(recipe_path, bump)
279
+
280
+ def build_recipe(
281
+ self,
282
+ folder: Path,
283
+ recipe_name: str,
284
+ dist_folder: Path,
285
+ ):
286
+
287
+ recipe_path = folder / recipe_name
288
+ dist_path = dist_folder / recipe_name
289
+
290
+ self._venv.hatch_build(recipe_path, dist_path, allow_custom_classifiers=True)
291
+
292
+ def publish_recipe(
293
+ self,
294
+ folder: Path,
295
+ recipe_name: str,
296
+ dist_folder: Path,
297
+ index_url: str,
298
+ **options: str,
299
+ ):
300
+ """
301
+ Provided options like:
302
+ option_name='option_value', target='bob'
303
+ will be passed to the publisher plugin like:
304
+ -o option_name=option_value -o default_target=blessed
305
+
306
+ """
307
+ recipe_path = folder / recipe_name
308
+ dist_path = dist_folder / recipe_name
309
+
310
+ self._venv.hatch_publish(recipe_path, dist_path, index_url, **options)
311
+
312
+ def run_recipe_method(
313
+ self, folder: Path, recipe_name: str, method_name: str, *args, **kwargs
314
+ ) -> Any:
315
+ """
316
+ WARNING: the method is not executed in the workbench venv but from wherever this is
317
+ called (might by a GUI app or an automation script outside of the workspace).
318
+
319
+ """
320
+ recipe_path = folder / recipe_name
321
+ if not recipe_path.exists():
322
+ raise ValueError(
323
+ f"Cannot run method {method_name} from {recipe_name!r}: it was not found in {folder})."
324
+ )
325
+ toml_path = recipe_path / "src" / recipe_name / "recipe.toml"
326
+ recipe = self.get_recipe_from_toml(toml_path)
327
+
328
+ try:
329
+ method = getattr(recipe, method_name)
330
+ except Exception as err:
331
+ raise AttributeError(
332
+ f"Cannot get method {method_name} from {recipe_name!r}: {err}."
333
+ )
334
+ try:
335
+ return method(*args, **kwargs)
336
+ except Exception as err:
337
+ print(
338
+ f"Error runnig recipr method {method_name} on {recipe_name} (a {recipe.RECIPE_TYPE_INFO.type_name}): {err}"
339
+ )
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+ from typing import Literal
3
+
4
+ import os
5
+ from pathlib import Path
6
+ import click
7
+ import dataclasses
8
+ import json
9
+ import rich, rich.table
10
+
11
+ from .._version import __version__
12
+ from ..workbench import Workbench
13
+ from .utils import ShortNameGroup
14
+
15
+
16
+ @click.group(
17
+ cls=ShortNameGroup,
18
+ context_settings={"help_option_names": ["-h", "--help"]},
19
+ )
20
+ @click.version_option(version=__version__, prog_name="cuisine")
21
+ @click.option(
22
+ "-w",
23
+ "--workbench",
24
+ metavar="PATH",
25
+ default=".",
26
+ help="Path to the cuisine workbench.",
27
+ )
28
+ @click.option(
29
+ "-c",
30
+ "--create-workbench",
31
+ default=None,
32
+ metavar="PY-VERSION",
33
+ help="Create the specified workbench if it doesn't exists, use the python version provided by this flag",
34
+ )
35
+ @click.pass_context
36
+ def cuisine_cli(ctx, workbench: str, create_workbench: str):
37
+ workbench_path = os.path.abspath(os.path.normpath(workbench))
38
+ wb = Workbench(workbench_path)
39
+ if create_workbench:
40
+ wb.ensure_exists(python_version=create_workbench)
41
+
42
+ ctx.obj = wb
43
+
44
+
45
+ #
46
+ # WORKBENCH
47
+ #
48
+
49
+
50
+ # @cuisine_cli.group(cls=ShortNameGroup, help="Manage the cuisine workbenche.")
51
+ # def workbench():
52
+ # pass
53
+
54
+
55
+ #
56
+ # RECIPES
57
+ #
58
+
59
+
60
+ @cuisine_cli.group(cls=ShortNameGroup, help="Manage Recipes")
61
+ def recipe():
62
+ pass
63
+
64
+
65
+ @recipe.command
66
+ @click.argument("name")
67
+ @click.option(
68
+ "-T",
69
+ "--recipe-type",
70
+ default="Recipe",
71
+ help="The recipe type to create, like Product or Workscene (valid values depend on the installed asset plugins)",
72
+ )
73
+ @click.pass_obj
74
+ def create(wb: Workbench, recipe_type: str, name: str):
75
+ wb.create_recipe(recipe_type_name=recipe_type, recipe_name=name)
76
+
77
+
78
+ @recipe.command
79
+ @click.option(
80
+ "-e",
81
+ "--editable-only",
82
+ is_flag=True,
83
+ help="List only the editable recipes.",
84
+ )
85
+ @click.option(
86
+ "-l",
87
+ "--libs",
88
+ is_flag=True,
89
+ help="List library package (python) too.",
90
+ )
91
+ @click.option(
92
+ "-f",
93
+ "--format",
94
+ default="human",
95
+ help="Output format: 'json', 'rich', 'human'. Default to `human`.",
96
+ )
97
+ @click.pass_obj
98
+ def ls(
99
+ wb: Workbench,
100
+ editable_only: bool = False,
101
+ libs: bool = False,
102
+ format: Literal["json", "rich", "human"] = "human",
103
+ ):
104
+ """List installed Recipes (and libs, optionnaly)."""
105
+ matched = wb.get_dist_infos(editable_only=editable_only, include_non_recipe=libs)
106
+
107
+ if format == "human":
108
+ table = rich.table.Table("Name", "Is Recipe", "Is Editable")
109
+ for dist_info in matched:
110
+ style = dist_info.is_editable and "yellow" or ""
111
+ style += dist_info.is_recipe and " on cyan" or ""
112
+
113
+ table.add_row(
114
+ dist_info.dist.name,
115
+ dist_info.is_recipe and "✔" or "",
116
+ dist_info.is_editable and "✔" or "",
117
+ style=style,
118
+ )
119
+ rich.print(table)
120
+
121
+ if format == "rich":
122
+ rich.print(matched)
123
+
124
+ elif format == "json":
125
+
126
+ def drop_undumpable(d: dict, encoder: json.JSONEncoder):
127
+ to_drop = []
128
+ to_dig = []
129
+ for k, v in d.items():
130
+ if v is None:
131
+ continue
132
+ if isinstance(v, dict):
133
+ to_dig.append(k)
134
+ elif isinstance(v, Path):
135
+ d[k] = str(v)
136
+ elif not isinstance(v, (str, bool, list, tuple)):
137
+ to_drop.append(k)
138
+ for k in to_drop:
139
+ del d[k]
140
+ for k in to_dig:
141
+ drop_undumpable(d[k], encoder)
142
+
143
+ data = [dataclasses.asdict(dist_info) for dist_info in matched]
144
+ for d in data:
145
+ drop_undumpable(d, json.JSONEncoder())
146
+ click.echo(json.dumps(data))
147
+
148
+ else:
149
+ raise click.UsageError(
150
+ f"Unsupported value for -f/--format: {format!r} (must be 'json', 'rich' or 'human')."
151
+ )
152
+
153
+
154
+ @recipe.command
155
+ @click.argument("name")
156
+ @click.pass_obj
157
+ def pyproject_rebuild(wb: Workbench, name: str):
158
+ """Rebuild the recipe pyproject file, bump the version micro and (re)install."""
159
+ wb.rebuid_pyproject(name)
160
+
161
+
162
+ @recipe.command
163
+ @click.argument("name")
164
+ @click.pass_obj
165
+ def dinit_rebuild(wb: Workbench, name: str):
166
+ """Rebuild the recipe __init__.py file, bump the version micro and (re)install."""
167
+ wb.rebuid_dinit(name)
168
+
169
+
170
+ @recipe.command
171
+ @click.argument("name")
172
+ @click.argument("tags", nargs=-1)
173
+ @click.pass_obj
174
+ def tag(wb: Workbench, name: str, tags: list[str]):
175
+ """Add tags to recipe."""
176
+ if not tags:
177
+ click.echo("No tags given, nothing to do.")
178
+ wb.tag_recipe(name, *tags)
179
+
180
+
181
+ @recipe.command
182
+ @click.argument("name")
183
+ @click.option(
184
+ "-g",
185
+ "--group",
186
+ default="",
187
+ help="If specified, add to this group (like dev, build, edit,...)",
188
+ )
189
+ @click.argument("requirements", nargs=-1)
190
+ @click.pass_obj
191
+ def add_input(wb: Workbench, name, group, requirements):
192
+ if not requirements:
193
+ click.echo("No requirements given, nothing to do.")
194
+ wb.add_inputs(name, group, *requirements)
195
+
196
+
197
+ @recipe.command(help=f"\b\n {Workbench.bump_recipe.__doc__}")
198
+ @click.argument("name")
199
+ @click.argument("bump", required=False, default="minor")
200
+ @click.pass_obj
201
+ def bump(wb: Workbench, name, bump):
202
+ wb.bump_recipe(name, bump)
203
+
204
+
205
+ @recipe.command
206
+ @click.argument("name")
207
+ @click.option(
208
+ "-b",
209
+ "--bump",
210
+ default="minor",
211
+ help=(
212
+ "The part of the version to bump, like "
213
+ "'major', 'minor', 'micro', 'alpha', 'rc' or a combination like 'minor,rc'. "
214
+ "Defaults to 'minor'."
215
+ ),
216
+ )
217
+ @click.pass_obj
218
+ def build(wb: Workbench, name, bump):
219
+ wb.build_recipe(name, bump)
220
+
221
+
222
+ @recipe.command
223
+ @click.argument("name")
224
+ @click.option(
225
+ "-r",
226
+ "--repo",
227
+ required=True,
228
+ help="Name of repo to upload to.",
229
+ )
230
+ @click.pass_obj
231
+ def publish(wb: Workbench, name, repo: str):
232
+ wb.publish_recipe(name, repo)
@@ -0,0 +1,7 @@
1
+ from . import cuisine_cli
2
+
3
+ import sys
4
+
5
+
6
+ def main():
7
+ sys.exit(cuisine_cli())
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+
6
+ class ShortNameGroup(click.Group):
7
+ """
8
+ This Group recognizes commands with less
9
+ than their full name when there is no ambiguity.
10
+ For example: 'wo'->'workspace' if no other command
11
+ starts with 'wo'
12
+ """
13
+
14
+ def __init__(self, *args, **kwargs) -> None:
15
+ super().__init__(*args, **kwargs)
16
+ self.no_args_is_help = True
17
+ self.invoke_without_command = False
18
+
19
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
20
+ known_commands = self.list_commands(ctx)
21
+ if cmd_name not in known_commands:
22
+ found = [name for name in known_commands if name.startswith(cmd_name)]
23
+ if len(found) > 1:
24
+ candidats = " or ".join(found)
25
+ raise click.UsageError(
26
+ f'Ambiuous command "{cmd_name}" (could be {candidats}).'
27
+ )
28
+ elif found:
29
+ cmd_name = found[0]
30
+
31
+ return super().get_command(ctx, cmd_name)