tabsdata-cli 2.0.0__cp312-abi3-win_amd64.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.
Files changed (39) hide show
  1. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/__init__.py +5 -0
  2. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/_registry.py +78 -0
  3. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/BANNER +5 -0
  4. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/LICENSE +3 -0
  5. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/README-PyPi.md +8 -0
  6. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/RELEASE-NOTES +1 -0
  7. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/THIRD-PARTY +3 -0
  8. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/VERSION +1 -0
  9. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/cli_utils.py +993 -0
  10. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/shell.py +390 -0
  11. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/__init__.py +3 -0
  12. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/auth_group.py +197 -0
  13. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/cli.py +508 -0
  14. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/collection_group.py +458 -0
  15. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/config_utils.py +93 -0
  16. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/connection_group.py +159 -0
  17. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/examples_group.py +206 -0
  18. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/execution_utils.py +429 -0
  19. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fn_group.py +1109 -0
  20. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fnrun_group.py +285 -0
  21. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/group_group.py +184 -0
  22. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/metrics_group.py +323 -0
  23. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/permission_group.py +259 -0
  24. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/plan_group.py +324 -0
  25. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/project_group.py +170 -0
  26. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/role_group.py +630 -0
  27. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/settings_group.py +344 -0
  28. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/table_group.py +600 -0
  29. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trigger_group.py +338 -0
  30. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trx_group.py +329 -0
  31. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/user_group.py +200 -0
  32. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/worker_group.py +185 -0
  33. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/template.py +781 -0
  34. tabsdata_cli-2.0.0.data/purelib/tabsdatak/py.typed +0 -0
  35. tabsdata_cli-2.0.0.dist-info/METADATA +39 -0
  36. tabsdata_cli-2.0.0.dist-info/RECORD +39 -0
  37. tabsdata_cli-2.0.0.dist-info/WHEEL +5 -0
  38. tabsdata_cli-2.0.0.dist-info/licenses/src/main/python/tabsdatak/cli/assets/manifest/LICENSE +3 -0
  39. tabsdata_cli-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,458 @@
1
+ #
2
+ # Copyright 2026 Tabsdata Inc.
3
+ #
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import rich_click as click
12
+ from rich.table import Table
13
+
14
+ from tabsdatak.cli._registry import cli_command
15
+ from tabsdatak.cli.cli_utils import (
16
+ all_projects_option,
17
+ echo_more_results,
18
+ format_option,
19
+ get_currently_pinned_object,
20
+ hint_common_solutions,
21
+ logical_prompt,
22
+ make_console,
23
+ max_result_option,
24
+ proj_option,
25
+ render,
26
+ resolve_all_projects,
27
+ serialize,
28
+ take,
29
+ verify_login_or_prompt,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from tabsdatak.sdk.tabsdata_server import TabsdataServer
34
+
35
+
36
+ @cli_command
37
+ @click.group()
38
+ def collection():
39
+ """Collection management commands"""
40
+
41
+
42
+ @collection.command()
43
+ @proj_option
44
+ @click.option("--name", "-n", help="Name of the collection to create.")
45
+ @click.option("--description", help="Description of the collection.")
46
+ @click.option(
47
+ "--group",
48
+ help=(
49
+ "Name of the group to assign. Defaults to 'default' when omitted. "
50
+ "Use 'tdk group list' to see available groups."
51
+ ),
52
+ )
53
+ @click.option(
54
+ "--storage-path",
55
+ "base_path",
56
+ help=(
57
+ "Logical storage prefix for the collection's artifacts. Defaults to '/'. "
58
+ "The physical backend is selected by the server's configured mounts."
59
+ ),
60
+ )
61
+ @click.option(
62
+ "--conn-file",
63
+ help=(
64
+ "Connection YAML document ('-' to read from stdin). Required for a "
65
+ "source/destination collection — it is created WITH its connection. "
66
+ "The connection is validated and dehydrated client-side; secret "
67
+ "values are stripped into the secret payload."
68
+ ),
69
+ )
70
+ @click.pass_context
71
+ def create(
72
+ ctx: click.Context,
73
+ name: str,
74
+ description: str,
75
+ group: str,
76
+ base_path: str,
77
+ conn_file: str,
78
+ ):
79
+ """Create a new collection"""
80
+ verify_login_or_prompt(ctx)
81
+ name = (
82
+ name
83
+ or get_currently_pinned_object(ctx, "collection")
84
+ or logical_prompt(ctx, "Name of the collection to create")
85
+ )
86
+ description = description or ""
87
+ connection = None
88
+ secrets = None
89
+ if conn_file:
90
+ connection, group, secrets = _load_connection_document(conn_file, group)
91
+ elif group in ("sources", "destinations"):
92
+ raise click.ClickException(
93
+ f"a {group} collection is created WITH its connection; pass --conn-file"
94
+ )
95
+ click.echo("Creating a new collection")
96
+ click.echo("-" * 10)
97
+ try:
98
+ server: TabsdataServer = ctx.obj["tabsdataserver"]
99
+ server.create_collection(
100
+ name,
101
+ description,
102
+ group=group,
103
+ base_path=base_path,
104
+ connection=connection,
105
+ secrets=secrets,
106
+ )
107
+ click.echo("Collection created successfully")
108
+ except Exception as e:
109
+ hint_common_solutions(ctx, e)
110
+ raise click.ClickException(f"Failed to create collection: {e}")
111
+
112
+
113
+ _DIRECTION_TO_GROUP = {
114
+ "src": "sources",
115
+ "stream-src": "sources",
116
+ "dest": "destinations",
117
+ }
118
+
119
+
120
+ def _group_for_conn(conn_cls) -> str:
121
+ """The collection group a connection belongs to, resolved from the SPI
122
+ registry: a connection bound to a (stream) source -> ``sources``, one
123
+ bound to a destination -> ``destinations``.
124
+
125
+ Every connection now shares the uniform ``connectionDef`` kind, so the
126
+ direction is taken from the registered binding rather than parsed out of
127
+ the kind string."""
128
+ # noinspection PyProtectedMember
129
+ from tabsdatak.cli import template
130
+
131
+ for r in template.list_connectors():
132
+ if r.conn_cls is conn_cls:
133
+ return _DIRECTION_TO_GROUP[r.direction]
134
+ raise click.ClickException(
135
+ f"connection type {conn_cls.__qualname__!r} is not a registered "
136
+ "source or destination connection"
137
+ )
138
+
139
+
140
+ _COLLECTION_TYPE_LABELS = {"F": "Data Factory", "S": "Source", "D": "Destination"}
141
+
142
+
143
+ def _connection_system(doc: dict) -> str:
144
+ """The target-system name (e.g. 'PostgreSQL') of a connection document,
145
+ resolved from its ``type`` through the connector registry; falls back to the
146
+ raw type for an unregistered connector."""
147
+ # noinspection PyProtectedMember
148
+ from tabsdatak.cli import template
149
+
150
+ raw = str(doc.get("type", ""))
151
+ try:
152
+ conn_cls = template.resolve_class(doc.get("type"))
153
+ except Exception: # noqa: BLE001 -- unknown / unregistered type
154
+ return raw
155
+ for r in template.list_connectors():
156
+ if r.conn_cls is conn_cls:
157
+ return r.system
158
+ return raw
159
+
160
+
161
+ def _public_connection(conn_read: dict) -> dict:
162
+ """The connection view safe to display: just the dehydrated document, never
163
+ the secret or var names."""
164
+ return {
165
+ k: v for k, v in conn_read.items() if k not in ("secret_names", "var_names")
166
+ }
167
+
168
+
169
+ def _load_connection_document(conn_file, group):
170
+ """Read the connection YAML, validate it as a `Conn`, and dehydrate it.
171
+
172
+ Returns ``(connection_yaml, group, secrets)``: the dehydrated connection
173
+ re-serialized as YAML (secret leaves reduced to ``secret:``), the group its
174
+ kind implies, and the harvested secret values. No secret value is sent
175
+ inside the connection document — they ride in the secret payload.
176
+
177
+ ``$secret:<ENV_VAR>`` values are resolved from the environment here (on the
178
+ client, at create/update time); the env vars read are echoed to the user.
179
+ """
180
+ # noinspection PyProtectedMember
181
+ from tabsdatak.cli import template
182
+ from tabsdatak.common._error import CommonException
183
+ from tabsdatak.common._json_serde import (
184
+ env_secret_ref_vars,
185
+ resolve_env_secret_refs,
186
+ )
187
+
188
+ try:
189
+ raw = (
190
+ sys.stdin.read()
191
+ if conn_file == "-"
192
+ else Path(conn_file).read_text(encoding="utf-8")
193
+ )
194
+ except OSError as e:
195
+ raise click.ClickException(f"cannot read connection file: {e}")
196
+ try:
197
+ document = template.from_yaml(raw)
198
+ except Exception as e: # noqa: BLE001 -- any YAML parse failure
199
+ raise click.ClickException(f"invalid connection YAML: {e}")
200
+ env_vars = env_secret_ref_vars(document)
201
+ try:
202
+ document = resolve_env_secret_refs(document)
203
+ except CommonException as e:
204
+ raise click.ClickException(str(e))
205
+ if env_vars:
206
+ click.echo(
207
+ "Connection secrets resolved from environment variable(s): "
208
+ + ", ".join(env_vars)
209
+ )
210
+ try:
211
+ dehydrated, secrets = template.dehydrate_document(document)
212
+ except template.TemplateError as e:
213
+ raise click.ClickException(str(e))
214
+ implied = _group_for_conn(template.resolve_class(document["type"]))
215
+ if group is not None and group != implied:
216
+ raise click.ClickException(
217
+ f"connection type {document['type']!r} makes this a {implied} "
218
+ f"collection, but --group says '{group}'"
219
+ )
220
+ return template.to_yaml(dehydrated), implied, secrets
221
+
222
+
223
+ @collection.command()
224
+ @proj_option
225
+ @click.option("--name", "-n", help="Name of the collection to delete.")
226
+ @click.option(
227
+ "--confirm",
228
+ help="Write 'delete' to confirm deletion. Will be prompted for it if not provided.",
229
+ )
230
+ @click.pass_context
231
+ def delete(ctx: click.Context, name: str, confirm: str):
232
+ """Delete a collection by name"""
233
+ verify_login_or_prompt(ctx)
234
+ name = (
235
+ name
236
+ or get_currently_pinned_object(ctx, "collection")
237
+ or logical_prompt(ctx, "Name of the collection to delete")
238
+ )
239
+ click.echo(f"Deleting collection: {name}")
240
+ click.echo("-" * 10)
241
+ confirm = confirm or logical_prompt(ctx, "Please type 'delete' to confirm deletion")
242
+ if confirm != "delete":
243
+ raise click.ClickException(
244
+ "Deletion not confirmed. The confirmation word is 'delete'."
245
+ )
246
+ try:
247
+ server: TabsdataServer = ctx.obj["tabsdataserver"]
248
+ server.delete_collection(name)
249
+ click.echo("Collection deleted successfully")
250
+ except Exception as e:
251
+ hint_common_solutions(ctx, e)
252
+ raise click.ClickException(f"Failed to delete collection: {e}")
253
+
254
+
255
+ @collection.command()
256
+ @proj_option
257
+ @format_option
258
+ @click.option("--name", "-n", help="Name of the collection to display.")
259
+ @click.pass_context
260
+ def get(ctx: click.Context, name: str):
261
+ """Display a collection by name"""
262
+ verify_login_or_prompt(ctx)
263
+ name = (
264
+ name
265
+ or get_currently_pinned_object(ctx, "collection")
266
+ or logical_prompt(ctx, "Name of the collection to display")
267
+ )
268
+ try:
269
+ server: TabsdataServer = ctx.obj["tabsdataserver"]
270
+ collection = server.get_collection(name)
271
+ data = collection._data or {}
272
+ collection_type = data.get("collection_type")
273
+ conn_read = None
274
+ if collection_type in ("S", "D"):
275
+ conn_read = _public_connection(server.get_collection_connection(name))
276
+
277
+ def _table():
278
+ table = Table(title="Collection", show_header=False, show_lines=True)
279
+ table.add_column("Field", style="cyan", no_wrap=True)
280
+ table.add_column("Value")
281
+ table.add_row("Name:", collection.name)
282
+ table.add_row("Description:", collection.description or "")
283
+ table.add_row("Project:", data.get("project") or "-")
284
+ table.add_row("Group:", data.get("group") or "-")
285
+ table.add_row(
286
+ "Collection type:",
287
+ _COLLECTION_TYPE_LABELS.get(collection_type, collection_type or "-"),
288
+ )
289
+ table.add_row("Created by:", collection.created_by or "-")
290
+ table.add_row("Created on:", collection.created_on_str or "-")
291
+ if conn_read is not None:
292
+ doc = conn_read.get("connection") or {}
293
+ table.add_row("Connection type:", _connection_system(doc))
294
+ table.add_row(
295
+ "Connection info:", serialize(conn_read, "yaml").rstrip("\n")
296
+ )
297
+ click.echo()
298
+ # emoji/markup off: connection info carries ':secret:' sentinels and
299
+ # '[]' that rich would otherwise treat as an emoji shortcode / markup.
300
+ make_console(emoji=False, markup=False).print(table)
301
+ click.echo()
302
+
303
+ render(ctx, data=lambda: collection._data, table=_table)
304
+ except Exception as e:
305
+ hint_common_solutions(ctx, e)
306
+ raise click.ClickException(f"Failed to display collection: {e}")
307
+
308
+
309
+ @collection.command()
310
+ @proj_option
311
+ @click.option(
312
+ "-o",
313
+ "--format",
314
+ "fmt",
315
+ type=click.Choice(("json", "yaml")),
316
+ default="json",
317
+ show_default=True,
318
+ help="Output format.",
319
+ )
320
+ @click.option(
321
+ "--name",
322
+ "-n",
323
+ help="Name of the source/destination collection whose connection to display.",
324
+ )
325
+ @click.pass_context
326
+ def connection(ctx: click.Context, fmt: str, name: str):
327
+ """Display the connection of a source/destination collection.
328
+
329
+ Emits the dehydrated connection document as JSON or YAML; secret and var
330
+ names are never shown.
331
+ """
332
+ verify_login_or_prompt(ctx)
333
+ name = (
334
+ name
335
+ or get_currently_pinned_object(ctx, "collection")
336
+ or logical_prompt(ctx, "Name of the collection whose connection to display")
337
+ )
338
+ try:
339
+ server: TabsdataServer = ctx.obj["tabsdataserver"]
340
+ conn_read = _public_connection(server.get_collection_connection(name))
341
+ click.echo(serialize(conn_read, fmt), nl=(fmt == "json"))
342
+ except Exception as e:
343
+ hint_common_solutions(ctx, e)
344
+ raise click.ClickException(f"Failed to display connection: {e}")
345
+
346
+
347
+ @collection.command()
348
+ @proj_option
349
+ @all_projects_option
350
+ @format_option
351
+ @max_result_option
352
+ @click.pass_context
353
+ def list(ctx: click.Context, all_projects: bool):
354
+ """List all collections"""
355
+ verify_login_or_prompt(ctx)
356
+ try:
357
+ server: TabsdataServer = ctx.obj["tabsdataserver"]
358
+ spanning = resolve_all_projects(ctx, all_projects)
359
+ list_of_collections, truncated = take(ctx, server.list_collections_generator())
360
+
361
+ def _table():
362
+ table = Table(title="Collections")
363
+ table.add_column("Name", style="cyan", no_wrap=True)
364
+ table.add_column("Description")
365
+ if spanning:
366
+ table.add_column("Project")
367
+ table.add_column("Created by")
368
+ table.add_column("Created on")
369
+ for collection in list_of_collections:
370
+ row = [collection.name, collection.description or ""]
371
+ if spanning:
372
+ row.append(collection.project or "-")
373
+ row.extend([
374
+ collection.created_by or "-",
375
+ collection.created_on_str or "-",
376
+ ])
377
+ table.add_row(*row)
378
+ click.echo()
379
+ make_console().print(table)
380
+ click.echo(f"Number of collections: {len(list_of_collections)}")
381
+ click.echo()
382
+
383
+ render(
384
+ ctx,
385
+ data=lambda: [c._data for c in list_of_collections],
386
+ table=_table,
387
+ )
388
+ echo_more_results(ctx, truncated)
389
+ except Exception as e:
390
+ hint_common_solutions(ctx, e)
391
+ raise click.ClickException(f"Failed to list collections: {e}")
392
+
393
+
394
+ @collection.command()
395
+ @proj_option
396
+ @click.option("--name", "-n", help="Name of the collection to update.")
397
+ @click.option("--new-name", help="New name for the collection.")
398
+ @click.option("--description", help="New description for the collection.")
399
+ @click.option(
400
+ "--storage-path",
401
+ "base_path",
402
+ help=(
403
+ "New logical storage prefix for the collection's artifacts. Existing data "
404
+ "versions keep their stamped path; only new artifacts use the new prefix."
405
+ ),
406
+ )
407
+ @click.option(
408
+ "--conn-file",
409
+ help=(
410
+ "Connection YAML document ('-' to read from stdin) to create or replace "
411
+ "the source/destination collection's connection. Validated and dehydrated "
412
+ "client-side; secret values are stripped into the secret payload."
413
+ ),
414
+ )
415
+ @click.pass_context
416
+ def update(
417
+ ctx: click.Context,
418
+ name: str,
419
+ new_name: str,
420
+ description: str,
421
+ base_path: str,
422
+ conn_file: str,
423
+ ):
424
+ """Update a collection by name"""
425
+ verify_login_or_prompt(ctx)
426
+ name = (
427
+ name
428
+ or get_currently_pinned_object(ctx, "collection")
429
+ or logical_prompt(ctx, "Name of the collection to update")
430
+ )
431
+ click.echo(f"Updating collection: {name}")
432
+ click.echo("-" * 10)
433
+ connection = None
434
+ secrets = None
435
+ if conn_file:
436
+ connection, _group, secrets = _load_connection_document(conn_file, None)
437
+ try:
438
+ server: TabsdataServer = ctx.obj["tabsdataserver"]
439
+ # Scalar fields go through the collection-update endpoint; the connection
440
+ # (if any) through its dedicated upsert endpoint. When only --conn-file is
441
+ # given, skip the no-op scalar update.
442
+ scalar_change = new_name or description is not None or base_path is not None
443
+ if scalar_change or not conn_file:
444
+ server.update_collection(
445
+ name,
446
+ new_name=new_name,
447
+ new_description=description,
448
+ new_base_path=base_path,
449
+ )
450
+ if connection is not None:
451
+ # A rename lands first, so the connection upsert targets the new name.
452
+ server.update_collection_connection(
453
+ new_name or name, connection=connection, secrets=secrets
454
+ )
455
+ click.echo("Collection updated successfully")
456
+ except Exception as e:
457
+ hint_common_solutions(ctx, e)
458
+ raise click.ClickException(f"Failed to update collection: {e}")
@@ -0,0 +1,93 @@
1
+ #
2
+ # Copyright 2026 Tabsdata Inc.
3
+ #
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+
10
+ from ruamel.yaml import YAML
11
+
12
+ from tabsdatak.common._resources.reader import td_resource
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ACTIONS_DIRECTORY = "resources/cli/actions"
17
+
18
+
19
+ class Action:
20
+ """
21
+ Base class for all action implementations.
22
+ """
23
+
24
+ def __init__(self, name: str, definition: dict):
25
+ self.name = name
26
+ self.definition = definition
27
+
28
+ @property
29
+ def enabled(self) -> bool:
30
+ return self.definition.get("enabled", True)
31
+
32
+ def execute(self, **kwargs):
33
+ raise NotImplementedError("Subclasses must implement the execute method.")
34
+
35
+ def __str__(self):
36
+ return f"Action(name={self.name}, definition={self.definition})"
37
+
38
+ def __repr__(self):
39
+ return self.__str__()
40
+
41
+
42
+ def variant_action_classes() -> dict[str, type[Action]]:
43
+ try:
44
+ from tabsdatak.extensions.variant.cli.config_utils import ACTION_CLASSES
45
+ except ImportError:
46
+ return {}
47
+ return ACTION_CLASSES
48
+
49
+
50
+ def create_action(action_name: str, action_definition: dict) -> Action | None:
51
+ action_cls = variant_action_classes().get(action_name)
52
+ if not action_cls:
53
+ logger.warning(
54
+ f"Skipping action {action_name!r}: no implementation available in "
55
+ "the current variant."
56
+ )
57
+ return None
58
+ return action_cls(action_name, action_definition)
59
+
60
+
61
+ def load_actions_from_config(config: dict) -> list[Action]:
62
+ actions = []
63
+ raw_actions = config.get("actions", []) or []
64
+ for action_configuration in raw_actions:
65
+ action_name, action_definition = next(iter(action_configuration.items()))
66
+ action = create_action(action_name, action_definition)
67
+ if action is not None:
68
+ actions.append(action)
69
+ return actions
70
+
71
+
72
+ def load_actions_config_for_command(command_name: str) -> dict:
73
+ config_path = os.path.join(td_resource(ACTIONS_DIRECTORY), f"{command_name}.yaml")
74
+ try:
75
+ with open(config_path, "r") as f:
76
+ # ruamel, not PyYAML: PyYAML is NOT a declared tabsdata-cli
77
+ # dependency (only ruamel is, transitively via tabsdata-api), and
78
+ # this module is imported eagerly by the root CLI -- an undeclared
79
+ # top-level ``import yaml`` would fail the whole CLI on a minimal
80
+ # install. ``or {}`` keeps the empty-file case a dict (safe_load
81
+ # returned None), matching the FileNotFoundError default.
82
+ config = YAML(typ="safe").load(f) or {}
83
+ except FileNotFoundError:
84
+ config = {}
85
+ return config
86
+
87
+
88
+ def execute_actions_for_command(command_name: str, **kwargs):
89
+ config = load_actions_config_for_command(command_name)
90
+ actions = load_actions_from_config(config)
91
+ for action in actions:
92
+ if action.enabled:
93
+ action.execute(**kwargs)