glaip-sdk 0.0.5b1__py3-none-any.whl → 0.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glaip_sdk/__init__.py +1 -1
- glaip_sdk/_version.py +42 -19
- glaip_sdk/branding.py +3 -2
- glaip_sdk/cli/commands/__init__.py +1 -1
- glaip_sdk/cli/commands/agents.py +452 -285
- glaip_sdk/cli/commands/configure.py +14 -13
- glaip_sdk/cli/commands/mcps.py +30 -20
- glaip_sdk/cli/commands/models.py +5 -3
- glaip_sdk/cli/commands/tools.py +111 -106
- glaip_sdk/cli/display.py +48 -27
- glaip_sdk/cli/io.py +1 -1
- glaip_sdk/cli/main.py +26 -5
- glaip_sdk/cli/resolution.py +5 -4
- glaip_sdk/cli/utils.py +437 -188
- glaip_sdk/cli/validators.py +7 -2
- glaip_sdk/client/agents.py +276 -153
- glaip_sdk/client/base.py +69 -27
- glaip_sdk/client/tools.py +44 -26
- glaip_sdk/client/validators.py +154 -94
- glaip_sdk/config/constants.py +0 -2
- glaip_sdk/models.py +5 -4
- glaip_sdk/utils/__init__.py +7 -7
- glaip_sdk/utils/client_utils.py +191 -101
- glaip_sdk/utils/display.py +4 -2
- glaip_sdk/utils/general.py +8 -6
- glaip_sdk/utils/import_export.py +58 -25
- glaip_sdk/utils/rendering/formatting.py +12 -6
- glaip_sdk/utils/rendering/models.py +1 -1
- glaip_sdk/utils/rendering/renderer/base.py +523 -332
- glaip_sdk/utils/rendering/renderer/console.py +6 -5
- glaip_sdk/utils/rendering/renderer/debug.py +94 -52
- glaip_sdk/utils/rendering/renderer/stream.py +93 -48
- glaip_sdk/utils/rendering/steps.py +103 -39
- glaip_sdk/utils/rich_utils.py +1 -1
- glaip_sdk/utils/run_renderer.py +1 -1
- glaip_sdk/utils/serialization.py +9 -3
- glaip_sdk/utils/validation.py +2 -2
- glaip_sdk-0.0.7.dist-info/METADATA +183 -0
- glaip_sdk-0.0.7.dist-info/RECORD +55 -0
- glaip_sdk-0.0.5b1.dist-info/METADATA +0 -645
- glaip_sdk-0.0.5b1.dist-info/RECORD +0 -55
- {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.7.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.7.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py
CHANGED
|
@@ -14,6 +14,8 @@ import shlex
|
|
|
14
14
|
import shutil
|
|
15
15
|
import subprocess
|
|
16
16
|
import tempfile
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
17
19
|
from typing import TYPE_CHECKING, Any
|
|
18
20
|
|
|
19
21
|
import click
|
|
@@ -28,18 +30,16 @@ try:
|
|
|
28
30
|
from prompt_toolkit.shortcuts import prompt
|
|
29
31
|
|
|
30
32
|
_HAS_PTK = True
|
|
31
|
-
except Exception:
|
|
33
|
+
except Exception: # pragma: no cover - optional dependency
|
|
32
34
|
_HAS_PTK = False
|
|
33
35
|
|
|
34
36
|
try:
|
|
35
37
|
import questionary
|
|
36
|
-
except Exception:
|
|
38
|
+
except Exception: # pragma: no cover - optional dependency
|
|
37
39
|
questionary = None
|
|
38
40
|
|
|
39
|
-
if TYPE_CHECKING:
|
|
41
|
+
if TYPE_CHECKING: # pragma: no cover - import-only during type checking
|
|
40
42
|
from glaip_sdk import Client
|
|
41
|
-
|
|
42
|
-
from glaip_sdk import Client
|
|
43
43
|
from glaip_sdk.cli.commands.configure import load_config
|
|
44
44
|
from glaip_sdk.rich_components import AIPPanel, AIPTable
|
|
45
45
|
from glaip_sdk.utils import is_uuid
|
|
@@ -52,6 +52,31 @@ from glaip_sdk.utils.rendering.renderer import (
|
|
|
52
52
|
console = Console()
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
# ----------------------------- Context helpers ---------------------------- #
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_ctx_value(ctx: Any, key: str, default: Any = None) -> Any:
|
|
59
|
+
"""Safely resolve a value from click's context object."""
|
|
60
|
+
if ctx is None:
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
obj = getattr(ctx, "obj", None)
|
|
64
|
+
if obj is None:
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
if isinstance(obj, dict):
|
|
68
|
+
return obj.get(key, default)
|
|
69
|
+
|
|
70
|
+
getter = getattr(obj, "get", None)
|
|
71
|
+
if callable(getter):
|
|
72
|
+
try:
|
|
73
|
+
return getter(key, default)
|
|
74
|
+
except TypeError:
|
|
75
|
+
return default
|
|
76
|
+
|
|
77
|
+
return getattr(obj, key, default) if hasattr(obj, key) else default
|
|
78
|
+
|
|
79
|
+
|
|
55
80
|
# ----------------------------- Pager helpers ----------------------------- #
|
|
56
81
|
|
|
57
82
|
|
|
@@ -75,8 +100,8 @@ def _prepare_pager_env(
|
|
|
75
100
|
|
|
76
101
|
|
|
77
102
|
def _render_ansi(
|
|
78
|
-
renderable,
|
|
79
|
-
) -> str:
|
|
103
|
+
renderable: Any,
|
|
104
|
+
) -> str:
|
|
80
105
|
"""Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
|
|
81
106
|
buf = io.StringIO()
|
|
82
107
|
tmp_console = Console(
|
|
@@ -92,7 +117,7 @@ def _render_ansi(
|
|
|
92
117
|
return buf.getvalue()
|
|
93
118
|
|
|
94
119
|
|
|
95
|
-
def _pager_header() -> str:
|
|
120
|
+
def _pager_header() -> str:
|
|
96
121
|
v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
|
|
97
122
|
if v in {"0", "false", "off"}:
|
|
98
123
|
return ""
|
|
@@ -105,15 +130,17 @@ def _pager_header() -> str: # pragma: no cover - terminal UI helper
|
|
|
105
130
|
)
|
|
106
131
|
|
|
107
132
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
) -> bool: # pragma: no cover - spawns real pager
|
|
111
|
-
"""Prefer 'less' with a temp file so stdin remains the TTY."""
|
|
133
|
+
def _should_use_pager() -> bool:
|
|
134
|
+
"""Check if we should attempt to use a system pager."""
|
|
112
135
|
if not (console.is_terminal and os.isatty(1)):
|
|
113
136
|
return False
|
|
114
137
|
if (os.getenv("TERM") or "").lower() == "dumb":
|
|
115
138
|
return False
|
|
139
|
+
return True
|
|
116
140
|
|
|
141
|
+
|
|
142
|
+
def _resolve_pager_command() -> tuple[list[str] | None, str | None]:
|
|
143
|
+
"""Resolve the pager command and path to use."""
|
|
117
144
|
pager_cmd = None
|
|
118
145
|
pager_env = os.getenv("PAGER")
|
|
119
146
|
if pager_env:
|
|
@@ -122,60 +149,92 @@ def _page_with_system_pager(
|
|
|
122
149
|
pager_cmd = parts
|
|
123
150
|
|
|
124
151
|
less_path = shutil.which("less")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
152
|
+
return pager_cmd, less_path
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _run_less_pager(
|
|
156
|
+
pager_cmd: list[str] | None, less_path: str | None, tmp_path: str
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Run less pager with appropriate command and flags."""
|
|
159
|
+
if pager_cmd:
|
|
160
|
+
subprocess.run([*pager_cmd, tmp_path], check=False)
|
|
161
|
+
else:
|
|
162
|
+
flags = os.getenv("LESS", "-RS").split()
|
|
163
|
+
subprocess.run([less_path, *flags, tmp_path], check=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run_more_pager(tmp_path: str) -> None:
|
|
167
|
+
"""Run more pager as fallback."""
|
|
168
|
+
more_path = shutil.which("more")
|
|
169
|
+
if more_path:
|
|
170
|
+
subprocess.run([more_path, tmp_path], check=False)
|
|
171
|
+
else:
|
|
172
|
+
raise FileNotFoundError("more command not found")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _run_pager_with_temp_file(
|
|
176
|
+
pager_runner: Callable[[str], None], ansi_text: str
|
|
177
|
+
) -> bool:
|
|
178
|
+
"""Run a pager using a temporary file containing the content."""
|
|
179
|
+
_prepare_pager_env(clear_on_exit=True)
|
|
180
|
+
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
|
|
181
|
+
tmp.write(_pager_header())
|
|
182
|
+
tmp.write(ansi_text)
|
|
183
|
+
tmp_path = tmp.name
|
|
184
|
+
try:
|
|
185
|
+
pager_runner(tmp_path)
|
|
142
186
|
return True
|
|
187
|
+
except Exception:
|
|
188
|
+
# If pager fails, return False to indicate paging was not successful
|
|
189
|
+
return False
|
|
190
|
+
finally:
|
|
191
|
+
try:
|
|
192
|
+
os.unlink(tmp_path)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _page_with_system_pager(
|
|
198
|
+
ansi_text: str,
|
|
199
|
+
) -> bool: # pragma: no cover - spawns real pager
|
|
200
|
+
"""Prefer 'less' with a temp file so stdin remains the TTY."""
|
|
201
|
+
if not _should_use_pager():
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
pager_cmd, less_path = _resolve_pager_command()
|
|
205
|
+
|
|
206
|
+
if pager_cmd or less_path:
|
|
207
|
+
return _run_pager_with_temp_file(
|
|
208
|
+
lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text
|
|
209
|
+
)
|
|
143
210
|
|
|
144
211
|
# Windows 'more' is poor with ANSI; let Rich fallback handle it
|
|
145
212
|
if platform.system().lower().startswith("win"):
|
|
146
213
|
return False
|
|
147
214
|
|
|
148
215
|
# POSIX 'more' fallback (may or may not honor ANSI)
|
|
149
|
-
|
|
150
|
-
if more_path:
|
|
151
|
-
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
|
|
152
|
-
tmp.write(_pager_header())
|
|
153
|
-
tmp.write(ansi_text)
|
|
154
|
-
tmp_path = tmp.name
|
|
155
|
-
try:
|
|
156
|
-
subprocess.run([more_path, tmp_path], check=False)
|
|
157
|
-
finally:
|
|
158
|
-
try:
|
|
159
|
-
os.unlink(tmp_path)
|
|
160
|
-
except Exception:
|
|
161
|
-
pass
|
|
162
|
-
return True
|
|
216
|
+
return _run_pager_with_temp_file(_run_more_pager, ansi_text)
|
|
163
217
|
|
|
164
|
-
return False
|
|
165
218
|
|
|
219
|
+
def _get_view(ctx: Any) -> str:
|
|
220
|
+
view = get_ctx_value(ctx, "view")
|
|
221
|
+
if view:
|
|
222
|
+
return view
|
|
166
223
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return obj.get("view") or obj.get("format") or "rich"
|
|
224
|
+
fallback = get_ctx_value(ctx, "format")
|
|
225
|
+
return fallback or "rich"
|
|
170
226
|
|
|
171
227
|
|
|
172
228
|
# ----------------------------- Client config ----------------------------- #
|
|
173
229
|
|
|
174
230
|
|
|
175
|
-
def get_client(ctx) -> Client:
|
|
231
|
+
def get_client(ctx: Any) -> Client: # pragma: no cover
|
|
176
232
|
"""Get configured client from context, env, and config file (ctx > env > file)."""
|
|
233
|
+
from glaip_sdk import Client
|
|
234
|
+
|
|
177
235
|
file_config = load_config() or {}
|
|
178
|
-
|
|
236
|
+
context_config_obj = getattr(ctx, "obj", None)
|
|
237
|
+
context_config = context_config_obj or {}
|
|
179
238
|
|
|
180
239
|
raw_timeout = os.getenv("AIP_TIMEOUT", "0") or "0"
|
|
181
240
|
try:
|
|
@@ -269,17 +328,17 @@ def _resolve_mask_fields() -> set[str]:
|
|
|
269
328
|
# ----------------------------- Fuzzy palette ----------------------------- #
|
|
270
329
|
|
|
271
330
|
|
|
272
|
-
def
|
|
273
|
-
"""
|
|
274
|
-
Build a compact text label for the palette.
|
|
275
|
-
Prefers: name • type • framework • [id] (when available)
|
|
276
|
-
Falls back to first 2 columns + [id].
|
|
277
|
-
"""
|
|
331
|
+
def _extract_display_fields(row: dict[str, Any]) -> tuple[str, str, str, str]:
|
|
332
|
+
"""Extract display fields from row data."""
|
|
278
333
|
name = str(row.get("name", "")).strip()
|
|
279
334
|
_id = str(row.get("id", "")).strip()
|
|
280
335
|
type_ = str(row.get("type", "")).strip()
|
|
281
336
|
fw = str(row.get("framework", "")).strip()
|
|
337
|
+
return name, _id, type_, fw
|
|
282
338
|
|
|
339
|
+
|
|
340
|
+
def _build_primary_parts(name: str, type_: str, fw: str) -> list[str]:
|
|
341
|
+
"""Build primary display parts from name, type, and framework."""
|
|
283
342
|
parts = []
|
|
284
343
|
if name:
|
|
285
344
|
parts.append(name)
|
|
@@ -287,18 +346,58 @@ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
|
|
|
287
346
|
parts.append(type_)
|
|
288
347
|
if fw:
|
|
289
348
|
parts.append(fw)
|
|
349
|
+
return parts
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _get_fallback_columns(columns: list[tuple]) -> list[tuple]:
|
|
353
|
+
"""Get first two visible columns for fallback display."""
|
|
354
|
+
return columns[:2]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _is_standard_field(k: str) -> bool:
|
|
358
|
+
"""Check if field is a standard field to skip."""
|
|
359
|
+
return k in ("id", "name", "type", "framework")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _extract_fallback_values(row: dict[str, Any], columns: list[tuple]) -> list[str]:
|
|
363
|
+
"""Extract fallback values from columns."""
|
|
364
|
+
fallback_parts = []
|
|
365
|
+
for k, _hdr, _style, _w in columns:
|
|
366
|
+
if _is_standard_field(k):
|
|
367
|
+
continue
|
|
368
|
+
val = str(row.get(k, "")).strip()
|
|
369
|
+
if val:
|
|
370
|
+
fallback_parts.append(val)
|
|
371
|
+
if len(fallback_parts) >= 2:
|
|
372
|
+
break
|
|
373
|
+
return fallback_parts
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _build_display_parts(
|
|
377
|
+
name: str, _id: str, type_: str, fw: str, row: dict[str, Any], columns: list[tuple]
|
|
378
|
+
) -> list[str]:
|
|
379
|
+
"""Build complete display parts list."""
|
|
380
|
+
parts = _build_primary_parts(name, type_, fw)
|
|
381
|
+
|
|
290
382
|
if not parts:
|
|
291
|
-
#
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
val = str(row.get(k, "")).strip()
|
|
296
|
-
if val:
|
|
297
|
-
parts.append(val)
|
|
298
|
-
if len(parts) >= 2:
|
|
299
|
-
break
|
|
383
|
+
# Use fallback columns
|
|
384
|
+
fallback_columns = _get_fallback_columns(columns)
|
|
385
|
+
parts.extend(_extract_fallback_values(row, fallback_columns))
|
|
386
|
+
|
|
300
387
|
if _id:
|
|
301
388
|
parts.append(f"[{_id}]")
|
|
389
|
+
|
|
390
|
+
return parts
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
|
|
394
|
+
"""
|
|
395
|
+
Build a compact text label for the palette.
|
|
396
|
+
Prefers: name • type • framework • [id] (when available)
|
|
397
|
+
Falls back to first 2 columns + [id].
|
|
398
|
+
"""
|
|
399
|
+
name, _id, type_, fw = _extract_display_fields(row)
|
|
400
|
+
parts = _build_display_parts(name, _id, type_, fw, row, columns)
|
|
302
401
|
return " • ".join(parts) if parts else (_id or "(row)")
|
|
303
402
|
|
|
304
403
|
|
|
@@ -332,10 +431,12 @@ def _build_unique_labels(
|
|
|
332
431
|
class _FuzzyCompleter:
|
|
333
432
|
"""Fuzzy completer for prompt_toolkit."""
|
|
334
433
|
|
|
335
|
-
def __init__(self, words: list[str]):
|
|
434
|
+
def __init__(self, words: list[str]) -> None:
|
|
336
435
|
self.words = words
|
|
337
436
|
|
|
338
|
-
def get_completions(
|
|
437
|
+
def get_completions(
|
|
438
|
+
self, document: Any, _complete_event: Any
|
|
439
|
+
) -> Any: # pragma: no cover
|
|
339
440
|
word = document.get_word_before_cursor()
|
|
340
441
|
if not word:
|
|
341
442
|
return
|
|
@@ -346,7 +447,7 @@ class _FuzzyCompleter:
|
|
|
346
447
|
if self._fuzzy_match(word_lower, label_lower):
|
|
347
448
|
yield Completion(label, start_position=-len(word))
|
|
348
449
|
|
|
349
|
-
def _fuzzy_match(self, search: str, target: str) -> bool:
|
|
450
|
+
def _fuzzy_match(self, search: str, target: str) -> bool: # pragma: no cover
|
|
350
451
|
"""True fuzzy matching: checks if all characters in search appear in order in target."""
|
|
351
452
|
if not search:
|
|
352
453
|
return True
|
|
@@ -404,47 +505,37 @@ def _fuzzy_pick(
|
|
|
404
505
|
complete_in_thread=True,
|
|
405
506
|
complete_while_typing=True,
|
|
406
507
|
)
|
|
407
|
-
except (KeyboardInterrupt, EOFError):
|
|
508
|
+
except (KeyboardInterrupt, EOFError): # pragma: no cover - user cancelled input
|
|
408
509
|
return None
|
|
409
510
|
|
|
410
511
|
return _perform_fuzzy_search(answer, labels, by_label) if answer else None
|
|
411
512
|
|
|
412
513
|
|
|
413
|
-
def
|
|
414
|
-
"""
|
|
415
|
-
Calculate fuzzy match score.
|
|
416
|
-
Higher score = better match.
|
|
417
|
-
Returns -1 if no match possible.
|
|
418
|
-
"""
|
|
514
|
+
def _is_fuzzy_match(search: str, target: str) -> bool:
|
|
515
|
+
"""Check if search string is a fuzzy match for target."""
|
|
419
516
|
if not search:
|
|
420
|
-
return
|
|
517
|
+
return True
|
|
421
518
|
|
|
422
|
-
# Check if it's a fuzzy match first
|
|
423
519
|
search_idx = 0
|
|
424
520
|
for char in target:
|
|
425
521
|
if search_idx < len(search) and search[search_idx] == char:
|
|
426
522
|
search_idx += 1
|
|
427
523
|
if search_idx == len(search):
|
|
428
|
-
|
|
524
|
+
return True
|
|
525
|
+
return False
|
|
429
526
|
|
|
430
|
-
if search_idx < len(search):
|
|
431
|
-
return -1 # Not a fuzzy match
|
|
432
527
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
# 3. Shorter search terms get bonus points
|
|
528
|
+
def _calculate_exact_match_bonus(search: str, target: str) -> int:
|
|
529
|
+
"""Calculate bonus for exact substring matches."""
|
|
530
|
+
return 100 if search.lower() in target.lower() else 0
|
|
437
531
|
|
|
438
|
-
score = 0
|
|
439
532
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
score += 100
|
|
443
|
-
|
|
444
|
-
# Consecutive character bonus
|
|
533
|
+
def _calculate_consecutive_bonus(search: str, target: str) -> int:
|
|
534
|
+
"""Calculate bonus for consecutive character matches."""
|
|
445
535
|
consecutive = 0
|
|
446
536
|
max_consecutive = 0
|
|
447
537
|
search_idx = 0
|
|
538
|
+
|
|
448
539
|
for char in target:
|
|
449
540
|
if search_idx < len(search) and search[search_idx] == char:
|
|
450
541
|
consecutive += 1
|
|
@@ -453,10 +544,31 @@ def _fuzzy_score(search: str, target: str) -> int:
|
|
|
453
544
|
else:
|
|
454
545
|
consecutive = 0
|
|
455
546
|
|
|
456
|
-
|
|
547
|
+
return max_consecutive * 10
|
|
548
|
+
|
|
457
549
|
|
|
458
|
-
|
|
459
|
-
|
|
550
|
+
def _calculate_length_bonus(search: str, target: str) -> int:
|
|
551
|
+
"""Calculate bonus for shorter search terms."""
|
|
552
|
+
return (len(target) - len(search)) * 2
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _fuzzy_score(search: str, target: str) -> int:
|
|
556
|
+
"""
|
|
557
|
+
Calculate fuzzy match score.
|
|
558
|
+
Higher score = better match.
|
|
559
|
+
Returns -1 if no match possible.
|
|
560
|
+
"""
|
|
561
|
+
if not search:
|
|
562
|
+
return 0
|
|
563
|
+
|
|
564
|
+
if not _is_fuzzy_match(search, target):
|
|
565
|
+
return -1 # Not a fuzzy match
|
|
566
|
+
|
|
567
|
+
# Calculate score based on different factors
|
|
568
|
+
score = 0
|
|
569
|
+
score += _calculate_exact_match_bonus(search, target)
|
|
570
|
+
score += _calculate_consecutive_bonus(search, target)
|
|
571
|
+
score += _calculate_length_bonus(search, target)
|
|
460
572
|
|
|
461
573
|
return score
|
|
462
574
|
|
|
@@ -511,12 +623,12 @@ def _render_markdown_output(data: Any) -> None:
|
|
|
511
623
|
|
|
512
624
|
|
|
513
625
|
def output_result(
|
|
514
|
-
ctx,
|
|
626
|
+
ctx: Any,
|
|
515
627
|
result: Any,
|
|
516
628
|
title: str = "Result",
|
|
517
629
|
panel_title: str | None = None,
|
|
518
630
|
success_message: str | None = None,
|
|
519
|
-
):
|
|
631
|
+
) -> None:
|
|
520
632
|
fmt = _get_view(ctx)
|
|
521
633
|
|
|
522
634
|
data = _coerce_result_payload(result)
|
|
@@ -557,7 +669,9 @@ def output_result(
|
|
|
557
669
|
# _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
|
|
558
670
|
|
|
559
671
|
|
|
560
|
-
def _normalise_rows(
|
|
672
|
+
def _normalise_rows(
|
|
673
|
+
items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None
|
|
674
|
+
) -> list[dict[str, Any]]:
|
|
561
675
|
try:
|
|
562
676
|
rows: list[dict[str, Any]] = []
|
|
563
677
|
for item in items:
|
|
@@ -620,7 +734,7 @@ def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
|
|
|
620
734
|
)
|
|
621
735
|
|
|
622
736
|
|
|
623
|
-
def _create_table(columns: list[tuple], title: str):
|
|
737
|
+
def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
|
|
624
738
|
table = AIPTable(title=title, expand=True)
|
|
625
739
|
for _key, header, style, width in columns:
|
|
626
740
|
table.add_column(header, style=style, width=width)
|
|
@@ -651,37 +765,93 @@ def _should_page_output(row_count: int, is_tty: bool) -> bool:
|
|
|
651
765
|
return is_tty
|
|
652
766
|
|
|
653
767
|
|
|
768
|
+
def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
|
|
769
|
+
"""Handle JSON output format."""
|
|
770
|
+
data = (
|
|
771
|
+
rows
|
|
772
|
+
if rows
|
|
773
|
+
else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
|
|
774
|
+
)
|
|
775
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _handle_plain_output(
|
|
779
|
+
rows: list[dict[str, Any]], title: str, columns: list[tuple]
|
|
780
|
+
) -> None:
|
|
781
|
+
"""Handle plain text output format."""
|
|
782
|
+
_render_plain_list(rows, title, columns)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _handle_markdown_output(
|
|
786
|
+
rows: list[dict[str, Any]], title: str, columns: list[tuple]
|
|
787
|
+
) -> None:
|
|
788
|
+
"""Handle markdown output format."""
|
|
789
|
+
_render_markdown_list(rows, title, columns)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _handle_empty_items(title: str) -> None:
|
|
793
|
+
"""Handle case when no items are found."""
|
|
794
|
+
console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _handle_fuzzy_pick_selection(
|
|
798
|
+
rows: list[dict[str, Any]], columns: list[tuple], title: str
|
|
799
|
+
) -> bool:
|
|
800
|
+
"""Handle fuzzy picker selection, returns True if selection was made."""
|
|
801
|
+
picked = (
|
|
802
|
+
_fuzzy_pick(rows, columns, title)
|
|
803
|
+
if console.is_terminal and os.isatty(1)
|
|
804
|
+
else None
|
|
805
|
+
)
|
|
806
|
+
if picked:
|
|
807
|
+
table = _create_table(columns, title)
|
|
808
|
+
table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
|
|
809
|
+
console.print(table)
|
|
810
|
+
console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
|
|
811
|
+
return True
|
|
812
|
+
return False
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _handle_table_output(
|
|
816
|
+
rows: list[dict[str, Any]], columns: list[tuple], title: str
|
|
817
|
+
) -> None:
|
|
818
|
+
"""Handle table output with paging."""
|
|
819
|
+
content = _build_table_group(rows, columns, title)
|
|
820
|
+
if _should_page_output(len(rows), console.is_terminal and os.isatty(1)):
|
|
821
|
+
ansi = _render_ansi(content)
|
|
822
|
+
if not _page_with_system_pager(ansi):
|
|
823
|
+
with console.pager(styles=True):
|
|
824
|
+
console.print(content)
|
|
825
|
+
else:
|
|
826
|
+
console.print(content)
|
|
827
|
+
|
|
828
|
+
|
|
654
829
|
def output_list(
|
|
655
|
-
ctx,
|
|
830
|
+
ctx: Any,
|
|
656
831
|
items: list[Any],
|
|
657
832
|
title: str,
|
|
658
|
-
columns: list[tuple],
|
|
659
|
-
transform_func=None,
|
|
660
|
-
):
|
|
833
|
+
columns: list[tuple[str, str, str, int | None]],
|
|
834
|
+
transform_func: Callable | None = None,
|
|
835
|
+
) -> None:
|
|
661
836
|
"""Display a list with fuzzy palette by default on TTY, Rich table as fallback."""
|
|
662
837
|
fmt = _get_view(ctx)
|
|
663
838
|
rows = _normalise_rows(items, transform_func)
|
|
664
839
|
rows = _mask_rows_if_configured(rows)
|
|
665
840
|
|
|
666
841
|
if fmt == "json":
|
|
667
|
-
|
|
668
|
-
rows
|
|
669
|
-
if rows
|
|
670
|
-
else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
|
|
671
|
-
)
|
|
672
|
-
click.echo(json.dumps(data, indent=2, default=str))
|
|
842
|
+
_handle_json_output(items, rows)
|
|
673
843
|
return
|
|
674
844
|
|
|
675
845
|
if fmt == "plain":
|
|
676
|
-
|
|
846
|
+
_handle_plain_output(rows, title, columns)
|
|
677
847
|
return
|
|
678
848
|
|
|
679
849
|
if fmt == "md":
|
|
680
|
-
|
|
850
|
+
_handle_markdown_output(rows, title, columns)
|
|
681
851
|
return
|
|
682
852
|
|
|
683
853
|
if not items:
|
|
684
|
-
|
|
854
|
+
_handle_empty_items(title)
|
|
685
855
|
return
|
|
686
856
|
|
|
687
857
|
if _should_sort_rows(rows):
|
|
@@ -690,50 +860,33 @@ def output_list(
|
|
|
690
860
|
except Exception:
|
|
691
861
|
pass
|
|
692
862
|
|
|
693
|
-
|
|
694
|
-
_fuzzy_pick(rows, columns, title)
|
|
695
|
-
if console.is_terminal and os.isatty(1)
|
|
696
|
-
else None
|
|
697
|
-
)
|
|
698
|
-
if picked:
|
|
699
|
-
table = _create_table(columns, title)
|
|
700
|
-
table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
|
|
701
|
-
console.print(table)
|
|
702
|
-
console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
|
|
863
|
+
if _handle_fuzzy_pick_selection(rows, columns, title):
|
|
703
864
|
return
|
|
704
865
|
|
|
705
|
-
|
|
706
|
-
if _should_page_output(len(rows), console.is_terminal and os.isatty(1)):
|
|
707
|
-
ansi = _render_ansi(content)
|
|
708
|
-
if not _page_with_system_pager(ansi):
|
|
709
|
-
with console.pager(styles=True):
|
|
710
|
-
console.print(content)
|
|
711
|
-
return
|
|
712
|
-
|
|
713
|
-
console.print(content)
|
|
866
|
+
_handle_table_output(rows, columns, title)
|
|
714
867
|
|
|
715
868
|
|
|
716
869
|
# ------------------------- Output flags decorator ------------------------ #
|
|
717
870
|
|
|
718
871
|
|
|
719
|
-
def _set_view(ctx, _param, value):
|
|
872
|
+
def _set_view(ctx: Any, _param: Any, value: str) -> None:
|
|
720
873
|
if not value:
|
|
721
874
|
return
|
|
722
875
|
ctx.ensure_object(dict)
|
|
723
876
|
ctx.obj["view"] = value
|
|
724
877
|
|
|
725
878
|
|
|
726
|
-
def _set_json(ctx, _param, value):
|
|
879
|
+
def _set_json(ctx: Any, _param: Any, value: bool) -> None:
|
|
727
880
|
if not value:
|
|
728
881
|
return
|
|
729
882
|
ctx.ensure_object(dict)
|
|
730
883
|
ctx.obj["view"] = "json"
|
|
731
884
|
|
|
732
885
|
|
|
733
|
-
def output_flags():
|
|
886
|
+
def output_flags() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
734
887
|
"""Decorator to allow output format flags on any subcommand."""
|
|
735
888
|
|
|
736
|
-
def decorator(f):
|
|
889
|
+
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
737
890
|
f = click.option(
|
|
738
891
|
"--json",
|
|
739
892
|
"json_mode",
|
|
@@ -760,7 +913,7 @@ def output_flags():
|
|
|
760
913
|
# ------------------------- Ambiguity handling --------------------------- #
|
|
761
914
|
|
|
762
915
|
|
|
763
|
-
def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
|
|
916
|
+
def coerce_to_row(item: Any, keys: list[str]) -> dict[str, Any]:
|
|
764
917
|
"""Coerce an item (dict or object) to a row dict with specified keys.
|
|
765
918
|
|
|
766
919
|
Args:
|
|
@@ -781,15 +934,15 @@ def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
|
|
|
781
934
|
|
|
782
935
|
|
|
783
936
|
def build_renderer(
|
|
784
|
-
_ctx,
|
|
937
|
+
_ctx: Any,
|
|
785
938
|
*,
|
|
786
|
-
save_path,
|
|
787
|
-
theme="dark",
|
|
788
|
-
verbose=False,
|
|
789
|
-
_tty_enabled=True,
|
|
790
|
-
live=None,
|
|
791
|
-
snapshots=None,
|
|
792
|
-
):
|
|
939
|
+
save_path: str | os.PathLike[str] | None,
|
|
940
|
+
theme: str = "dark",
|
|
941
|
+
verbose: bool = False,
|
|
942
|
+
_tty_enabled: bool = True,
|
|
943
|
+
live: bool | None = None,
|
|
944
|
+
snapshots: bool | None = None,
|
|
945
|
+
) -> tuple[RichStreamRenderer, Console | CapturingConsole]:
|
|
793
946
|
"""Build renderer and capturing console for CLI commands.
|
|
794
947
|
|
|
795
948
|
Args:
|
|
@@ -901,16 +1054,49 @@ def _fuzzy_pick_for_resources(
|
|
|
901
1054
|
return _perform_fuzzy_search(answer, labels, by_label) if answer else None
|
|
902
1055
|
|
|
903
1056
|
|
|
1057
|
+
def _resolve_by_id(ref: str, get_by_id: Callable) -> Any | None:
|
|
1058
|
+
"""Resolve resource by UUID if ref is a valid UUID."""
|
|
1059
|
+
if is_uuid(ref):
|
|
1060
|
+
return get_by_id(ref)
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _resolve_by_name_multiple_with_select(matches: list[Any], select: int) -> Any:
|
|
1065
|
+
"""Resolve multiple matches using select parameter."""
|
|
1066
|
+
idx = int(select) - 1
|
|
1067
|
+
if not (0 <= idx < len(matches)):
|
|
1068
|
+
raise click.ClickException(f"--select must be 1..{len(matches)}")
|
|
1069
|
+
return matches[idx]
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _resolve_by_name_multiple_fuzzy(
|
|
1073
|
+
ctx: Any, ref: str, matches: list[Any], label: str
|
|
1074
|
+
) -> Any:
|
|
1075
|
+
"""Resolve multiple matches using fuzzy picker interface."""
|
|
1076
|
+
picked = _fuzzy_pick_for_resources(matches, label.lower(), ref)
|
|
1077
|
+
if picked:
|
|
1078
|
+
return picked
|
|
1079
|
+
# Fallback to original ambiguity handler if fuzzy picker fails
|
|
1080
|
+
return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _resolve_by_name_multiple_questionary(
|
|
1084
|
+
ctx: Any, ref: str, matches: list[Any], label: str
|
|
1085
|
+
) -> Any:
|
|
1086
|
+
"""Resolve multiple matches using questionary interface."""
|
|
1087
|
+
return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
|
|
1088
|
+
|
|
1089
|
+
|
|
904
1090
|
def resolve_resource(
|
|
905
|
-
ctx,
|
|
1091
|
+
ctx: Any,
|
|
906
1092
|
ref: str,
|
|
907
1093
|
*,
|
|
908
|
-
get_by_id,
|
|
909
|
-
find_by_name,
|
|
1094
|
+
get_by_id: Callable,
|
|
1095
|
+
find_by_name: Callable,
|
|
910
1096
|
label: str,
|
|
911
1097
|
select: int | None = None,
|
|
912
1098
|
interface_preference: str = "fuzzy",
|
|
913
|
-
):
|
|
1099
|
+
) -> Any | None:
|
|
914
1100
|
"""Resolve resource reference (ID or name) with ambiguity handling.
|
|
915
1101
|
|
|
916
1102
|
Args:
|
|
@@ -925,8 +1111,14 @@ def resolve_resource(
|
|
|
925
1111
|
Returns:
|
|
926
1112
|
Resolved resource object
|
|
927
1113
|
"""
|
|
1114
|
+
# Try to resolve by ID first
|
|
1115
|
+
result = _resolve_by_id(ref, get_by_id)
|
|
1116
|
+
if result is not None:
|
|
1117
|
+
return result
|
|
1118
|
+
|
|
1119
|
+
# If get_by_id returned None, the resource doesn't exist
|
|
928
1120
|
if is_uuid(ref):
|
|
929
|
-
|
|
1121
|
+
raise click.ClickException(f"{label} '{ref}' not found")
|
|
930
1122
|
|
|
931
1123
|
# Find resources by name
|
|
932
1124
|
matches = find_by_name(name=ref)
|
|
@@ -936,59 +1128,66 @@ def resolve_resource(
|
|
|
936
1128
|
if len(matches) == 1:
|
|
937
1129
|
return matches[0]
|
|
938
1130
|
|
|
939
|
-
# Multiple matches
|
|
1131
|
+
# Multiple matches found, handle ambiguity
|
|
940
1132
|
if select:
|
|
941
|
-
|
|
942
|
-
if not (0 <= idx < len(matches)):
|
|
943
|
-
raise click.ClickException(f"--select must be 1..{len(matches)}")
|
|
944
|
-
return matches[idx]
|
|
1133
|
+
return _resolve_by_name_multiple_with_select(matches, select)
|
|
945
1134
|
|
|
946
1135
|
# Choose interface based on preference
|
|
947
1136
|
if interface_preference == "fuzzy":
|
|
948
|
-
|
|
949
|
-
picked = _fuzzy_pick_for_resources(matches, label.lower(), ref)
|
|
950
|
-
if picked:
|
|
951
|
-
return picked
|
|
952
|
-
# Fallback to original ambiguity handler if fuzzy picker fails
|
|
953
|
-
return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
|
|
1137
|
+
return _resolve_by_name_multiple_fuzzy(ctx, ref, matches, label)
|
|
954
1138
|
else:
|
|
955
|
-
|
|
956
|
-
return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
|
|
1139
|
+
return _resolve_by_name_multiple_questionary(ctx, ref, matches, label)
|
|
957
1140
|
|
|
958
1141
|
|
|
959
|
-
def
|
|
960
|
-
|
|
1142
|
+
def _handle_json_view_ambiguity(matches: list[Any]) -> Any:
|
|
1143
|
+
"""Handle ambiguity in JSON view by returning first match."""
|
|
1144
|
+
return matches[0]
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _handle_questionary_ambiguity(
|
|
1148
|
+
resource_type: str, ref: str, matches: list[Any]
|
|
961
1149
|
) -> Any:
|
|
962
|
-
"""Handle
|
|
963
|
-
if
|
|
964
|
-
|
|
1150
|
+
"""Handle ambiguity using questionary interactive interface."""
|
|
1151
|
+
if not (questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1)):
|
|
1152
|
+
raise click.ClickException("Interactive selection not available")
|
|
1153
|
+
|
|
1154
|
+
# Escape special characters for questionary
|
|
1155
|
+
safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
|
|
1156
|
+
safe_ref = ref.replace("{", "{{").replace("}", "}}")
|
|
1157
|
+
|
|
1158
|
+
picked_idx = questionary.select(
|
|
1159
|
+
f"Multiple {safe_resource_type}s match '{safe_ref}'. Pick one:",
|
|
1160
|
+
choices=[
|
|
1161
|
+
questionary.Choice(
|
|
1162
|
+
title=f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — {getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}",
|
|
1163
|
+
value=i,
|
|
1164
|
+
)
|
|
1165
|
+
for i, m in enumerate(matches)
|
|
1166
|
+
],
|
|
1167
|
+
use_indicator=True,
|
|
1168
|
+
qmark="🧭",
|
|
1169
|
+
instruction="↑/↓ to select • Enter to confirm",
|
|
1170
|
+
).ask()
|
|
1171
|
+
if picked_idx is None:
|
|
1172
|
+
raise click.ClickException("Selection cancelled")
|
|
1173
|
+
return matches[picked_idx]
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _handle_fallback_numeric_ambiguity(
|
|
1177
|
+
resource_type: str, ref: str, matches: list[Any]
|
|
1178
|
+
) -> Any:
|
|
1179
|
+
"""Handle ambiguity using numeric prompt fallback."""
|
|
1180
|
+
# Escape special characters for display
|
|
1181
|
+
safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
|
|
1182
|
+
safe_ref = ref.replace("{", "{{").replace("}", "}}")
|
|
965
1183
|
|
|
966
|
-
if questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1):
|
|
967
|
-
picked_idx = questionary.select(
|
|
968
|
-
f"Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s match '{ref.replace('{', '{{').replace('}', '}}')}'. Pick one:",
|
|
969
|
-
choices=[
|
|
970
|
-
questionary.Choice(
|
|
971
|
-
title=f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — {getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}",
|
|
972
|
-
value=i,
|
|
973
|
-
)
|
|
974
|
-
for i, m in enumerate(matches)
|
|
975
|
-
],
|
|
976
|
-
use_indicator=True,
|
|
977
|
-
qmark="🧭",
|
|
978
|
-
instruction="↑/↓ to select • Enter to confirm",
|
|
979
|
-
).ask()
|
|
980
|
-
if picked_idx is None:
|
|
981
|
-
raise click.ClickException("Selection cancelled")
|
|
982
|
-
return matches[picked_idx]
|
|
983
|
-
|
|
984
|
-
# Fallback numeric prompt
|
|
985
1184
|
console.print(
|
|
986
1185
|
Text(
|
|
987
|
-
f"[yellow]Multiple {
|
|
1186
|
+
f"[yellow]Multiple {safe_resource_type}s found matching '{safe_ref}':[/yellow]"
|
|
988
1187
|
)
|
|
989
1188
|
)
|
|
990
1189
|
table = AIPTable(
|
|
991
|
-
title=f"Select {
|
|
1190
|
+
title=f"Select {safe_resource_type.title()}",
|
|
992
1191
|
)
|
|
993
1192
|
table.add_column("#", style="dim", width=3)
|
|
994
1193
|
table.add_column("ID", style="dim", width=36)
|
|
@@ -996,10 +1195,60 @@ def handle_ambiguous_resource(
|
|
|
996
1195
|
for i, m in enumerate(matches, 1):
|
|
997
1196
|
table.add_row(str(i), str(getattr(m, "id", "")), str(getattr(m, "name", "")))
|
|
998
1197
|
console.print(table)
|
|
999
|
-
|
|
1000
|
-
f"Select {
|
|
1001
|
-
type=int,
|
|
1198
|
+
choice_str = click.prompt(
|
|
1199
|
+
f"Select {safe_resource_type} (1-{len(matches)})",
|
|
1002
1200
|
)
|
|
1201
|
+
try:
|
|
1202
|
+
choice = int(choice_str)
|
|
1203
|
+
except ValueError:
|
|
1204
|
+
raise click.ClickException("Invalid selection")
|
|
1003
1205
|
if 1 <= choice <= len(matches):
|
|
1004
1206
|
return matches[choice - 1]
|
|
1005
1207
|
raise click.ClickException("Invalid selection")
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _should_fallback_to_numeric_prompt(exception: Exception) -> bool:
|
|
1211
|
+
"""Determine if we should fallback to numeric prompt for this exception."""
|
|
1212
|
+
# Re-raise cancellation - user explicitly cancelled
|
|
1213
|
+
if "Selection cancelled" in str(exception):
|
|
1214
|
+
return False
|
|
1215
|
+
|
|
1216
|
+
# Fall back to numeric prompt for other exceptions
|
|
1217
|
+
return True
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def handle_ambiguous_resource(
|
|
1221
|
+
ctx: Any, resource_type: str, ref: str, matches: list[Any]
|
|
1222
|
+
) -> Any:
|
|
1223
|
+
"""Handle multiple resource matches gracefully."""
|
|
1224
|
+
if _get_view(ctx) == "json":
|
|
1225
|
+
return _handle_json_view_ambiguity(matches)
|
|
1226
|
+
|
|
1227
|
+
try:
|
|
1228
|
+
return _handle_questionary_ambiguity(resource_type, ref, matches)
|
|
1229
|
+
except Exception as e:
|
|
1230
|
+
if _should_fallback_to_numeric_prompt(e):
|
|
1231
|
+
try:
|
|
1232
|
+
return _handle_fallback_numeric_ambiguity(resource_type, ref, matches)
|
|
1233
|
+
except Exception:
|
|
1234
|
+
# If fallback also fails, re-raise the original exception
|
|
1235
|
+
raise e
|
|
1236
|
+
else:
|
|
1237
|
+
# Re-raise cancellation exceptions
|
|
1238
|
+
raise
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def detect_export_format(file_path: str | Path) -> str:
|
|
1242
|
+
"""Detect export format from file extension.
|
|
1243
|
+
|
|
1244
|
+
Args:
|
|
1245
|
+
file_path: Path to the export file
|
|
1246
|
+
|
|
1247
|
+
Returns:
|
|
1248
|
+
"yaml" if file extension is .yaml or .yml, "json" otherwise
|
|
1249
|
+
"""
|
|
1250
|
+
path = Path(file_path)
|
|
1251
|
+
if path.suffix.lower() in [".yaml", ".yml"]:
|
|
1252
|
+
return "yaml"
|
|
1253
|
+
else:
|
|
1254
|
+
return "json"
|