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,993 @@
1
+ #
2
+ # Copyright 2026 Tabsdata Inc.
3
+ #
4
+
5
+ """Shared helpers for the ``tdk`` CLI.
6
+
7
+ ``tdk`` migration can land without dragging in the explicitly-excluded
8
+ ``_utils/internal/`` subtree. Placeholder pending a proper v2 port
9
+ (see TODO block below).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import importlib.abc
16
+ import importlib.machinery
17
+ import importlib.util
18
+ import io
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ from contextlib import contextmanager
24
+ from itertools import islice
25
+ from types import ModuleType
26
+ from typing import TYPE_CHECKING, Any, Iterable, Iterator, Sequence
27
+
28
+ import rich_click as click
29
+ from rich_click import Option, UsageError
30
+
31
+ # Heavy imports (the server SDK, requests, ruamel) are deferred into the
32
+ # functions that use them so importing this shared helper -- which every command
33
+ # module does -- stays cheap. Type-only names live under TYPE_CHECKING.
34
+ if TYPE_CHECKING:
35
+ from rich.console import Console
36
+
37
+ RENDER_WIDTH = 1024
38
+
39
+
40
+ def make_console(**kwargs) -> "Console":
41
+ from rich.console import Console
42
+
43
+ width = None if sys.stdout.isatty() else RENDER_WIDTH
44
+ return Console(width=width, **kwargs)
45
+
46
+
47
+ CONNECTION_FILE = "connection.json"
48
+
49
+ # Mirrors tabsdatak.sdk._apiserver.DEFAULT_TABSDATA_DIRECTORY. Defined locally so
50
+ # the common CLI path (which needs only this constant) never imports the
51
+ # requests-heavy _apiserver module.
52
+ DEFAULT_TABSDATA_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tabsdata")
53
+
54
+ # Keys of the persisted settings in ``options.json``, all managed by ``tdk set``
55
+ # / ``tdk unset``. On/off settings store ENABLED / DISABLED; ``pins`` nests the
56
+ # pinned project and collection.
57
+ OPTION_HINTS = "hints"
58
+ OPTION_PROMPT = "prompt"
59
+ OPTION_SERVER = "server"
60
+ OPTION_PINS = "pins"
61
+
62
+ ENABLED = "enabled"
63
+ DISABLED = "disabled"
64
+
65
+ PIN_PROJECT = "project"
66
+ PIN_COLLECTION = "collection"
67
+
68
+ # Mirrors what tabsdatak.sdk._apiserver.process_url() fills in when a
69
+ # user-typed server URL carries no protocol or no port. Defined locally for the
70
+ # same reason as DEFAULT_TABSDATA_DIRECTORY above, and shared by every place
71
+ # that takes a server URL so they all quote the same defaults.
72
+ DEFAULT_SERVER_PROTOCOL = "http://"
73
+ DEFAULT_APISERVER_PORT = "2457"
74
+
75
+ SERVER_URL_DEFAULTS_HELP = (
76
+ f"If omitted, the protocol defaults to '{DEFAULT_SERVER_PROTOCOL}' and the "
77
+ f"port to {DEFAULT_APISERVER_PORT}."
78
+ )
79
+
80
+
81
+ def is_valid_id(possible_id: str) -> bool:
82
+ """
83
+ Check if the provided string is a valid ID for an APIServer entity,
84
+ for example a worker.
85
+ Matches both id forms 2.0 emits: a 26-character lowercase base32hex id, and a
86
+ logical id (``<key><YYYYMMDD>-<counter>``, e.g. ``wkr20260728-3``). Uppercase
87
+ is accepted too since the id decoder is case-insensitive.
88
+ For now only needed in the CLI, if it ever is needed in more places it should be
89
+ moved to general utils.
90
+ """
91
+ id_pattern = re.compile(r"^([0-9a-vA-V]{26}|[a-zA-Z]{1,3}[0-9]{8}-[0-9]{1,10})$")
92
+ return bool(id_pattern.match(possible_id))
93
+
94
+
95
+ def beautify_list(list_to_show) -> str:
96
+ if not list_to_show:
97
+ return "<None>"
98
+ if isinstance(list_to_show, list):
99
+ return "\n".join(list_to_show)
100
+ return str(list_to_show)
101
+
102
+
103
+ def beautify_time(time: str) -> str:
104
+ if time == "None":
105
+ return "-"
106
+ return time
107
+
108
+
109
+ def collection_label(obj) -> str:
110
+ """Collection name for display, suffixed with ' (deleted)' when the owning
111
+ collection has been soft-deleted. The name itself is the real pre-deletion
112
+ name (resolved server-side); the suffix only flags that it no longer exists.
113
+ Degrades to the plain name when the response carries no `collection_deleted`.
114
+ """
115
+ name = obj.collection.name
116
+ data = getattr(obj, "_data", None) or {}
117
+ return f"{name} (deleted)" if data.get("collection_deleted") else name
118
+
119
+
120
+ def prompt_with_escape(message, default=None, hide_input=False):
121
+ """Prompt using prompt_toolkit so that both Ctrl+C and ESC abort."""
122
+ from prompt_toolkit import PromptSession
123
+ from prompt_toolkit.key_binding import KeyBindings
124
+
125
+ kb = KeyBindings()
126
+
127
+ @kb.add("escape")
128
+ def _(event):
129
+ event.app.exit(exception=KeyboardInterrupt)
130
+
131
+ suffix = ": "
132
+ if default is not None:
133
+ prompt_text = f"{message} [{default}]{suffix}"
134
+ else:
135
+ prompt_text = f"{message}{suffix}"
136
+
137
+ session = PromptSession()
138
+ session.app.ttimeoutlen = 0.1
139
+ session.app.timeoutlen = 0.1
140
+
141
+ result = session.prompt(
142
+ prompt_text,
143
+ key_bindings=kb,
144
+ is_password=hide_input,
145
+ )
146
+
147
+ return result if result else default
148
+
149
+
150
+ def _prompting_disabled_message(ctx: click.Context) -> str:
151
+ """Why prompting is off, and how to turn it back on: the persisted
152
+ ``tdk set --prompt false`` outlives the invocation, so it is named ahead of
153
+ the one-shot ``--no-prompt`` flag."""
154
+ if not ctx.obj.get("prompt_setting", True):
155
+ return (
156
+ "Prompting is disabled by the 'prompt' setting and some required "
157
+ "values are missing. Please provide the required values or "
158
+ "re-enable prompting with 'tdk set --prompt true'."
159
+ )
160
+ return (
161
+ "Prompting is disabled and some required "
162
+ "values are missing. Please provide the "
163
+ "required values or avoid using '--no-prompt'."
164
+ )
165
+
166
+
167
+ def logical_prompt(
168
+ ctx: click.Context, message: str, default_value=None, hide_input: bool = False
169
+ ):
170
+ """
171
+ Prompt the user for a value if prompt is enabled. Otherwise, either return the
172
+ default value, or raise an error.
173
+ """
174
+
175
+ if ctx.obj["no_prompt"]:
176
+ if default_value is None:
177
+ raise click.ClickException(_prompting_disabled_message(ctx))
178
+ return default_value
179
+ try:
180
+ import sys
181
+
182
+ if sys.stdin.isatty():
183
+ return prompt_with_escape(
184
+ message, default=default_value, hide_input=hide_input
185
+ )
186
+ else:
187
+ return click.prompt(message, default=default_value, hide_input=hide_input)
188
+ except (KeyboardInterrupt, EOFError):
189
+ click.echo()
190
+ raise click.exceptions.Exit(0)
191
+
192
+
193
+ def utils_login(ctx: click.Context, server_url: str, username: str, password: str):
194
+ import requests
195
+
196
+ # noinspection PyProtectedMember
197
+ from tabsdatak.sdk._apiserver import APIServerError, obtain_connection
198
+
199
+ try:
200
+ obtain_connection(
201
+ server_url,
202
+ username,
203
+ password,
204
+ credentials_file=get_credentials_file_path(ctx),
205
+ )
206
+ except Exception as e:
207
+ if isinstance(e, requests.exceptions.ConnectionError):
208
+ show_hint(
209
+ ctx,
210
+ "It seems like there is no Tabsdata server at the "
211
+ f"provided URL: {server_url}.\n "
212
+ "Please ensure that the URL is correct and the server is "
213
+ "running and reachable.\n If started locally, the status can be "
214
+ "checked by "
215
+ "executing 'tdserver status'.",
216
+ )
217
+ elif isinstance(e, APIServerError):
218
+ show_hint(
219
+ ctx,
220
+ "It seems like the Tabsdata server is running but refusing "
221
+ "to login. Please ensure that the credentials are correct and "
222
+ "the server is healthy.",
223
+ )
224
+ raise click.ClickException(f"Failed to login: {e}")
225
+ click.echo("Login successful.")
226
+
227
+
228
+ def request_login_information(ctx: click.Context):
229
+ server_url = configured_server(ctx) or logical_prompt(ctx, "Server URL")
230
+ username = logical_prompt(ctx, "Username")
231
+ password = logical_prompt(ctx, "Password", hide_input=True)
232
+ return server_url, username, password
233
+
234
+
235
+ class _LazyTabsdataServer:
236
+ """Stand-in for the connected ``TabsdataServer`` that builds the real,
237
+ SDK-backed object (and pays its heavy imports) only on first attribute
238
+ access. Commands that never touch the server -- ``examples``,
239
+ ``connection``, ``--help`` -- never trigger it. It is truthy so the usual
240
+ ``if not ctx.obj["tabsdataserver"]`` login checks keep working."""
241
+
242
+ def __init__(self, ctx: click.Context, credentials: dict):
243
+ self._ctx = ctx
244
+ self._credentials = credentials
245
+ self._server = None
246
+
247
+ def _build(self):
248
+ if self._server is None:
249
+ # noinspection PyProtectedMember
250
+ from tabsdatak.sdk._apiserver import APIServer
251
+ from tabsdatak.sdk.tabsdata_server import TabsdataServer
252
+
253
+ credentials = self._credentials
254
+ connection = APIServer(
255
+ credentials.get("url"),
256
+ credentials_file=get_credentials_file_path(self._ctx),
257
+ )
258
+ connection.refresh_token = credentials.get("refresh_token")
259
+ connection.bearer_token = credentials.get("bearer_token")
260
+ connection.token_type = credentials.get("token_type")
261
+ connection.expires_in = credentials.get("expires_in")
262
+ connection.expiration_time = credentials.get("expiration_time")
263
+ # The active project rides on the connection: it defaults to the
264
+ # pinned project, and a per-command `--proj` overrides it.
265
+ connection.project = get_pin(self._ctx, PIN_PROJECT)
266
+ server = TabsdataServer.__new__(TabsdataServer)
267
+ server.connection = connection
268
+ self._server = server
269
+ return self._server
270
+
271
+ def __getattr__(self, name: str):
272
+ return getattr(self._build(), name)
273
+
274
+ def __bool__(self) -> bool:
275
+ return True
276
+
277
+
278
+ def initialise_tabsdata_server_connection(ctx: click.Context):
279
+ try:
280
+ with open(os.path.join(DEFAULT_TABSDATA_DIRECTORY, CONNECTION_FILE)) as f:
281
+ credentials = json.load(f)
282
+ except FileNotFoundError:
283
+ ctx.obj["tabsdataserver"] = None
284
+ return
285
+ ctx.obj["tabsdataserver"] = _LazyTabsdataServer(ctx, credentials)
286
+
287
+
288
+ def get_currently_pinned_object(ctx: click.Context, object: str) -> str | None:
289
+ """
290
+ Get the currently pinned object from the context.
291
+ """
292
+ currently_pinned = get_pin(ctx, object)
293
+ if currently_pinned:
294
+ click.echo(f"Using currently pinned {object}: {currently_pinned}")
295
+ return currently_pinned
296
+
297
+
298
+ def _set_active_project(ctx: click.Context, project: str | None):
299
+ server = ctx.obj.get("tabsdataserver")
300
+ if server is not None:
301
+ server.connection.project = project
302
+
303
+
304
+ def _apply_proj(ctx: click.Context, param, value):
305
+ """``--proj`` option callback: target a project for this command.
306
+
307
+ The active project rides on the server connection. The root group seeds it
308
+ from the pinned project; a per-command ``--proj`` overrides it for this
309
+ invocation. The connection builds project-scoped paths from it and narrows
310
+ cross-project listings to it, so commands never thread it any further.
311
+
312
+ Carrying the option on the context too marks the command as project-scoped
313
+ -- which is what makes :func:`resolve_project_or_prompt` ask for a project
314
+ before anything else -- and lets a command that has to log in first apply
315
+ ``--proj`` to the connection that login builds."""
316
+ ctx.ensure_object(dict)
317
+ ctx.obj["project_scoped"] = True
318
+ if value:
319
+ ctx.obj["proj"] = value
320
+ _set_active_project(ctx, value)
321
+ return value
322
+
323
+
324
+ def proj_option(fn):
325
+ """Decorator adding a per-command ``--proj`` that targets a project,
326
+ overriding the pinned project for that invocation."""
327
+ return click.option(
328
+ "--proj",
329
+ "-P",
330
+ "proj",
331
+ expose_value=False,
332
+ callback=_apply_proj,
333
+ help=(
334
+ "Project to target; overrides the pinned project. Prompted for when "
335
+ "a command needs one and neither is set."
336
+ ),
337
+ )(fn)
338
+
339
+
340
+ def all_projects_option(fn):
341
+ """Decorator adding ``--all-projects`` to a cross-project list command."""
342
+ return click.option(
343
+ "--all-projects",
344
+ "all_projects",
345
+ is_flag=True,
346
+ default=False,
347
+ help=(
348
+ "List across every project you can view, ignoring any pinned or "
349
+ "--proj project."
350
+ ),
351
+ )(fn)
352
+
353
+
354
+ def _spans_projects(ctx: click.Context) -> bool:
355
+ """Whether this command runs fine with no project at all -- true for the
356
+ cross-project listings, which are exactly the ones offering
357
+ ``--all-projects``."""
358
+ return any(param.name == "all_projects" for param in ctx.command.params)
359
+
360
+
361
+ def resolve_project_or_prompt(ctx: click.Context):
362
+ """Settle the active project before the command prompts for anything else.
363
+
364
+ Collections, functions and tables are all addressed within a project, so a
365
+ project-scoped command with no project selected asks for one first --
366
+ otherwise it would prompt for a collection only to fail afterwards on the
367
+ missing project. With prompting off the project is left unset and the
368
+ request fails with the SDK's ``NoProjectSelectedError``, which names both
369
+ ways to supply one."""
370
+ if ctx.obj.get("no_prompt") or not ctx.obj.get("project_scoped"):
371
+ return
372
+ if _spans_projects(ctx):
373
+ return
374
+ server = ctx.obj.get("tabsdataserver")
375
+ if server is None or server.connection.project:
376
+ return
377
+ _set_active_project(ctx, logical_prompt(ctx, "Name of the project to target"))
378
+
379
+
380
+ def resolve_all_projects(ctx: click.Context, all_projects: bool) -> bool:
381
+ """Apply ``--all-projects`` for this command and report whether the listing
382
+ spans projects.
383
+
384
+ ``--all-projects`` clears the active project, overriding any pinned or
385
+ ``--proj`` project — an unset project makes listings span every project
386
+ the requester can view. Even without the flag a listing still spans
387
+ projects when no project is selected at all (the implicit fallback)."""
388
+ server = ctx.obj.get("tabsdataserver")
389
+ if server is None:
390
+ return False
391
+ if all_projects:
392
+ server.connection.project = None
393
+ return server.connection.project is None
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Output rendering: -o/--format {table,json,yaml}.
398
+ # ---------------------------------------------------------------------------
399
+
400
+ OUTPUT_FORMATS = ("table", "json", "yaml")
401
+
402
+
403
+ def _apply_format(ctx: click.Context, param, value):
404
+ """``-o/--format`` callback: stash the chosen format on the context so the
405
+ command's renderer can dispatch on it."""
406
+ ctx.ensure_object(dict)
407
+ ctx.obj["output_format"] = value or "table"
408
+ return value
409
+
410
+
411
+ def format_option(fn):
412
+ """Decorator adding ``-o/--format {table,json,yaml}`` (default ``table``)."""
413
+ return click.option(
414
+ "-o",
415
+ "--format",
416
+ "fmt",
417
+ type=click.Choice(OUTPUT_FORMATS),
418
+ default="table",
419
+ show_default=True,
420
+ expose_value=False,
421
+ callback=_apply_format,
422
+ help="Output format.",
423
+ )(fn)
424
+
425
+
426
+ def output_format(ctx: click.Context) -> str:
427
+ """The active output format for this command (defaults to ``table``)."""
428
+ return (ctx.obj or {}).get("output_format", "table")
429
+
430
+
431
+ def serialize(payload, fmt: str) -> str:
432
+ """Serialize a payload to a ``json`` or ``yaml`` string (the machine formats
433
+ of ``--format``). JSON is pretty-printed; both carry every field."""
434
+ if fmt == "json":
435
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
436
+ from ruamel.yaml import YAML
437
+
438
+ # Normalize through JSON (default=str) so any value is YAML-safe, then
439
+ # dump with ruamel (a guaranteed dependency via tabsdata-api; PyYAML is
440
+ # not a declared tabsdata-cli dependency). Key order is preserved.
441
+ normalized = json.loads(json.dumps(payload, default=str))
442
+ yaml = YAML()
443
+ yaml.default_flow_style = False
444
+ yaml.allow_unicode = True
445
+ buf = io.StringIO()
446
+ yaml.dump(normalized, buf)
447
+ return buf.getvalue()
448
+
449
+
450
+ def render(ctx: click.Context, *, data, table) -> None:
451
+ """Render a command's result in the active output format.
452
+
453
+ ``table`` is a zero-arg callable that prints the human-facing view (the
454
+ per-command rich table); it is invoked only in ``table`` mode. ``data`` is
455
+ -- or returns -- the full DTO(s) to serialize for ``json``/``yaml``; pass a
456
+ callable to defer fetching the full payload when the table view is shown.
457
+ JSON is pretty-printed and both machine formats carry all available fields;
458
+ data goes to stdout so it stays parseable."""
459
+ fmt = output_format(ctx)
460
+ if fmt == "table":
461
+ table()
462
+ return
463
+ payload = data() if callable(data) else data
464
+ text = serialize(payload, fmt)
465
+ # YAML already ends with a newline; JSON does not.
466
+ click.echo(text, nl=(fmt == "json"))
467
+
468
+
469
+ # ---------------------------------------------------------------------------
470
+ # Listing size: --max-result.
471
+ # ---------------------------------------------------------------------------
472
+
473
+ DEFAULT_MAX_RESULT = 100
474
+
475
+
476
+ def _apply_max_result(ctx: click.Context, param, value):
477
+ """``--max-result`` callback: stash the cap on the context so the command's
478
+ listing helper can apply it."""
479
+ ctx.ensure_object(dict)
480
+ ctx.obj["max_result"] = DEFAULT_MAX_RESULT if value is None else value
481
+ return value
482
+
483
+
484
+ def max_result_option(fn):
485
+ """Decorator adding ``--max-result`` to a list command."""
486
+ return click.option(
487
+ "--max-result",
488
+ "max_result",
489
+ type=click.IntRange(min=0),
490
+ default=DEFAULT_MAX_RESULT,
491
+ show_default=True,
492
+ expose_value=False,
493
+ callback=_apply_max_result,
494
+ help="Maximum number of rows to list. Use 0 for no limit.",
495
+ )(fn)
496
+
497
+
498
+ def max_result(ctx: click.Context) -> int:
499
+ """The active row cap for this command (0 means no limit)."""
500
+ return (ctx.obj or {}).get("max_result", DEFAULT_MAX_RESULT)
501
+
502
+
503
+ def take(ctx: click.Context, rows: Iterable) -> tuple[list, bool]:
504
+ """Drain a listing generator up to the active ``--max-result``.
505
+
506
+ Fetches one row beyond the cap so a full page is distinguishable from a
507
+ truncated one, then discards it. Returns the capped rows and whether the
508
+ server holds more."""
509
+ limit = max_result(ctx)
510
+ if not limit:
511
+ return list(rows), False
512
+ capped = list(islice(rows, limit + 1))
513
+ if len(capped) > limit:
514
+ return capped[:limit], True
515
+ return capped, False
516
+
517
+
518
+ def echo_more_results(ctx: click.Context, truncated: bool) -> None:
519
+ """Tell the user the listing was cut short by ``--max-result``.
520
+
521
+ In the machine formats the notice goes to stderr so stdout stays a single
522
+ parseable document."""
523
+ if not truncated:
524
+ return
525
+ message = (
526
+ f"More results available: showing the first {max_result(ctx)}. Raise "
527
+ "'--max-result <N>', or pass '--max-result 0' for all."
528
+ )
529
+ click.echo(message, err=output_format(ctx) != "table")
530
+
531
+
532
+ def verify_login_or_prompt(ctx: click.Context):
533
+ """Settle what every command needs before it runs: a logged-in connection
534
+ and, for a project-scoped command, an active project. Commands call it as
535
+ their first statement, so the project is asked for ahead of the collection,
536
+ function or table it scopes."""
537
+ if not ctx.obj["tabsdataserver"]:
538
+ click.echo("No credentials found. Please login first.")
539
+ server_url, username, password = request_login_information(ctx)
540
+ utils_login(ctx, server_url, username, password)
541
+ initialise_tabsdata_server_connection(ctx)
542
+ # `--proj` was parsed before there was a connection to put it on; the
543
+ # login just built one, so apply it now.
544
+ if ctx.obj.get("proj"):
545
+ _set_active_project(ctx, ctx.obj["proj"])
546
+ resolve_project_or_prompt(ctx)
547
+
548
+
549
+ class MutuallyExclusiveOption(Option):
550
+ def __init__(self, *args, **kwargs):
551
+ self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", []))
552
+ help = kwargs.get("help", "")
553
+ if self.mutually_exclusive:
554
+ ex_str = ", ".join(self.mutually_exclusive)
555
+ kwargs["help"] = help + (
556
+ " NOTE: This argument is mutually exclusive with arguments: ["
557
+ + ex_str
558
+ + "]."
559
+ )
560
+ super(MutuallyExclusiveOption, self).__init__(*args, **kwargs)
561
+
562
+ def handle_parse_result(self, ctx, opts, args):
563
+ if self.mutually_exclusive.intersection(opts) and self.name in opts:
564
+ raise UsageError(
565
+ "Illegal usage: `{}` is mutually exclusive with arguments `{}`.".format(
566
+ self.name, ", ".join(self.mutually_exclusive)
567
+ )
568
+ )
569
+ return super(MutuallyExclusiveOption, self).handle_parse_result(ctx, opts, args)
570
+
571
+
572
+ def get_credentials_file_path(ctx: click.Context) -> str:
573
+ """
574
+ Get the path to the credentials file.
575
+ """
576
+ return os.path.join(ctx.obj["tabsdata_directory"], CONNECTION_FILE)
577
+
578
+
579
+ def load_cli_options(ctx: click.Context):
580
+ try:
581
+ with open(get_cli_options_file_path(ctx)) as f:
582
+ cli_options = json.load(f)
583
+ except FileNotFoundError:
584
+ cli_options = {}
585
+ pins = cli_options.get(OPTION_PINS)
586
+ cli_options[OPTION_PINS] = pins if isinstance(pins, dict) else {}
587
+ ctx.obj["cli_options"] = cli_options
588
+
589
+
590
+ def get_cli_options_file_path(ctx: click.Context) -> str:
591
+ """
592
+ Get the path to the cli options file.
593
+ """
594
+ return os.path.join(ctx.obj["tabsdata_directory"], "options.json")
595
+
596
+
597
+ def store_cli_options(ctx: click.Context):
598
+ try:
599
+ with open(get_cli_options_file_path(ctx), "w") as f:
600
+ json.dump(ctx.obj["cli_options"], f)
601
+ except Exception as e:
602
+ click.echo(f"Failed to store CLI options: {e}")
603
+ click.echo("This will not affect the rest of the command execution.")
604
+
605
+
606
+ def get_current_cli_option(ctx: click.Context, option: str, default=None) -> str | None:
607
+ """
608
+ Get the current CLI option from the context.
609
+ """
610
+ cli_option = ctx.obj["cli_options"].get(option, default)
611
+ return cli_option
612
+
613
+
614
+ def set_current_cli_option(ctx: click.Context, option: str, value: str):
615
+ """
616
+ Set the current CLI option in the context.
617
+ """
618
+ ctx.obj["cli_options"][option] = value
619
+
620
+
621
+ def clear_current_cli_option(ctx: click.Context, option: str):
622
+ """
623
+ Remove a CLI option, restoring its default.
624
+ """
625
+ ctx.obj["cli_options"].pop(option, None)
626
+
627
+
628
+ def cli_option_enabled(ctx: click.Context, option: str, default: bool = True) -> bool:
629
+ """Whether an on/off option is enabled. Anything other than the stored
630
+ ``disabled`` marker reads as enabled."""
631
+ stored = get_current_cli_option(
632
+ ctx, option, default=ENABLED if default else DISABLED
633
+ )
634
+ return stored != DISABLED
635
+
636
+
637
+ def set_cli_option_enabled(ctx: click.Context, option: str, enabled: bool):
638
+ """Turn an on/off option on or off (persist with
639
+ :func:`store_cli_options`)."""
640
+ set_current_cli_option(ctx, option, ENABLED if enabled else DISABLED)
641
+
642
+
643
+ def configured_server(ctx: click.Context) -> str | None:
644
+ """The server URL ``tdk login`` falls back to when given no ``--server``,
645
+ as set by ``tdk set --server``."""
646
+ return get_current_cli_option(ctx, OPTION_SERVER)
647
+
648
+
649
+ def get_pins(ctx: click.Context) -> dict:
650
+ """The pinned objects, as stored in the CLI options."""
651
+ return ctx.obj["cli_options"].setdefault(OPTION_PINS, {})
652
+
653
+
654
+ def get_pin(ctx: click.Context, name: str) -> str | None:
655
+ return get_pins(ctx).get(name)
656
+
657
+
658
+ def set_pin(ctx: click.Context, name: str, value: str | None):
659
+ """Pin (or, with ``None``, unpin) an object (persist with
660
+ :func:`store_cli_options`)."""
661
+ get_pins(ctx)[name] = value
662
+
663
+
664
+ def clear_pins(ctx: click.Context):
665
+ """Drop every pin (persist with :func:`store_cli_options`)."""
666
+ get_pins(ctx).clear()
667
+
668
+
669
+ def show_hint(ctx: click.Context, hint: str, final_empty_line: bool = False):
670
+ """
671
+ Show a hint in the CLI.
672
+ """
673
+ if cli_option_enabled(ctx, OPTION_HINTS):
674
+ click.echo(click.style("Hint: ", fg="green", bold=True), nl=False)
675
+ click.echo(hint)
676
+ click.echo("Use 'tdk unset --hints' to stop seeing these hints.")
677
+ if final_empty_line:
678
+ click.echo()
679
+
680
+
681
+ def hint_common_solutions(ctx: click.Context, e: Exception):
682
+ """
683
+ Show common solutions for CLI issues.
684
+ """
685
+ import requests
686
+
687
+ # noinspection PyProtectedMember
688
+ from tabsdatak.sdk._apiserver import BASE_API_URL
689
+
690
+ if isinstance(e, requests.exceptions.ConnectionError):
691
+ server = ctx.obj.get("tabsdataserver")
692
+ server_url = server.connection.url[: -len(BASE_API_URL)] if server else None
693
+ if server_url:
694
+ show_hint(
695
+ ctx,
696
+ "It seems like there is no Tabsdata server at the "
697
+ f"provided URL: {server_url}.\n "
698
+ "Please ensure that the URL is correct and the server is "
699
+ "running and reachable.\n If started locally, the status can be "
700
+ "checked by "
701
+ "executing 'tdserver status'.",
702
+ )
703
+ elif isinstance(e, AttributeError):
704
+ show_hint(
705
+ ctx,
706
+ "It seems like you might have an expired session. You can "
707
+ "log in again with 'tdk login'.",
708
+ )
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Dynamic-import of a user function from a "path/to/file.py::name" string.
713
+ #
714
+ # Used by ``tdk fn register`` to materialise the user's decorated
715
+ # function (a tabsdatak ``*Info`` descriptor) without touching
716
+ # ``sys.path``. The legacy version lived in the SDK; v2 keeps it
717
+ # CLI-side because the SDK takes a ready-made ``info`` object directly.
718
+ # ---------------------------------------------------------------------------
719
+
720
+
721
+ def _split_python_function_path(path: str) -> tuple[str, str]:
722
+ if len(path.split("::")) != 2:
723
+ raise ValueError(
724
+ f"Failed to split path '{path}' in two parts. Please make sure that "
725
+ f"the path to the registered function is of the form "
726
+ f"'/path/to/file.py::function_name'"
727
+ )
728
+ file_path, function_name = path.split("::")
729
+ return file_path, function_name
730
+
731
+
732
+ class _SiblingFinder(importlib.abc.MetaPathFinder):
733
+ """Resolves a bare top-level name to a module or package in one of
734
+ ``folders`` (a ``<name>.py`` file or a ``<name>/`` package), so an
735
+ absolute ``import <name>`` inside a bundled user file finds either a
736
+ flat sibling (the entry file's own folder) or a top-level bundle
737
+ package (the bundle root).
738
+
739
+ Appended to the *end* of ``sys.meta_path``, so every real finder
740
+ (builtin, frozen, ``PathFinder`` over ``sys.path``) gets first
741
+ refusal: installed packages always win, and a user file named after
742
+ an installed package cannot shadow it. A bundle name is reachable by
743
+ absolute import only when it does not collide with an installed
744
+ package; a colliding name stays importable via the relative form
745
+ (``from .<name> import ...``).
746
+
747
+ Resolution is delegated to ``PathFinder`` restricted to ``folders``
748
+ so ``.py`` files, package directories, and parent/submodule binding
749
+ all behave exactly as a normal on-``sys.path`` import would --
750
+ matching what the runtime runner does when it puts the extracted
751
+ bundle folder on ``sys.path``.
752
+ """
753
+
754
+ def __init__(self, folders: Sequence[str]) -> None:
755
+ self.folders = list(dict.fromkeys(folders))
756
+ self.claimed: set[str] = set()
757
+
758
+ def find_spec(
759
+ self,
760
+ fullname: str,
761
+ path: Sequence[str] | None = None,
762
+ target: ModuleType | None = None,
763
+ ) -> importlib.machinery.ModuleSpec | None:
764
+ if "." in fullname:
765
+ return None
766
+ spec = importlib.machinery.PathFinder.find_spec(
767
+ fullname, list(self.folders), target
768
+ )
769
+ if spec is None:
770
+ return None
771
+ self.claimed.add(fullname)
772
+ return spec
773
+
774
+
775
+ @contextmanager
776
+ def _sibling_imports(folders: Sequence[str]) -> Iterator[None]:
777
+ """Activate sibling-fallback resolution while a user file executes,
778
+ then restore ``sys.meta_path`` and drop every name the finder
779
+ resolved from ``sys.modules`` so nothing outlives the register call
780
+ (no cross-function leakage into a shared CLI process, where a later
781
+ function may have a same-named sibling with different contents)."""
782
+ finder = _SiblingFinder(folders)
783
+ prefixes = tuple(os.path.abspath(folder) + os.sep for folder in finder.folders)
784
+ sys.meta_path.append(finder)
785
+ try:
786
+ yield
787
+ finally:
788
+ try:
789
+ sys.meta_path.remove(finder)
790
+ except ValueError:
791
+ pass
792
+ for bare in finder.claimed:
793
+ module = sys.modules.get(bare)
794
+ spec = getattr(module, "__spec__", None)
795
+ locations = [
796
+ os.path.abspath(location)
797
+ for location in (
798
+ ([spec.origin] if spec and spec.origin else [])
799
+ + list(getattr(spec, "submodule_search_locations", None) or [])
800
+ )
801
+ ]
802
+ if locations and all(
803
+ location.startswith(prefixes) for location in locations
804
+ ):
805
+ sys.modules.pop(bare, None)
806
+
807
+
808
+ def _bundle_relative_dotted_name(file_path: str, bundle_root: str) -> str:
809
+ """Dotted module path of ``file_path`` relative to ``bundle_root``.
810
+
811
+ ``<root>/a/b/c/d.py`` under ``<root>`` -> ``"a.b.c.d"``; a file
812
+ directly at the root -> its bare leaf. This is the name the module
813
+ will be importable under at runtime, where the runner puts the
814
+ bundle's ``user_code/`` (a verbatim copy of the tree rooted at
815
+ ``bundle_root``) on ``sys.path`` -- so the recorded ``func`` /
816
+ class ref must match the on-disk nesting. Falls back to the bare
817
+ leaf if the file is not under ``bundle_root``.
818
+ """
819
+ leaf = os.path.splitext(os.path.basename(file_path))[0]
820
+ try:
821
+ relative = os.path.relpath(file_path, bundle_root)
822
+ except ValueError:
823
+ return leaf
824
+ if relative.startswith(os.pardir + os.sep) or relative == os.pardir:
825
+ return leaf
826
+ parts = os.path.splitext(relative)[0].split(os.sep)
827
+ return ".".join(parts)
828
+
829
+
830
+ def _dynamic_import_function_from_path(
831
+ path: str, bundle_root: str | None = None
832
+ ) -> Any: # noqa: C901
833
+ """Import the symbol named by ``path`` (``"/path/to/file.py::name"``).
834
+
835
+ Loads the user's file as a submodule of a synthetic package whose
836
+ ``__path__`` chain mirrors the file's nesting under ``bundle_root``
837
+ (the folder that becomes the bundle's ``user_code/``). The synthetic
838
+ root package name is derived from a sha1 of ``bundle_root``, so:
839
+
840
+ * a user file named after an installed package
841
+ (``databricks.py``, ``snowflake.py``, ...) does NOT shadow the
842
+ installed package -- ``sys.modules["databricks"]`` is left alone.
843
+ * two user files with the same relative path loaded from different
844
+ roots don't collide.
845
+ * relative imports (``from . import helpers``, ``from ..cousin
846
+ import X``) work at every nesting level because the synthetic
847
+ package chain reproduces the on-disk package structure -- exactly
848
+ what the runtime runner reconstructs from ``user_code/`` (PEP 420
849
+ namespace packages, no ``__init__.py`` required).
850
+ * absolute imports of siblings (``import helpers`` /
851
+ ``from helpers import X``) work when the sibling name does not
852
+ collide with an installed package: a ``_SiblingFinder`` appended to
853
+ ``sys.meta_path`` resolves the bare name to the sibling file INSIDE
854
+ the synthetic package (installed-first, so no shadowing). A sibling
855
+ whose name collides with an installed package stays reachable via
856
+ the relative form only.
857
+
858
+ The stamped ``__module__`` (used to serialise ``func`` / user
859
+ classes into ``function_def.json``) is the file's dotted path
860
+ relative to ``bundle_root`` (e.g. ``a.b.c.d``), so the runner can
861
+ re-import it from ``user_code/a/b/c/d.py``. ``sys.path`` is never
862
+ mutated; sibling resolution runs entirely through the synthetic
863
+ package.
864
+
865
+ Not reentrant: it mutates process-global ``sys.modules`` /
866
+ ``sys.meta_path`` for the duration of the import and tears the
867
+ entries down before returning. Sequential calls in one process are
868
+ safe (each fully cleans up first); concurrent calls in the same
869
+ interpreter would race and must be serialised by the caller.
870
+ """
871
+ file_path, function_name = _split_python_function_path(path)
872
+ file_path = os.path.abspath(file_path)
873
+ if not os.path.exists(file_path):
874
+ if not file_path.endswith(".py"):
875
+ raise FileNotFoundError(
876
+ f"File not found: {file_path}. The .py extension may be missing."
877
+ )
878
+ raise FileNotFoundError(f"File not found: {file_path}")
879
+
880
+ folder_path = os.path.dirname(file_path)
881
+ if bundle_root is None:
882
+ bundle_root = folder_path
883
+ bundle_root = os.path.abspath(bundle_root)
884
+ user_module_name = _bundle_relative_dotted_name(file_path, bundle_root)
885
+
886
+ root_digest = hashlib.sha1(bundle_root.encode("utf-8")).hexdigest()[:12]
887
+ root_package = f"td_usr_pkg_{root_digest}"
888
+ module_name = f"{root_package}.{user_module_name}"
889
+
890
+ # Materialise the synthetic package chain root -> ... -> parent of the
891
+ # entry module, each ``__path__`` pointing at the matching on-disk
892
+ # folder, so relative imports at every level resolve on-disk. Every
893
+ # entry we add is removed in the ``finally`` so back-to-back
894
+ # registrations in one CLI process don't see each other's stale tree.
895
+ synthetic_names: list[str] = []
896
+ relative_parts = user_module_name.split(".")
897
+ accumulated = root_package
898
+ current_folder = bundle_root
899
+ for part in relative_parts[:-1]:
900
+ current_folder = os.path.join(current_folder, part)
901
+ accumulated = f"{accumulated}.{part}"
902
+ if accumulated not in sys.modules:
903
+ pkg_spec = importlib.machinery.ModuleSpec(
904
+ accumulated, loader=None, is_package=True
905
+ )
906
+ pkg_spec.submodule_search_locations = [current_folder]
907
+ sys.modules[accumulated] = importlib.util.module_from_spec(pkg_spec)
908
+ synthetic_names.append(accumulated)
909
+ if root_package not in sys.modules:
910
+ root_spec = importlib.machinery.ModuleSpec(
911
+ root_package, loader=None, is_package=True
912
+ )
913
+ root_spec.submodule_search_locations = [bundle_root]
914
+ sys.modules[root_package] = importlib.util.module_from_spec(root_spec)
915
+ synthetic_names.append(root_package)
916
+
917
+ module_spec = importlib.util.spec_from_file_location(module_name, file_path)
918
+ if module_spec is None or module_spec.loader is None:
919
+ raise ImportError(
920
+ f"Failed to load module spec from {file_path}. "
921
+ "The file may not be a valid Python module."
922
+ )
923
+ module = importlib.util.module_from_spec(module_spec)
924
+ if module is None:
925
+ raise ImportError(
926
+ f"Failed to create module from spec for {file_path}. "
927
+ "The file may not be a valid Python module."
928
+ )
929
+ sys.modules[module_name] = module
930
+ synthetic_names.append(module_name)
931
+ try:
932
+ try:
933
+ with _sibling_imports([folder_path, bundle_root]):
934
+ module_spec.loader.exec_module(module)
935
+ except BaseException as e:
936
+ raise ImportError(f"Failed to execute module {file_path}: {str(e)}")
937
+
938
+ try:
939
+ symbol = getattr(module, function_name)
940
+ except AttributeError:
941
+ raise AttributeError(
942
+ f"Function '{function_name}' not found in module {file_path}."
943
+ )
944
+
945
+ # Strip the synthetic ``td_usr_pkg_<sha>.`` prefix off the stamped
946
+ # ``__module__`` so ``info.to_json`` serialises ``func`` (via
947
+ # ``callable_to_str``) and user-defined classes (via
948
+ # ``qualified_class``) under the bundle-relative name -- importable
949
+ # from ``user_code/`` at runtime, not the CLI-process-local
950
+ # synthetic package. This covers the entry module itself and any
951
+ # sibling module a serialised ``func`` / ``Conn`` / ``Src`` was
952
+ # imported from, at any nesting depth.
953
+ #
954
+ # Only objects carrying the synthetic prefix are rewritten: those
955
+ # are the bundle's own modules. Imported builtins / installed types
956
+ # (e.g. ``datetime`` pulled in with ``from datetime import
957
+ # datetime``) never carry the prefix, so they are skipped -- which
958
+ # also avoids ``__module__`` assignment on immutable C types.
959
+ prefix = f"{root_package}."
960
+
961
+ def strip_synthetic_module(obj: Any) -> None:
962
+ name = getattr(obj, "__module__", None)
963
+ if isinstance(name, str) and name.startswith(prefix):
964
+ obj.__module__ = name[len(prefix) :]
965
+
966
+ underlying = getattr(symbol, "func", None)
967
+ strip_synthetic_module(underlying if underlying is not None else symbol)
968
+
969
+ for value in vars(module).values():
970
+ if isinstance(value, type):
971
+ strip_synthetic_module(value)
972
+ return symbol
973
+ finally:
974
+ for name in synthetic_names:
975
+ sys.modules.pop(name, None)
976
+
977
+
978
+ def load_info_and_project_root(
979
+ path: str,
980
+ dir_to_bundle: str | None = None,
981
+ ) -> tuple[Any, str]:
982
+ """Resolve ``--path`` and ``--dir-to-bundle`` flags into the
983
+ ``(info, project_root)`` pair the v2 SDK ``register_function`` /
984
+ ``update_function`` / ``patch_function`` methods expect.
985
+
986
+ ``project_root`` defaults to the directory containing the user's
987
+ file when ``dir_to_bundle`` is unset -- mirrors the legacy
988
+ behaviour where the bundle's project root was implicit.
989
+ """
990
+ file_path = path.split("::", 1)[0]
991
+ project_root = dir_to_bundle or os.path.dirname(os.path.abspath(file_path))
992
+ info = _dynamic_import_function_from_path(path, bundle_root=project_root)
993
+ return info, project_root