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,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
|