snowflake-cli 3.11.0__py3-none-any.whl → 3.12.0__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 (38) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/cli_app.py +0 -1
  3. snowflake/cli/_app/printing.py +153 -19
  4. snowflake/cli/_plugins/dbt/commands.py +37 -8
  5. snowflake/cli/_plugins/dbt/manager.py +144 -10
  6. snowflake/cli/_plugins/dcm/commands.py +65 -90
  7. snowflake/cli/_plugins/dcm/manager.py +137 -50
  8. snowflake/cli/_plugins/logs/commands.py +7 -0
  9. snowflake/cli/_plugins/logs/manager.py +21 -1
  10. snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +3 -1
  11. snowflake/cli/_plugins/snowpark/common.py +1 -0
  12. snowflake/cli/_plugins/snowpark/package/anaconda_packages.py +29 -5
  13. snowflake/cli/_plugins/snowpark/package_utils.py +44 -3
  14. snowflake/cli/_plugins/spcs/services/manager.py +5 -4
  15. snowflake/cli/_plugins/sql/lexer/types.py +1 -0
  16. snowflake/cli/_plugins/sql/repl.py +100 -26
  17. snowflake/cli/_plugins/sql/repl_commands.py +607 -0
  18. snowflake/cli/_plugins/sql/statement_reader.py +44 -20
  19. snowflake/cli/api/artifacts/bundle_map.py +32 -2
  20. snowflake/cli/api/artifacts/regex_resolver.py +54 -0
  21. snowflake/cli/api/artifacts/upload.py +5 -1
  22. snowflake/cli/api/artifacts/utils.py +12 -1
  23. snowflake/cli/api/cli_global_context.py +7 -0
  24. snowflake/cli/api/console/abc.py +13 -2
  25. snowflake/cli/api/console/console.py +20 -0
  26. snowflake/cli/api/constants.py +9 -0
  27. snowflake/cli/api/entities/utils.py +10 -6
  28. snowflake/cli/api/feature_flags.py +1 -0
  29. snowflake/cli/api/identifiers.py +18 -1
  30. snowflake/cli/api/project/schemas/entities/entities.py +0 -6
  31. snowflake/cli/api/rendering/sql_templates.py +2 -0
  32. {snowflake_cli-3.11.0.dist-info → snowflake_cli-3.12.0.dist-info}/METADATA +5 -5
  33. {snowflake_cli-3.11.0.dist-info → snowflake_cli-3.12.0.dist-info}/RECORD +36 -36
  34. snowflake/cli/_plugins/dcm/dcm_project_entity_model.py +0 -59
  35. snowflake/cli/_plugins/sql/snowsql_commands.py +0 -331
  36. {snowflake_cli-3.11.0.dist-info → snowflake_cli-3.12.0.dist-info}/WHEEL +0 -0
  37. {snowflake_cli-3.11.0.dist-info → snowflake_cli-3.12.0.dist-info}/entry_points.txt +0 -0
  38. {snowflake_cli-3.11.0.dist-info → snowflake_cli-3.12.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,607 @@
1
+ import abc
2
+ import logging
3
+ import os
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Any, Dict, Generator, Iterable, List, Tuple, Type
9
+ from urllib.parse import urlencode
10
+
11
+ import click
12
+ from snowflake.cli._app.printing import print_result
13
+ from snowflake.cli.api.cli_global_context import get_cli_context
14
+ from snowflake.cli.api.console import cli_console
15
+ from snowflake.cli.api.exceptions import CliError
16
+ from snowflake.cli.api.output.types import CollectionResult, QueryResult
17
+ from snowflake.connector import SnowflakeConnection
18
+
19
+ # Command pattern to detect REPL commands
20
+ COMMAND_PATTERN = re.compile(r"^(![\w]+)(?:\s+(.*))?$")
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ VALID_UUID_RE = re.compile(
25
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
26
+ )
27
+
28
+ # Registry for auto-discovery of commands
29
+ _COMMAND_REGISTRY: Dict[str, Type["ReplCommand"]] = {}
30
+
31
+
32
+ class UnknownCommandError(CliError):
33
+ """Raised when a command pattern matches but no registered command is found."""
34
+
35
+ def __init__(self, command_name: str):
36
+ self.command_name = command_name
37
+ super().__init__(f"Unknown command '{command_name}'")
38
+
39
+
40
+ def register_command(command_name: str | List[str]):
41
+ """Decorator to register a command class."""
42
+
43
+ def decorator(cls):
44
+ command_names = (
45
+ command_name if isinstance(command_name, list) else [command_name]
46
+ )
47
+ for cmd_name in command_names:
48
+ _COMMAND_REGISTRY[cmd_name.lower()] = cls
49
+ return cls
50
+
51
+ return decorator
52
+
53
+
54
+ class ReplCommand(abc.ABC):
55
+ """Base class for REPL commands."""
56
+
57
+ @abc.abstractmethod
58
+ def execute(self, connection: SnowflakeConnection):
59
+ """Executes command and prints the result."""
60
+ ...
61
+
62
+ @classmethod
63
+ @abc.abstractmethod
64
+ def from_args(cls, raw_args, kwargs=None) -> "CompileCommandResult":
65
+ """Parses raw argument string and creates command ready for execution."""
66
+ ...
67
+
68
+ @classmethod
69
+ def _parse_args(cls, raw_args: str) -> tuple[List[str], Dict[str, Any]]:
70
+ """Parse raw argument string into positional args and keyword arguments.
71
+
72
+ This is a helper method that commands can use for standard argument parsing.
73
+ Commands can override this if they need custom parsing logic.
74
+ """
75
+ if not raw_args:
76
+ return [], {}
77
+
78
+ args = []
79
+ kwargs = {}
80
+ cmd_args = raw_args.split()
81
+
82
+ for cmd_arg in cmd_args:
83
+ if "=" not in cmd_arg:
84
+ args.append(cmd_arg)
85
+ else:
86
+ key, val = cmd_arg.split("=", maxsplit=1)
87
+ if key in kwargs:
88
+ raise ValueError(f"Duplicated argument '{key}'")
89
+ kwargs[key] = val
90
+
91
+ return args, kwargs
92
+
93
+
94
+ def _print_result_to_stdout(headers: Iterable[str], rows: Iterable[Iterable[Any]]):
95
+ formatted_rows: Generator[Dict[str, Any], None, None] = (
96
+ {key: value for key, value in zip(headers, row)} for row in rows
97
+ )
98
+ print_result(CollectionResult(formatted_rows))
99
+
100
+
101
+ @dataclass
102
+ class CompileCommandResult:
103
+ command: ReplCommand | None = None
104
+ error_message: str | None = None
105
+
106
+
107
+ @register_command("!queries")
108
+ @dataclass
109
+ class QueriesCommand(ReplCommand):
110
+ """Command to query and display query history."""
111
+
112
+ help_mode: bool = False
113
+ from_current_session: bool = False
114
+ amount: int = 25
115
+ user: str | None = None
116
+ warehouse: str | None = None
117
+ start_timestamp_ms: int | None = None
118
+ end_timestamp_ms: int | None = None
119
+ duration: str | None = None
120
+ stmt_type: str | None = None
121
+ status: str | None = None
122
+
123
+ def execute(self, connection: SnowflakeConnection) -> None:
124
+ if self.help_mode:
125
+ self._execute_help()
126
+ else:
127
+ self._execute_queries(connection)
128
+
129
+ def _execute_help(self):
130
+ headers = ["FILTER", "ARGUMENT", "DEFAULT"]
131
+ filters = [
132
+ ["amount", "integer", "25"],
133
+ ["status", "string", "any"],
134
+ ["warehouse", "string", "any"],
135
+ ["user", "string", "any"],
136
+ [
137
+ "start_date",
138
+ "datetime in ISO format (for example YYYY-MM-DDTHH:mm:ss.sss)",
139
+ "any",
140
+ ],
141
+ [
142
+ "end_date",
143
+ "datetime in ISO format (for example YYYY-MM-DDTHH:mm:ss.sss)",
144
+ "any",
145
+ ],
146
+ ["start", "timestamp in milliseconds (integer)", "any"],
147
+ ["end", "timestamp in milliseconds (integer)", "any"],
148
+ ["type", "string", "any"],
149
+ ["duration", "time in milliseconds", "any"],
150
+ ["session", "No arguments", "any"],
151
+ ]
152
+ _print_result_to_stdout(headers, filters)
153
+
154
+ def _execute_queries(self, connection: SnowflakeConnection) -> None:
155
+ url_parameters = {
156
+ "_dc": f"{time.time()}",
157
+ "includeDDL": "false",
158
+ "max": self.amount,
159
+ }
160
+ if self.user:
161
+ url_parameters["user"] = self.user
162
+ if self.warehouse:
163
+ url_parameters["wh"] = self.warehouse
164
+ if self.start_timestamp_ms:
165
+ url_parameters["start"] = self.start_timestamp_ms
166
+ if self.end_timestamp_ms:
167
+ url_parameters["end"] = self.end_timestamp_ms
168
+ if self.duration:
169
+ url_parameters["min_duration"] = self.duration
170
+ if self.from_current_session:
171
+ url_parameters["session_id"] = connection.session_id
172
+ if self.status:
173
+ url_parameters["subset"] = self.status
174
+ if self.stmt_type:
175
+ url_parameters["stmt_type"] = self.stmt_type
176
+
177
+ url = "/monitoring/queries?" + urlencode(url_parameters)
178
+ ret = connection.rest.request(url=url, method="get", client="rest")
179
+ if ret.get("data") and ret["data"].get("queries"):
180
+ _result: Generator[Tuple[str, str, str, str], None, None] = (
181
+ (
182
+ query["id"],
183
+ query["sqlText"],
184
+ query["state"],
185
+ query["totalDuration"],
186
+ )
187
+ for query in ret["data"]["queries"]
188
+ )
189
+ _print_result_to_stdout(
190
+ ["QUERY ID", "SQL TEXT", "STATUS", "DURATION_MS"], _result
191
+ )
192
+
193
+ @classmethod
194
+ def from_args(cls, raw_args, kwargs=None) -> CompileCommandResult:
195
+ """Parse arguments and create QueriesCommand instance.
196
+
197
+ Supports both calling patterns:
198
+ - New pattern: from_args("amount=3 user=jdoe")
199
+ - Old pattern: from_args(["session"], {"amount": "3"})
200
+ """
201
+ if isinstance(raw_args, str):
202
+ try:
203
+ args, kwargs = cls._parse_args(raw_args)
204
+ except ValueError as e:
205
+ return CompileCommandResult(error_message=str(e))
206
+ else:
207
+ args, kwargs = raw_args, kwargs or {}
208
+
209
+ return cls._from_parsed_args(args, kwargs)
210
+
211
+ @classmethod
212
+ def _from_parsed_args(
213
+ cls, args: List[str], kwargs: Dict[str, Any]
214
+ ) -> CompileCommandResult:
215
+ if "help" in args:
216
+ return CompileCommandResult(command=cls(help_mode=True))
217
+
218
+ # "session" is set by default if no other arguments are provided
219
+ from_current_session = "session" in args or not kwargs
220
+ amount = kwargs.pop("amount", "25")
221
+ if not amount.isdigit():
222
+ return CompileCommandResult(
223
+ error_message=f"Invalid argument passed to 'amount' filter: {amount}"
224
+ )
225
+ user = kwargs.pop("user", None)
226
+ warehouse = kwargs.pop("warehouse", None)
227
+ duration = kwargs.pop("duration", None)
228
+
229
+ start_timestamp_ms = kwargs.pop("start", None)
230
+ if start_timestamp_ms:
231
+ try:
232
+ start_timestamp_ms = int(start_timestamp_ms)
233
+ except ValueError:
234
+ return CompileCommandResult(
235
+ error_message=f"Invalid argument passed to 'start' filter: {start_timestamp_ms}"
236
+ )
237
+ end_timestamp_ms = kwargs.pop("end", None)
238
+ if end_timestamp_ms:
239
+ try:
240
+ end_timestamp_ms = int(end_timestamp_ms)
241
+ except ValueError:
242
+ return CompileCommandResult(
243
+ error_message=f"Invalid argument passed to 'end' filter: {end_timestamp_ms}"
244
+ )
245
+
246
+ start_date = kwargs.pop("start_date", None)
247
+ if start_date:
248
+ if start_timestamp_ms:
249
+ return CompileCommandResult(
250
+ error_message="'start_date' filter cannot be used with 'start' filter"
251
+ )
252
+ try:
253
+ seconds = datetime.fromisoformat(start_date).timestamp()
254
+ start_timestamp_ms = int(seconds * 1000) # convert to milliseconds
255
+ except ValueError:
256
+ return CompileCommandResult(
257
+ error_message=f"Invalid date format passed to 'start_date' filter: {start_date}"
258
+ )
259
+ end_date = kwargs.pop("end_date", None)
260
+ if end_date:
261
+ if end_timestamp_ms:
262
+ return CompileCommandResult(
263
+ error_message="'end_date' filter cannot be used with 'end' filter"
264
+ )
265
+ try:
266
+ seconds = datetime.fromisoformat(end_date).timestamp()
267
+ end_timestamp_ms = int(seconds * 1000) # convert to milliseconds
268
+ except ValueError:
269
+ return CompileCommandResult(
270
+ error_message=f"Invalid date format passed to 'end_date' filter: {end_date}"
271
+ )
272
+
273
+ stmt_type = kwargs.pop("type", None)
274
+ if stmt_type:
275
+ stmt_type = stmt_type.upper()
276
+ if stmt_type not in [
277
+ "ANY",
278
+ "SELECT",
279
+ "INSERT",
280
+ "UPDATE",
281
+ "DELETE",
282
+ "MERGE",
283
+ "MULTI_TABLE_INSERT",
284
+ "COPY",
285
+ "COMMIT",
286
+ "ROLLBACK",
287
+ "BEGIN_TRANSACTION",
288
+ "SHOW",
289
+ "GRANT",
290
+ "CREATE",
291
+ "ALTER",
292
+ ]:
293
+ return CompileCommandResult(
294
+ error_message=f"Invalid argument passed to 'type' filter: {stmt_type}"
295
+ )
296
+
297
+ status = kwargs.pop("status", None)
298
+ if status:
299
+ status = status.upper()
300
+ if status not in [
301
+ "RUNNING",
302
+ "SUCCEEDED",
303
+ "FAILED",
304
+ "BLOCKED",
305
+ "QUEUED",
306
+ "ABORTED",
307
+ ]:
308
+ return CompileCommandResult(
309
+ error_message=f"Invalid argument passed to 'status' filter: {status}"
310
+ )
311
+
312
+ for arg in args:
313
+ if arg.lower() not in ["session", "help"]:
314
+ return CompileCommandResult(
315
+ error_message=f"Invalid argument passed to 'queries' command: {arg}"
316
+ )
317
+
318
+ kwargs_error = _validate_kwargs_empty("queries", kwargs)
319
+ if kwargs_error:
320
+ return CompileCommandResult(error_message=kwargs_error)
321
+
322
+ return CompileCommandResult(
323
+ command=cls(
324
+ help_mode=False,
325
+ from_current_session=from_current_session,
326
+ amount=int(amount),
327
+ user=user,
328
+ warehouse=warehouse,
329
+ start_timestamp_ms=start_timestamp_ms,
330
+ end_timestamp_ms=end_timestamp_ms,
331
+ duration=duration,
332
+ stmt_type=stmt_type,
333
+ status=status,
334
+ )
335
+ )
336
+
337
+
338
+ def _validate_kwargs_empty(command_name: str, kwargs: Dict[str, Any]) -> str | None:
339
+ """Validate that kwargs is empty and return comprehensive error message if not."""
340
+ if not kwargs:
341
+ return None
342
+
343
+ invalid_args = [f"{key}={value}" for key, value in kwargs.items()]
344
+ if len(invalid_args) == 1:
345
+ return f"Invalid argument passed to '{command_name}' command: {invalid_args[0]}"
346
+ else:
347
+ args_str = ", ".join(invalid_args)
348
+ return f"Invalid arguments passed to '{command_name}' command: {args_str}"
349
+
350
+
351
+ def _validate_only_arg_is_query_id(
352
+ command_name: str, args: List[str], kwargs: Dict[str, Any]
353
+ ) -> str | None:
354
+ kwargs_error = _validate_kwargs_empty(command_name, kwargs)
355
+ if kwargs_error:
356
+ return kwargs_error
357
+ if len(args) != 1:
358
+ amount = "Too many" if args else "No"
359
+ return f"{amount} arguments passed to '{command_name}' command. Usage: `!{command_name} <query id>`"
360
+
361
+ qid = args[0]
362
+ if not VALID_UUID_RE.match(qid):
363
+ return f"Invalid query ID passed to '{command_name}' command: {qid}"
364
+
365
+ return None
366
+
367
+
368
+ @register_command("!result")
369
+ @dataclass
370
+ class ResultCommand(ReplCommand):
371
+ """Command to retrieve and display query results by ID."""
372
+
373
+ query_id: str
374
+
375
+ def execute(self, connection: SnowflakeConnection):
376
+ cursor = connection.cursor()
377
+ cursor.query_result(self.query_id)
378
+ print_result(QueryResult(cursor=cursor))
379
+
380
+ @classmethod
381
+ def from_args(cls, raw_args, kwargs=None) -> CompileCommandResult:
382
+ """Parse arguments and create ResultCommand instance.
383
+
384
+ Supports both calling patterns:
385
+ - New pattern: from_args("00000000-0000-0000-0000-000000000000")
386
+ - Old pattern: from_args(["query_id"], {})
387
+ """
388
+ if isinstance(raw_args, str):
389
+ try:
390
+ args, kwargs = cls._parse_args(raw_args)
391
+ except ValueError as e:
392
+ return CompileCommandResult(error_message=str(e))
393
+ else:
394
+ args, kwargs = raw_args, kwargs or {}
395
+
396
+ return cls._from_parsed_args(args, kwargs)
397
+
398
+ @classmethod
399
+ def _from_parsed_args(cls, args, kwargs) -> CompileCommandResult:
400
+ error_msg = _validate_only_arg_is_query_id("result", args, kwargs)
401
+ if error_msg:
402
+ return CompileCommandResult(error_message=error_msg)
403
+ return CompileCommandResult(command=cls(args[0]))
404
+
405
+
406
+ @register_command("!abort")
407
+ @dataclass
408
+ class AbortCommand(ReplCommand):
409
+ """Command to abort a running query by ID."""
410
+
411
+ query_id: str
412
+
413
+ def execute(self, connection: SnowflakeConnection):
414
+ cursor = connection.cursor()
415
+ cursor.execute("SELECT SYSTEM$CANCEL_QUERY(%s)", (self.query_id,))
416
+ print_result(QueryResult(cursor=cursor))
417
+
418
+ @classmethod
419
+ def from_args(cls, raw_args, kwargs=None) -> CompileCommandResult:
420
+ """Parse arguments and create AbortCommand instance.
421
+
422
+ Supports both calling patterns:
423
+ - New pattern: from_args("00000000-0000-0000-0000-000000000000")
424
+ - Old pattern: from_args(["query_id"], {})
425
+ """
426
+ if isinstance(raw_args, str):
427
+ try:
428
+ args, kwargs = cls._parse_args(raw_args)
429
+ except ValueError as e:
430
+ return CompileCommandResult(error_message=str(e))
431
+ else:
432
+ args, kwargs = raw_args, kwargs or {}
433
+
434
+ return cls._from_parsed_args(args, kwargs)
435
+
436
+ @classmethod
437
+ def _from_parsed_args(cls, args, kwargs) -> CompileCommandResult:
438
+ error_msg = _validate_only_arg_is_query_id("abort", args, kwargs)
439
+ if error_msg:
440
+ return CompileCommandResult(error_message=error_msg)
441
+ return CompileCommandResult(command=cls(args[0]))
442
+
443
+
444
+ @register_command("!edit")
445
+ @dataclass
446
+ class EditCommand(ReplCommand):
447
+ """Command to edit SQL statements using an external editor."""
448
+
449
+ sql_content: str = ""
450
+
451
+ def execute(self, connection: SnowflakeConnection):
452
+ """Execute the edit command.
453
+
454
+ Flow:
455
+ 1. Validate REPL mode and EDITOR environment variable
456
+ 2. Get content to edit (provided args or last command from history)
457
+ 3. Open editor with content using click.edit()
458
+ 4. Inject edited content back into REPL prompt for execution
459
+ """
460
+ if not get_cli_context().is_repl:
461
+ raise CliError("The edit command can only be used in interactive mode.")
462
+
463
+ editor = os.environ.get("EDITOR")
464
+ if not editor:
465
+ raise CliError(
466
+ "No editor is set. Please set the EDITOR environment variable."
467
+ )
468
+
469
+ content_to_edit = self.sql_content
470
+ if not content_to_edit:
471
+ content_to_edit = self._get_last_command_from_history()
472
+
473
+ edited_content = click.edit(
474
+ text=content_to_edit, editor=editor, extension=".sql", require_save=False
475
+ )
476
+
477
+ if edited_content is None:
478
+ log.debug("Editor closed without changes")
479
+ return
480
+
481
+ edited_content = edited_content.strip()
482
+
483
+ if edited_content:
484
+ log.debug("Editor returned content, length: %d", len(edited_content))
485
+
486
+ if repl := get_cli_context().repl:
487
+ repl.set_next_input(edited_content)
488
+ else:
489
+ log.warning("REPL instance not found in context")
490
+
491
+ cli_console.message(
492
+ "[green]✓ Edited SQL loaded into prompt. Modify as needed or press Enter to execute.[/green]"
493
+ )
494
+ else:
495
+ log.debug("Editor returned empty content")
496
+ cli_console.message("[yellow]Editor closed with no content.[/yellow]")
497
+
498
+ def _get_last_command_from_history(self) -> str:
499
+ """Get the last command from the REPL history."""
500
+ repl = get_cli_context().repl
501
+ if repl and repl.history:
502
+ history_entries = list(repl.history.get_strings())
503
+ for entry in reversed(history_entries):
504
+ entry = entry.strip()
505
+ is_repl_command = entry and entry.startswith("!")
506
+ if not is_repl_command:
507
+ return entry
508
+
509
+ return ""
510
+
511
+ @classmethod
512
+ def from_args(cls, raw_args, kwargs=None) -> CompileCommandResult:
513
+ """Parse arguments and create EditCommand instance.
514
+
515
+ Supports both calling patterns:
516
+ - New pattern: from_args("SELECT * FROM table WHERE id = 1")
517
+ - Old pattern: from_args(["SELECT", "*", "FROM", "table"], {})
518
+ """
519
+ if isinstance(raw_args, str):
520
+ try:
521
+ args, kwargs = cls._parse_args(raw_args)
522
+ except ValueError as e:
523
+ return CompileCommandResult(error_message=str(e))
524
+ else:
525
+ args, kwargs = raw_args, kwargs or {}
526
+
527
+ return cls._from_parsed_args(args, kwargs)
528
+
529
+ @classmethod
530
+ def _parse_args(cls, raw_args: str) -> tuple[List[str], Dict[str, Any]]:
531
+ """Parse raw argument string for EditCommand.
532
+
533
+ Unlike other commands, EditCommand treats all arguments as SQL content,
534
+ not as key-value pairs. This allows SQL with equals signs to work correctly.
535
+ """
536
+ return [raw_args] if raw_args else [], {}
537
+
538
+ @classmethod
539
+ def _from_parsed_args(cls, args, kwargs) -> CompileCommandResult:
540
+ """Create EditCommand from parsed arguments.
541
+
542
+ Since EditCommand's custom _parse_args always returns empty kwargs,
543
+ we only need to handle the args to reconstruct the SQL content.
544
+ """
545
+ sql_content = " ".join(args) if args else ""
546
+ return CompileCommandResult(command=cls(sql_content=sql_content))
547
+
548
+
549
+ def detect_command(input_text: str) -> tuple[str, str] | None:
550
+ """Detect if input text matches a command pattern.
551
+
552
+ Returns:
553
+ tuple[command_name, raw_args] if command pattern is detected, None otherwise
554
+ """
555
+ match = COMMAND_PATTERN.match(input_text.strip())
556
+ if match:
557
+ command_name = match.group(1) # The !command part
558
+ raw_args = match.group(2) or "" # Everything after the command
559
+ return command_name, raw_args
560
+ return None
561
+
562
+
563
+ def is_registered_command(command_name: str) -> bool:
564
+ """Check if a command name is registered."""
565
+ return command_name.lower() in _COMMAND_REGISTRY
566
+
567
+
568
+ def get_command_class(command_name: str) -> Type[ReplCommand] | None:
569
+ """Get the command class for a given command name."""
570
+ return _COMMAND_REGISTRY.get(command_name.lower())
571
+
572
+
573
+ def compile_repl_command(input_text: str) -> CompileCommandResult:
574
+ """Detect and compile a REPL command from input text.
575
+
576
+ This function handles:
577
+ 1. Command pattern detection
578
+ 2. Command registration checking
579
+ 3. Delegation to command-specific parsing
580
+ """
581
+ # Step 1: Detect if this is a command
582
+ detection_result = detect_command(input_text)
583
+ if not detection_result:
584
+ log.info("Input does not match command pattern")
585
+ return CompileCommandResult(error_message="Not a command")
586
+
587
+ command_name, raw_args = detection_result
588
+ log.debug("Detected command: %s", command_name)
589
+
590
+ # Step 2: Check if command is registered
591
+ if not is_registered_command(command_name):
592
+ log.info("Unknown command: %s", command_name)
593
+ raise UnknownCommandError(command_name)
594
+
595
+ # Step 3: Get command class and delegate parsing
596
+ command_class = get_command_class(command_name)
597
+ if command_class is None:
598
+ # This should never happen since we already checked registration
599
+ raise RuntimeError(f"Command class not found for {command_name}")
600
+
601
+ log.debug("Found command class: %s", command_class.__name__)
602
+ return command_class.from_args(raw_args)
603
+
604
+
605
+ def get_available_commands() -> List[str]:
606
+ """Returns a list of all registered command names."""
607
+ return list(_COMMAND_REGISTRY.keys())