relationalai 1.0.0a3__py3-none-any.whl → 1.0.0a5__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.
Files changed (118) hide show
  1. relationalai/config/config.py +47 -21
  2. relationalai/config/connections/__init__.py +5 -2
  3. relationalai/config/connections/duckdb.py +2 -2
  4. relationalai/config/connections/local.py +31 -0
  5. relationalai/config/connections/snowflake.py +0 -1
  6. relationalai/config/external/raiconfig_converter.py +235 -0
  7. relationalai/config/external/raiconfig_models.py +202 -0
  8. relationalai/config/external/utils.py +31 -0
  9. relationalai/config/shims.py +1 -0
  10. relationalai/semantics/__init__.py +10 -8
  11. relationalai/semantics/backends/sql/sql_compiler.py +1 -4
  12. relationalai/semantics/experimental/__init__.py +0 -0
  13. relationalai/semantics/experimental/builder.py +295 -0
  14. relationalai/semantics/experimental/builtins.py +154 -0
  15. relationalai/semantics/frontend/base.py +67 -42
  16. relationalai/semantics/frontend/core.py +34 -6
  17. relationalai/semantics/frontend/front_compiler.py +209 -37
  18. relationalai/semantics/frontend/pprint.py +6 -2
  19. relationalai/semantics/metamodel/__init__.py +7 -0
  20. relationalai/semantics/metamodel/metamodel.py +2 -0
  21. relationalai/semantics/metamodel/metamodel_analyzer.py +58 -16
  22. relationalai/semantics/metamodel/pprint.py +6 -1
  23. relationalai/semantics/metamodel/rewriter.py +11 -7
  24. relationalai/semantics/metamodel/typer.py +116 -41
  25. relationalai/semantics/reasoners/__init__.py +11 -0
  26. relationalai/semantics/reasoners/graph/__init__.py +35 -0
  27. relationalai/semantics/reasoners/graph/core.py +9028 -0
  28. relationalai/semantics/std/__init__.py +30 -10
  29. relationalai/semantics/std/aggregates.py +641 -12
  30. relationalai/semantics/std/common.py +146 -13
  31. relationalai/semantics/std/constraints.py +71 -1
  32. relationalai/semantics/std/datetime.py +904 -21
  33. relationalai/semantics/std/decimals.py +143 -2
  34. relationalai/semantics/std/floats.py +57 -4
  35. relationalai/semantics/std/integers.py +98 -4
  36. relationalai/semantics/std/math.py +857 -35
  37. relationalai/semantics/std/numbers.py +216 -20
  38. relationalai/semantics/std/re.py +213 -5
  39. relationalai/semantics/std/strings.py +437 -44
  40. relationalai/shims/executor.py +60 -52
  41. relationalai/shims/fixtures.py +85 -0
  42. relationalai/shims/helpers.py +26 -2
  43. relationalai/shims/hoister.py +28 -9
  44. relationalai/shims/mm2v0.py +204 -173
  45. relationalai/tools/cli/cli.py +192 -10
  46. relationalai/tools/cli/components/progress_reader.py +1 -1
  47. relationalai/tools/cli/docs.py +394 -0
  48. relationalai/tools/debugger.py +11 -4
  49. relationalai/tools/qb_debugger.py +435 -0
  50. relationalai/tools/typer_debugger.py +1 -2
  51. relationalai/util/dataclasses.py +3 -5
  52. relationalai/util/docutils.py +1 -2
  53. relationalai/util/error.py +2 -5
  54. relationalai/util/python.py +23 -0
  55. relationalai/util/runtime.py +1 -2
  56. relationalai/util/schema.py +2 -4
  57. relationalai/util/structures.py +4 -2
  58. relationalai/util/tracing.py +8 -2
  59. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/METADATA +8 -5
  60. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/RECORD +118 -95
  61. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/WHEEL +1 -1
  62. v0/relationalai/__init__.py +1 -1
  63. v0/relationalai/clients/client.py +52 -18
  64. v0/relationalai/clients/exec_txn_poller.py +122 -0
  65. v0/relationalai/clients/local.py +23 -8
  66. v0/relationalai/clients/resources/azure/azure.py +36 -11
  67. v0/relationalai/clients/resources/snowflake/__init__.py +4 -4
  68. v0/relationalai/clients/resources/snowflake/cli_resources.py +12 -1
  69. v0/relationalai/clients/resources/snowflake/direct_access_resources.py +124 -100
  70. v0/relationalai/clients/resources/snowflake/engine_service.py +381 -0
  71. v0/relationalai/clients/resources/snowflake/engine_state_handlers.py +35 -29
  72. v0/relationalai/clients/resources/snowflake/error_handlers.py +43 -2
  73. v0/relationalai/clients/resources/snowflake/snowflake.py +277 -179
  74. v0/relationalai/clients/resources/snowflake/use_index_poller.py +8 -0
  75. v0/relationalai/clients/types.py +5 -0
  76. v0/relationalai/errors.py +19 -1
  77. v0/relationalai/semantics/lqp/algorithms.py +173 -0
  78. v0/relationalai/semantics/lqp/builtins.py +199 -2
  79. v0/relationalai/semantics/lqp/executor.py +68 -37
  80. v0/relationalai/semantics/lqp/ir.py +28 -2
  81. v0/relationalai/semantics/lqp/model2lqp.py +215 -45
  82. v0/relationalai/semantics/lqp/passes.py +13 -658
  83. v0/relationalai/semantics/lqp/rewrite/__init__.py +12 -0
  84. v0/relationalai/semantics/lqp/rewrite/algorithm.py +385 -0
  85. v0/relationalai/semantics/lqp/rewrite/constants_to_vars.py +70 -0
  86. v0/relationalai/semantics/lqp/rewrite/deduplicate_vars.py +104 -0
  87. v0/relationalai/semantics/lqp/rewrite/eliminate_data.py +108 -0
  88. v0/relationalai/semantics/lqp/rewrite/extract_keys.py +25 -3
  89. v0/relationalai/semantics/lqp/rewrite/period_math.py +77 -0
  90. v0/relationalai/semantics/lqp/rewrite/quantify_vars.py +65 -31
  91. v0/relationalai/semantics/lqp/rewrite/unify_definitions.py +317 -0
  92. v0/relationalai/semantics/lqp/utils.py +11 -1
  93. v0/relationalai/semantics/lqp/validators.py +14 -1
  94. v0/relationalai/semantics/metamodel/builtins.py +2 -1
  95. v0/relationalai/semantics/metamodel/compiler.py +2 -1
  96. v0/relationalai/semantics/metamodel/dependency.py +12 -3
  97. v0/relationalai/semantics/metamodel/executor.py +11 -1
  98. v0/relationalai/semantics/metamodel/factory.py +2 -2
  99. v0/relationalai/semantics/metamodel/helpers.py +7 -0
  100. v0/relationalai/semantics/metamodel/ir.py +3 -2
  101. v0/relationalai/semantics/metamodel/rewrite/dnf_union_splitter.py +30 -20
  102. v0/relationalai/semantics/metamodel/rewrite/flatten.py +50 -13
  103. v0/relationalai/semantics/metamodel/rewrite/format_outputs.py +9 -3
  104. v0/relationalai/semantics/metamodel/typer/checker.py +6 -4
  105. v0/relationalai/semantics/metamodel/typer/typer.py +4 -3
  106. v0/relationalai/semantics/metamodel/visitor.py +4 -3
  107. v0/relationalai/semantics/reasoners/optimization/solvers_dev.py +1 -1
  108. v0/relationalai/semantics/reasoners/optimization/solvers_pb.py +336 -86
  109. v0/relationalai/semantics/rel/compiler.py +2 -1
  110. v0/relationalai/semantics/rel/executor.py +3 -2
  111. v0/relationalai/semantics/tests/lqp/__init__.py +0 -0
  112. v0/relationalai/semantics/tests/lqp/algorithms.py +345 -0
  113. v0/relationalai/tools/cli.py +339 -186
  114. v0/relationalai/tools/cli_controls.py +216 -67
  115. v0/relationalai/tools/cli_helpers.py +410 -6
  116. v0/relationalai/util/format.py +5 -2
  117. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/entry_points.txt +0 -0
  118. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  #pyright: reportPrivateImportUsage=false
2
2
  from __future__ import annotations
3
3
  import io
4
+ import json
4
5
  import os
5
6
  import re
6
7
  import sys
@@ -9,10 +10,12 @@ import rich
9
10
  import click
10
11
  import functools
11
12
  import pytz
13
+ from typing import NoReturn
12
14
 
13
15
  from v0.relationalai.util.constants import TOP_LEVEL_PROFILE_NAME
16
+ from v0.relationalai.errors import RAIException
14
17
  from rich.table import Table
15
- from typing import Callable, Dict, Any
18
+ from typing import Callable, Dict, Any, List, cast
16
19
  from ..clients import config
17
20
  from click.core import Context
18
21
  from rich.console import Console
@@ -23,9 +26,10 @@ from ..clients.config import ConfigFile
23
26
  from datetime import datetime, timedelta
24
27
  from click.formatting import HelpFormatter
25
28
  from ..clients.client import ResourcesBase
26
- from v0.relationalai.tools.constants import GlobalProfile
29
+ from v0.relationalai.tools.constants import GlobalProfile, SHOW_FULL_TRACES
27
30
  from v0.relationalai.tools.cli_controls import divider
28
31
  from v0.relationalai.util.format import humanized_bytes, humanized_duration
32
+ from InquirerPy.base.control import Choice
29
33
 
30
34
  #--------------------------------------------------
31
35
  # Helpers
@@ -152,6 +156,291 @@ def validate_engine_name(name:str) -> tuple[bool, str|None]:
152
156
  return False, ENGINE_NAME_ERROR
153
157
  return True, None
154
158
 
159
+ #--------------------------------------------------
160
+ # Engine types & selection helpers (Snowflake)
161
+ #--------------------------------------------------
162
+
163
+ def format_state_with_color(state: str) -> str:
164
+ """Format engine state with colors for display."""
165
+ if not state:
166
+ return ""
167
+ state_upper = state.upper()
168
+ if state_upper == "READY":
169
+ return f"[green]{state_upper}[/green]"
170
+ if state_upper == "SUSPENDED":
171
+ return f"[yellow]{state_upper}[/yellow]"
172
+ if state_upper in ("PENDING", "SYNCING", "PROCESSING"):
173
+ return f"[bold yellow]{state_upper}[/bold yellow]"
174
+ if state_upper in ("ABORTED", "QUARANTINED", "GONE"):
175
+ return f"[red]{state_upper}[/red]"
176
+ return state_upper
177
+
178
+ def _get_engine_type_api():
179
+ # Local import to avoid importing snowflake modules for non-snowflake usage
180
+ from v0.relationalai.clients.resources.snowflake import EngineType
181
+ return EngineType
182
+
183
+ def _get_internal_engine_sizes() -> list[str]:
184
+ # Local import to avoid snowflake imports for non-snowflake usage
185
+ from v0.relationalai.clients.resources.snowflake import INTERNAL_ENGINE_SIZES
186
+ return list(INTERNAL_ENGINE_SIZES)
187
+
188
+ def get_engine_type_choices(cfg: config.Config, exclude_types: List[str] | None = None) -> List[Choice]:
189
+ """Get sorted list of engine type choices for interactive selection."""
190
+ EngineType = _get_engine_type_api()
191
+ if exclude_types is None:
192
+ exclude_types = []
193
+ exclude_types_upper = [et.upper() for et in exclude_types]
194
+ engine_types_list = [EngineType.LOGIC, EngineType.ML, EngineType.SOLVER]
195
+ engine_types_list = [et for et in engine_types_list if et.upper() not in exclude_types_upper]
196
+ engine_types_list.sort(key=lambda et: EngineType.get_label(et))
197
+ return [
198
+ Choice(value=et, name=f"{EngineType.get_label(et)}: {EngineType.get_description(et)}")
199
+ for et in engine_types_list
200
+ ]
201
+
202
+ def select_engine_type_interactive(cfg: config.Config) -> str:
203
+ """Show interactive engine type selection and return the selected type."""
204
+ from . import cli_controls as controls
205
+ rich.print("")
206
+ engine_type_choices = get_engine_type_choices(cfg)
207
+ return controls.select("Engine type:", cast("list[str | Choice]", engine_type_choices), None, newline=True)
208
+
209
+ def select_engine_interactive(
210
+ provider: ResourcesBase,
211
+ prompt: str = "Select an engine:",
212
+ engine_name: str | None = None,
213
+ engines: List[Dict[str, Any]] | None = None,
214
+ ) -> tuple[str, str | None] | None:
215
+ """Interactive engine picker returning (name, type)."""
216
+ from . import cli_controls as controls
217
+
218
+ engine_map: Dict[str, tuple[str, str | None]] = {}
219
+
220
+ def get_engines():
221
+ engine_list = engines if engines is not None else provider.list_engines()
222
+ engine_map.clear()
223
+ items: List[str] = []
224
+ EngineType = _get_engine_type_api()
225
+ for engine in engine_list:
226
+ eng_name = engine.get("name", "")
227
+ if engine_name and eng_name.upper() != engine_name.upper():
228
+ continue
229
+ eng_type = engine.get("type", "")
230
+ eng_size = engine.get("size", "")
231
+ if eng_type:
232
+ label = f"{EngineType.get_label(eng_type)} ({eng_type})" if EngineType.is_valid(eng_type) else f"{eng_type} ({eng_type})"
233
+ display = f"{eng_name}, {label}, {eng_size}"
234
+ else:
235
+ display = f"{eng_name}, {eng_size}" if eng_size else eng_name
236
+ engine_map[display] = (eng_name, eng_type or None)
237
+ items.append(display)
238
+ return items
239
+
240
+ # Auto-select when engine_name uniquely identifies an engine
241
+ if engine_name:
242
+ engine_list = engines if engines is not None else provider.list_engines()
243
+ matches = [e for e in engine_list if e.get("name", "").upper() == engine_name.upper()]
244
+ if len(matches) == 1:
245
+ e = matches[0]
246
+ return (e.get("name", ""), e.get("type"))
247
+ if len(matches) == 0:
248
+ return None
249
+
250
+ selected = controls.fuzzy_with_refetch(prompt, "engines", get_engines)
251
+ if not selected or isinstance(selected, Exception):
252
+ return None
253
+ return engine_map.get(selected)
254
+
255
+ def select_engine_with_state_filter(
256
+ provider: ResourcesBase,
257
+ engine_name: str | None,
258
+ engine_type: str | None,
259
+ state_filter: str,
260
+ prompt_no_name: str,
261
+ prompt_with_name: str,
262
+ error_no_engines: str,
263
+ error_no_matching: str,
264
+ ) -> tuple[str, str | None] | None:
265
+ """Select an engine with optional state filtering + optional name filtering."""
266
+ EngineType = _get_engine_type_api()
267
+
268
+ if not engine_name:
269
+ filtered = provider.list_engines(state_filter)
270
+ if not filtered:
271
+ exit_with_error(error_no_engines)
272
+ return select_engine_interactive(provider, prompt_no_name, engines=filtered)
273
+
274
+ # If type provided and valid, return directly
275
+ if engine_type and EngineType.is_valid(engine_type):
276
+ return (engine_name, engine_type)
277
+
278
+ # Filter by name + state; selection handles (name,type)
279
+ filtered = provider.list_engines(state_filter, name=engine_name)
280
+ if not filtered:
281
+ exit_with_error(error_no_matching)
282
+ return select_engine_interactive(provider, prompt_with_name, engine_name=engine_name, engines=filtered)
283
+
284
+ def ensure_engine_type_for_snowflake(
285
+ provider: ResourcesBase,
286
+ engine_name: str,
287
+ engine_type: str | None,
288
+ error_message: str,
289
+ ) -> str:
290
+ """Ensure engine_type is provided and valid; default to LOGIC if omitted."""
291
+ EngineType = _get_engine_type_api()
292
+ # If --type was omitted, default to LOGIC for backwards compatibility
293
+ if engine_type is None:
294
+ return EngineType.LOGIC
295
+ assert isinstance(engine_type, str)
296
+ if engine_type == "" or not EngineType.is_valid(engine_type):
297
+ cfg = get_config()
298
+ if engine_type == "":
299
+ rich.print(f"[yellow]Empty engine type provided for engine '{engine_name}'.")
300
+ else:
301
+ rich.print(f"[yellow]Invalid engine type '{engine_type}' for engine '{engine_name}'.")
302
+ return select_engine_type_interactive(cfg)
303
+ return engine_type
304
+
305
+ def build_engine_operation_messages(
306
+ provider: ResourcesBase,
307
+ engine_name: str,
308
+ engine_type: str | None,
309
+ action: str,
310
+ action_past: str,
311
+ ) -> tuple[str, str]:
312
+ EngineType = _get_engine_type_api()
313
+ if engine_type:
314
+ label = EngineType.get_label(engine_type) if EngineType.is_valid(engine_type) else engine_type
315
+ return (f"{action} {label} engine '{engine_name}'", f"{label} Engine '{engine_name}' {action_past.lower()}")
316
+ return (f"{action} '{engine_name}' engine", f"Engine '{engine_name}' {action_past.lower()}")
317
+
318
+ def prompt_and_validate_engine_name(name: str | None) -> str:
319
+ """Prompt for engine name if missing; validate using ENGINE_NAME_REGEX."""
320
+ from . import cli_controls as controls
321
+ if not name:
322
+ name = controls.prompt(
323
+ "Engine name:",
324
+ name,
325
+ validator=ENGINE_NAME_REGEX.match,
326
+ invalid_message=ENGINE_NAME_ERROR,
327
+ newline=True,
328
+ )
329
+ assert isinstance(name, str)
330
+ return name
331
+
332
+ def validate_auto_suspend_mins(auto_suspend_mins: int | str | None) -> int | None:
333
+ if auto_suspend_mins is None:
334
+ return None
335
+ if isinstance(auto_suspend_mins, int):
336
+ return auto_suspend_mins
337
+ error_msg = f"[yellow]Error: auto_suspend_mins must be an integer instead of {type(auto_suspend_mins)}"
338
+ try:
339
+ return int(auto_suspend_mins)
340
+ except ValueError:
341
+ exit_with_error(error_msg)
342
+ return None
343
+
344
+ def get_engine_type_for_creation(provider: ResourcesBase, cfg: config.Config, engine_type: str | None) -> str | None:
345
+ """Get engine type for engine creation; defaults to LOGIC when omitted."""
346
+ EngineType = _get_engine_type_api()
347
+ if engine_type is None:
348
+ return EngineType.LOGIC
349
+ if engine_type == "" or not EngineType.is_valid(engine_type):
350
+ if engine_type == "":
351
+ rich.print("[yellow]Empty engine type provided.")
352
+ else:
353
+ valid_types_display = ", ".join(EngineType.get_all_types())
354
+ rich.print(f"[yellow]Invalid engine type '{engine_type}'. Valid types: {valid_types_display}")
355
+ return select_engine_type_interactive(cfg)
356
+ return engine_type
357
+
358
+ def get_and_validate_engine_size(
359
+ provider: ResourcesBase,
360
+ cfg: config.Config,
361
+ size: str | None,
362
+ engine_type: str | None = None,
363
+ ) -> str:
364
+ from . import cli_controls as controls
365
+ EngineType = _get_engine_type_api()
366
+ internal_sizes = set(_get_internal_engine_sizes())
367
+
368
+ cloud_provider = provider.get_cloud_provider()
369
+ valid_sizes = provider.get_engine_sizes(cloud_provider)
370
+
371
+ # Engine-type-aware filtering:
372
+ # Internal sizes (XS/S/M/L) are only valid for LOGIC engines (and only on some accounts).
373
+ # For ML/SOLVER, hide them to avoid presenting invalid options.
374
+ if engine_type and EngineType.is_valid(engine_type) and engine_type != EngineType.LOGIC:
375
+ valid_sizes = [s for s in valid_sizes if s not in internal_sizes]
376
+
377
+ # Ask if missing and not in config
378
+ if not size and not cfg.get("engine_size", None):
379
+ rich.print("")
380
+ # This refers to the cloud backing your Snowflake account (AWS/Azure), not the engine "platform".
381
+ size = controls.fuzzy(f"Engine size (Snowflake cloud: {cloud_provider.upper()}):", choices=valid_sizes)
382
+ elif size is None and cfg.get("engine_size", None):
383
+ size = cfg.get("engine_size", None)
384
+ if not isinstance(size, str) or size not in valid_sizes:
385
+ exit_with_error(f"\nInvalid engine size [yellow]{size}[/yellow] provided. Please check your config.\n\nValid sizes: [green]{valid_sizes}[/green]")
386
+ return size
387
+
388
+ def create_engine_with_spinner(
389
+ provider: ResourcesBase,
390
+ engine_name: str,
391
+ engine_size: str,
392
+ engine_type: str | None,
393
+ auto_suspend_mins: int | None,
394
+ ) -> None:
395
+ """Create an engine with appropriate spinner messages and error handling."""
396
+ from .cli_controls import Spinner
397
+
398
+ EngineType = _get_engine_type_api()
399
+ # Build creation message with engine type when available
400
+ if engine_type:
401
+ creation_message = (
402
+ f"Creating {EngineType.get_label(engine_type)} engine '{engine_name}' with size {engine_size}... "
403
+ f"(this may take several minutes)"
404
+ )
405
+ else:
406
+ creation_message = (
407
+ f"Creating engine '{engine_name}' with size {engine_size}... "
408
+ f"(this may take several minutes)"
409
+ )
410
+
411
+ with Spinner(
412
+ creation_message,
413
+ f"Engine '{engine_name}' created!",
414
+ failed_message=None, # We handle error display ourselves below
415
+ ):
416
+ try:
417
+ provider.create_engine(
418
+ engine_name,
419
+ type=engine_type,
420
+ size=engine_size,
421
+ auto_suspend_mins=auto_suspend_mins,
422
+ )
423
+ except Exception as e:
424
+ # Prefer richer error messages when available
425
+ error_msg = None
426
+
427
+ # EngineProvisioningFailed has a format_message() method that provides better error details
428
+ if hasattr(e, "format_message"):
429
+ try:
430
+ error_msg = getattr(e, "format_message")()
431
+ except Exception:
432
+ pass
433
+
434
+ # Try content/message fallbacks
435
+ if not error_msg and hasattr(e, "content"):
436
+ error_msg = getattr(e, "content", None)
437
+ if not error_msg and hasattr(e, "message"):
438
+ error_msg = str(getattr(e, "message", ""))
439
+ if not error_msg:
440
+ error_msg = str(e)
441
+
442
+ raise Exception(error_msg)
443
+
155
444
  #--------------------------------------------------
156
445
  # Tables
157
446
  #--------------------------------------------------
@@ -190,7 +479,7 @@ def format_value(value) -> str:
190
479
  def format_row(key: str, value) -> dict:
191
480
  result = {}
192
481
  result[key] = value
193
- if "status" or "state" in key.lower():
482
+ if "status" in key.lower() or "state" in key.lower():
194
483
  result["style"] = get_color_by_state(value)
195
484
  if key == "query_size" and isinstance(value, int):
196
485
  result[key] = humanized_bytes(value)
@@ -216,6 +505,28 @@ def show_dictionary_table(dict, format_fn:Callable|None=None):
216
505
 
217
506
 
218
507
  class RichGroup(click.Group):
508
+ def invoke(self, ctx: Context) -> Any:
509
+ """Invoke the CLI command, suppressing tracebacks for handled RAIExceptions.
510
+
511
+ Any `RAIException` is expected to already know how to render itself nicely via
512
+ `pprint()`. When such an exception bubbles up to the top-level Click runner,
513
+ Click will otherwise print a full Python traceback, which is noisy for users.
514
+ """
515
+ try:
516
+ return super().invoke(ctx)
517
+ except RAIException as exc:
518
+ # Respect config-based full-trace setting when available.
519
+ try:
520
+ show_full_traces = get_config().get("show_full_traces", SHOW_FULL_TRACES)
521
+ except Exception:
522
+ show_full_traces = SHOW_FULL_TRACES
523
+
524
+ if show_full_traces:
525
+ raise
526
+
527
+ exc.pprint()
528
+ raise click.exceptions.Exit(1) from None
529
+
219
530
  def format_help(self, ctx: Context, formatter: HelpFormatter) -> None:
220
531
  is_latest, current_ver, latest_ver = is_latest_cli_version()
221
532
 
@@ -383,16 +694,109 @@ def show_engines(engines):
383
694
  table = Table(show_header=True, border_style="dim", header_style="bold", box=rich_box.SIMPLE_HEAD)
384
695
  table.add_column("#")
385
696
  table.add_column("Name")
697
+ # Show type column if present
698
+ table.add_column("Type")
386
699
  table.add_column("Size")
387
700
  table.add_column("State")
701
+ table.add_column("Created By")
702
+ table.add_column("Created On")
703
+ EngineType = _get_engine_type_api()
388
704
  for index, engine in enumerate(engines):
389
- table.add_row(f"{index+1}", engine.get("name"), engine.get("size"), engine.get("state"))
705
+ engine_type = engine.get("type", "")
706
+ type_display = EngineType.get_label_with_value(engine_type) if engine_type and EngineType.is_valid(engine_type) else (engine_type or "")
707
+ created_on = format_value(engine.get("created_on"))
708
+ state_display = format_state_with_color(engine.get("state", ""))
709
+ table.add_row(
710
+ f"{index+1}",
711
+ engine.get("name"),
712
+ type_display,
713
+ engine.get("size"),
714
+ state_display,
715
+ engine.get("created_by", ""),
716
+ created_on,
717
+ )
390
718
  rich.print(table)
391
719
 
392
- def exit_with_error(message:str):
720
+
721
+ def show_engine_details(engine: dict[str, Any]) -> None:
722
+ """Print a vertical table of engine details (one field per row)."""
723
+ from v0.relationalai.clients.resources.snowflake import EngineType as _EngineType
724
+
725
+ table = Table(
726
+ show_header=True,
727
+ border_style="dim",
728
+ header_style="bold",
729
+ box=rich_box.SIMPLE_HEAD,
730
+ )
731
+ table.add_column("Field")
732
+ table.add_column("Value", overflow="fold")
733
+
734
+ engine_type_from_db = engine.get("type", "")
735
+ type_display = (
736
+ _EngineType.get_label_with_value(engine_type_from_db)
737
+ if engine_type_from_db and _EngineType.is_valid(engine_type_from_db)
738
+ else engine_type_from_db
739
+ )
740
+
741
+ rows: list[tuple[str, str]] = [
742
+ ("Name", str(engine.get("name", ""))),
743
+ ("Type", str(type_display)),
744
+ ("Size", str(engine.get("size", ""))),
745
+ ("State", str(format_state_with_color(engine.get("state", "")))),
746
+ ("Created By", str(engine.get("created_by", ""))),
747
+ ("Created On", str(format_value(engine.get("created_on")))),
748
+ ]
749
+
750
+ # Optional fields (may not exist on older backends).
751
+ for key, label, formatter in [
752
+ ("version", "Version", lambda v: "" if v is None else str(v)),
753
+ ("updated_on", "Updated On", format_value),
754
+ ("suspends_at", "Suspends At", format_value),
755
+ ]:
756
+ if key in engine:
757
+ try:
758
+ val = engine.get(key)
759
+ rows.append((label, str(formatter(val))))
760
+ except Exception:
761
+ rows.append((label, str(engine.get(key))))
762
+
763
+ # Auto-suspend minutes is represented differently across backends:
764
+ # - list_engines tends to return auto_suspend_mins
765
+ # - get_engine (Snowflake EngineServiceSQL) returns auto_suspend
766
+ if "auto_suspend_mins" in engine or "auto_suspend" in engine:
767
+ auto_suspend_val = engine.get("auto_suspend_mins", engine.get("auto_suspend"))
768
+ rows.append(("Auto Suspend (mins)", "" if auto_suspend_val is None else str(auto_suspend_val)))
769
+
770
+ settings = engine.get("settings")
771
+ if settings in (None, {}, ""):
772
+ settings_str = ""
773
+ elif isinstance(settings, dict):
774
+ settings_str = json.dumps(settings, indent=2, sort_keys=True)
775
+ else:
776
+ settings_str = str(settings)
777
+ rows.append(("Settings", settings_str))
778
+
779
+ for field, value in rows:
780
+ table.add_row(field, value)
781
+
782
+ rich.print(table)
783
+
784
+ def exit_with_error(message: str) -> NoReturn:
393
785
  rich.print(message, file=sys.stderr)
394
786
  exit_with_divider(1)
395
787
 
396
- def exit_with_divider(exit_code:int=0):
788
+ def exit_with_handled_exception(context: str, exc: Exception) -> NoReturn:
789
+ """Exit with a nicely formatted message for handled RAIExceptions.
790
+
791
+ - If `exc` is a RAIException (i.e. produced by an error handler), print its rich
792
+ formatted content via `exc.pprint()`.
793
+ - Otherwise, print a raw one-line error including the context and exception string.
794
+ """
795
+ if isinstance(exc, RAIException):
796
+ exc.pprint()
797
+ sys.exit(1)
798
+ exit_with_error(f"\n\n[yellow]{context}: {exc}")
799
+
800
+ def exit_with_divider(exit_code: int = 0) -> NoReturn:
397
801
  divider()
398
802
  sys.exit(exit_code)
@@ -49,7 +49,7 @@ def humanized_duration(ms: int) -> str:
49
49
  return " ".join(parts[:3])
50
50
 
51
51
 
52
- def format_duration(duration_seconds: float) -> str:
52
+ def format_duration(duration_seconds: float, seconds_decimals: bool = True) -> str:
53
53
  """Format duration in seconds to human-readable format.
54
54
 
55
55
  Args:
@@ -78,7 +78,10 @@ def format_duration(duration_seconds: float) -> str:
78
78
 
79
79
  # For durations less than 1 minute, show seconds with one decimal
80
80
  elif duration_seconds < 60:
81
- return f"{round(duration_seconds, 1)}s"
81
+ if seconds_decimals:
82
+ return f"{round(duration_seconds, 1)}s"
83
+ else:
84
+ return f"{int(duration_seconds)}s"
82
85
 
83
86
  # For durations less than 1 hour, show minutes and seconds
84
87
  elif duration_seconds < 3600: