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.
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/__init__.py +5 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/_registry.py +78 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/BANNER +5 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/LICENSE +3 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/README-PyPi.md +8 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/RELEASE-NOTES +1 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/THIRD-PARTY +3 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/VERSION +1 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/cli_utils.py +993 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/shell.py +390 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/__init__.py +3 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/auth_group.py +197 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/cli.py +508 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/collection_group.py +458 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/config_utils.py +93 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/connection_group.py +159 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/examples_group.py +206 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/execution_utils.py +429 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fn_group.py +1109 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fnrun_group.py +285 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/group_group.py +184 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/metrics_group.py +323 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/permission_group.py +259 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/plan_group.py +324 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/project_group.py +170 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/role_group.py +630 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/settings_group.py +344 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/table_group.py +600 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trigger_group.py +338 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trx_group.py +329 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/user_group.py +200 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/worker_group.py +185 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/template.py +781 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/py.typed +0 -0
- tabsdata_cli-2.0.0.dist-info/METADATA +39 -0
- tabsdata_cli-2.0.0.dist-info/RECORD +39 -0
- tabsdata_cli-2.0.0.dist-info/WHEEL +5 -0
- tabsdata_cli-2.0.0.dist-info/licenses/src/main/python/tabsdatak/cli/assets/manifest/LICENSE +3 -0
- 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)
|