snowflake-cli 3.10.1__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 (61) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/auth/__init__.py +13 -0
  3. snowflake/cli/_app/auth/errors.py +28 -0
  4. snowflake/cli/_app/auth/oidc_providers.py +393 -0
  5. snowflake/cli/_app/cli_app.py +0 -1
  6. snowflake/cli/_app/constants.py +10 -0
  7. snowflake/cli/_app/printing.py +153 -19
  8. snowflake/cli/_app/snow_connector.py +35 -0
  9. snowflake/cli/_plugins/auth/__init__.py +4 -2
  10. snowflake/cli/_plugins/auth/keypair/commands.py +2 -0
  11. snowflake/cli/_plugins/auth/oidc/__init__.py +13 -0
  12. snowflake/cli/_plugins/auth/oidc/commands.py +47 -0
  13. snowflake/cli/_plugins/auth/oidc/manager.py +66 -0
  14. snowflake/cli/_plugins/auth/oidc/plugin_spec.py +30 -0
  15. snowflake/cli/_plugins/connection/commands.py +37 -3
  16. snowflake/cli/_plugins/dbt/commands.py +37 -8
  17. snowflake/cli/_plugins/dbt/manager.py +144 -12
  18. snowflake/cli/_plugins/dcm/commands.py +102 -136
  19. snowflake/cli/_plugins/dcm/manager.py +136 -89
  20. snowflake/cli/_plugins/logs/commands.py +7 -0
  21. snowflake/cli/_plugins/logs/manager.py +21 -1
  22. snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +3 -1
  23. snowflake/cli/_plugins/notebook/notebook_entity.py +2 -0
  24. snowflake/cli/_plugins/notebook/notebook_entity_model.py +8 -1
  25. snowflake/cli/_plugins/object/command_aliases.py +16 -1
  26. snowflake/cli/_plugins/object/commands.py +27 -1
  27. snowflake/cli/_plugins/object/manager.py +12 -1
  28. snowflake/cli/_plugins/snowpark/commands.py +8 -1
  29. snowflake/cli/_plugins/snowpark/common.py +1 -0
  30. snowflake/cli/_plugins/snowpark/package/anaconda_packages.py +29 -5
  31. snowflake/cli/_plugins/snowpark/package_utils.py +44 -3
  32. snowflake/cli/_plugins/spcs/services/manager.py +5 -4
  33. snowflake/cli/_plugins/sql/lexer/types.py +1 -0
  34. snowflake/cli/_plugins/sql/repl.py +100 -26
  35. snowflake/cli/_plugins/sql/repl_commands.py +607 -0
  36. snowflake/cli/_plugins/sql/statement_reader.py +44 -20
  37. snowflake/cli/api/artifacts/bundle_map.py +32 -2
  38. snowflake/cli/api/artifacts/regex_resolver.py +54 -0
  39. snowflake/cli/api/artifacts/upload.py +5 -1
  40. snowflake/cli/api/artifacts/utils.py +12 -1
  41. snowflake/cli/api/cli_global_context.py +7 -0
  42. snowflake/cli/api/commands/decorators.py +7 -0
  43. snowflake/cli/api/commands/flags.py +26 -0
  44. snowflake/cli/api/config.py +24 -0
  45. snowflake/cli/api/connections.py +1 -0
  46. snowflake/cli/api/console/abc.py +13 -2
  47. snowflake/cli/api/console/console.py +20 -0
  48. snowflake/cli/api/constants.py +9 -0
  49. snowflake/cli/api/entities/utils.py +10 -6
  50. snowflake/cli/api/feature_flags.py +1 -0
  51. snowflake/cli/api/identifiers.py +18 -1
  52. snowflake/cli/api/project/schemas/entities/entities.py +0 -6
  53. snowflake/cli/api/rendering/sql_templates.py +2 -0
  54. snowflake/cli/api/utils/dict_utils.py +42 -1
  55. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/METADATA +15 -41
  56. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/RECORD +59 -52
  57. snowflake/cli/_plugins/dcm/dcm_project_entity_model.py +0 -59
  58. snowflake/cli/_plugins/sql/snowsql_commands.py +0 -331
  59. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/WHEEL +0 -0
  60. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/entry_points.txt +0 -0
  61. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,7 @@ from snowflake.cli._plugins.logs.utils import (
11
11
  )
12
12
  from snowflake.cli._plugins.object.commands import NameArgument, ObjectArgument
13
13
  from snowflake.cli.api.identifiers import FQN
14
+ from snowflake.cli.api.project.util import escape_like_pattern
14
15
  from snowflake.cli.api.sql_execution import SqlExecutionMixin
15
16
  from snowflake.connector.cursor import SnowflakeCursor
16
17
 
@@ -24,6 +25,7 @@ class LogsManager(SqlExecutionMixin):
24
25
  from_time: Optional[datetime] = None,
25
26
  event_table: Optional[str] = None,
26
27
  log_level: Optional[str] = "INFO",
28
+ partial_match: bool = False,
27
29
  ) -> Iterable[List[LogsQueryRow]]:
28
30
  try:
29
31
  previous_end = from_time
@@ -36,6 +38,7 @@ class LogsManager(SqlExecutionMixin):
36
38
  to_time=None,
37
39
  event_table=event_table,
38
40
  log_level=log_level,
41
+ partial_match=partial_match,
39
42
  ).fetchall()
40
43
 
41
44
  if raw_logs:
@@ -56,6 +59,7 @@ class LogsManager(SqlExecutionMixin):
56
59
  to_time: Optional[datetime] = None,
57
60
  event_table: Optional[str] = None,
58
61
  log_level: Optional[str] = "INFO",
62
+ partial_match: bool = False,
59
63
  ) -> Iterable[LogsQueryRow]:
60
64
  """
61
65
  Basic function to get a single batch of logs from the server
@@ -68,6 +72,7 @@ class LogsManager(SqlExecutionMixin):
68
72
  to_time=to_time,
69
73
  event_table=event_table,
70
74
  log_level=log_level,
75
+ partial_match=partial_match,
71
76
  )
72
77
 
73
78
  return sanitize_logs(logs)
@@ -80,10 +85,25 @@ class LogsManager(SqlExecutionMixin):
80
85
  to_time: Optional[datetime] = None,
81
86
  event_table: Optional[str] = None,
82
87
  log_level: Optional[str] = "INFO",
88
+ partial_match: bool = False,
83
89
  ) -> SnowflakeCursor:
84
90
 
85
91
  table = event_table if event_table else "SNOWFLAKE.TELEMETRY.EVENTS"
86
92
 
93
+ # Escape single quotes in object_name to prevent SQL injection
94
+ escaped_object_name = str(object_name).replace("'", "''")
95
+
96
+ # Build the object name condition based on partial_match flag
97
+ if partial_match:
98
+ # Use ILIKE for case-insensitive partial matching with wildcards
99
+ escaped_pattern = escape_like_pattern(
100
+ escaped_object_name, escape_sequence="\\"
101
+ )
102
+ object_condition = f"object_name ILIKE '%{escaped_pattern}%'"
103
+ else:
104
+ # Use exact match (original behavior)
105
+ object_condition = f"object_name = '{escaped_object_name}'"
106
+
87
107
  query = dedent(
88
108
  f"""
89
109
  SELECT
@@ -96,7 +116,7 @@ class LogsManager(SqlExecutionMixin):
96
116
  FROM {table}
97
117
  WHERE record_type = 'LOG'
98
118
  AND (record:severity_text IN ({parse_log_levels_for_query((log_level))}) or record:severity_text is NULL )
99
- AND object_name = '{object_name}'
119
+ AND {object_condition}
100
120
  {get_timestamp_query(from_time, to_time)}
101
121
  ORDER BY timestamp;
102
122
  """
@@ -632,6 +632,7 @@ class SnowflakeSQLFacade:
632
632
  role: str | None = None,
633
633
  database: str | None = None,
634
634
  schema: str | None = None,
635
+ temporary: bool = False,
635
636
  ):
636
637
  """
637
638
  Creates a stage.
@@ -641,13 +642,14 @@ class SnowflakeSQLFacade:
641
642
  @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in.
642
643
  @param [Optional] database: Database to use while running this script, unless the stage name is database-qualified.
643
644
  @param [Optional] schema: Schema to use while running this script, unless the stage name is schema-qualified.
645
+ @param [Optional] temporary: determines if stage should be temporary. Default is false.
644
646
  """
645
647
  fqn = FQN.from_string(name)
646
648
  identifier = to_identifier(fqn.name)
647
649
  database = fqn.database or database
648
650
  schema = fqn.schema or schema
649
651
 
650
- query = f"create stage if not exists {identifier}"
652
+ query = f"create{' temporary' if temporary else ''} stage if not exists {identifier}"
651
653
  if encryption_type:
652
654
  query += f" encryption = (type = '{encryption_type}')"
653
655
  if enable_directory:
@@ -60,6 +60,8 @@ class NotebookEntity(EntityBase[NotebookEntityModel]):
60
60
  query += f"\nCOMPUTE_POOL = '{self.model.compute_pool}'"
61
61
  if self.model.runtime_name:
62
62
  query += f"\nRUNTIME_NAME = '{self.model.runtime_name}'"
63
+ if self.model.runtime_environment_version and not self.model.compute_pool:
64
+ query += f"\nRUNTIME_ENVIRONMENT_VERSION = '{self.model.runtime_environment_version}'"
63
65
 
64
66
  query += (
65
67
  ";\n// Cannot use IDENTIFIER(...)"
@@ -20,6 +20,9 @@ class NotebookEntityModel(EntityModelBaseWithArtifacts):
20
20
  )
21
21
  notebook_file: Path = Field(title="Notebook file")
22
22
  query_warehouse: str = Field(title="Snowflake warehouse to execute the notebook")
23
+ runtime_environment_version: Optional[str] = Field(
24
+ title="Runtime environment version", default=None
25
+ )
23
26
  compute_pool: Optional[str] = Field(
24
27
  title="Compute pool to run the notebook in", default=None
25
28
  )
@@ -37,6 +40,10 @@ class NotebookEntityModel(EntityModelBaseWithArtifacts):
37
40
  def validate_container_setup(self):
38
41
  if self.compute_pool and not self.runtime_name:
39
42
  raise ValueError("compute_pool is specified without runtime_name")
40
- if self.runtime_name and not self.compute_pool and not self:
43
+ if self.runtime_name and not self.compute_pool:
41
44
  raise ValueError("runtime_name is specified without compute_pool")
45
+ if self.compute_pool and self.runtime_environment_version:
46
+ raise ValueError(
47
+ "runtime_environment_version is only applicable for notebooks using warehouse, not compute pool"
48
+ )
42
49
  return self
@@ -22,8 +22,10 @@ from snowflake.cli._plugins.object.commands import (
22
22
  ScopeOption,
23
23
  describe,
24
24
  drop,
25
+ limit_option_,
25
26
  list_,
26
27
  scope_option, # noqa: F401
28
+ terse_option_,
27
29
  )
28
30
  from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
29
31
  from snowflake.cli.api.constants import ObjectType
@@ -37,6 +39,8 @@ def add_object_command_aliases(
37
39
  like_option: Optional[typer.Option],
38
40
  scope_option: Optional[typer.Option],
39
41
  ommit_commands: Optional[List[str]] = None,
42
+ terse_option: Optional[typer.Option] = None,
43
+ limit_option: Optional[typer.Option] = None,
40
44
  ):
41
45
  if ommit_commands is None:
42
46
  ommit_commands = list()
@@ -47,11 +51,18 @@ def add_object_command_aliases(
47
51
  if not scope_option:
48
52
 
49
53
  @app.command("list", requires_connection=True)
50
- def list_cmd(like: str = like_option, **options): # type: ignore
54
+ def list_cmd(
55
+ like: str = like_option, # type: ignore
56
+ terse: bool = terse_option if terse_option else terse_option_(), # type: ignore
57
+ limit: Optional[int] = limit_option if limit_option else limit_option_(), # type: ignore
58
+ **options,
59
+ ):
51
60
  return list_(
52
61
  object_type=object_type.value.cli_name,
53
62
  like=like,
54
63
  scope=ScopeOption.default,
64
+ terse=terse,
65
+ limit=limit,
55
66
  **options,
56
67
  )
57
68
 
@@ -61,12 +72,16 @@ def add_object_command_aliases(
61
72
  def list_cmd(
62
73
  like: str = like_option, # type: ignore
63
74
  scope: Tuple[str, str] = scope_option, # type: ignore
75
+ terse: bool = terse_option if terse_option else terse_option_(), # type: ignore
76
+ limit: Optional[int] = limit_option if limit_option else limit_option_(), # type: ignore
64
77
  **options,
65
78
  ):
66
79
  return list_(
67
80
  object_type=object_type.value.cli_name,
68
81
  like=like,
69
82
  scope=scope,
83
+ terse=terse,
84
+ limit=limit,
70
85
  **options,
71
86
  )
72
87
 
@@ -94,6 +94,24 @@ def scope_option(help_example: str):
94
94
  )
95
95
 
96
96
 
97
+ def terse_option_():
98
+ return typer.Option(
99
+ None,
100
+ "--terse",
101
+ help=f"Returns only a subset of available columns.",
102
+ hidden=True,
103
+ )
104
+
105
+
106
+ def limit_option_():
107
+ return typer.Option(
108
+ None,
109
+ "--limit",
110
+ help=f"Limits the maximum number of rows returned.",
111
+ hidden=True,
112
+ )
113
+
114
+
97
115
  ScopeOption = scope_option(
98
116
  help_example="`list table --in database my_db`. Some object types have specialized scopes (e.g. list service --in compute-pool my_pool)"
99
117
  )
@@ -110,11 +128,19 @@ def list_(
110
128
  object_type: str = ObjectArgument,
111
129
  like: str = LikeOption,
112
130
  scope: Tuple[str, str] = ScopeOption,
131
+ terse: Optional[bool] = terse_option_(),
132
+ limit: Optional[int] = limit_option_(),
113
133
  **options,
114
134
  ):
115
135
  _scope_validate(object_type, scope)
116
136
  return QueryResult(
117
- ObjectManager().show(object_type=object_type, like=like, scope=scope)
137
+ ObjectManager().show(
138
+ object_type=object_type,
139
+ like=like,
140
+ scope=scope,
141
+ terse=terse,
142
+ limit=limit,
143
+ )
118
144
  )
119
145
 
120
146
 
@@ -44,14 +44,25 @@ class ObjectManager(SqlExecutionMixin):
44
44
  object_type: str,
45
45
  like: Optional[str] = None,
46
46
  scope: Union[Tuple[str, str], Tuple[None, None]] = (None, None),
47
+ terse: Optional[bool] = False,
48
+ limit: Optional[int] = None,
47
49
  **kwargs,
48
50
  ) -> SnowflakeCursor:
49
51
  object_name = _get_object_names(object_type).sf_plural_name
50
- query = f"show {object_name}"
52
+ query_parts = ["show"]
53
+
54
+ if terse:
55
+ query_parts.append("terse")
56
+
57
+ query_parts.append(object_name)
58
+ query = " ".join(query_parts)
59
+
51
60
  if like:
52
61
  query += f" like '{like}'"
53
62
  if scope[0] is not None:
54
63
  query += f" in {scope[0].replace('-', ' ')} {scope[1]}"
64
+ if limit is not None:
65
+ query += f" limit {limit}"
55
66
  return self.execute_query(query, **kwargs)
56
67
 
57
68
  def drop(self, *, object_type: str, fqn: FQN) -> SnowflakeCursor:
@@ -448,7 +448,14 @@ def list_(
448
448
  **options,
449
449
  ):
450
450
  """Lists all available procedures or functions."""
451
- return object_list(object_type=object_type.value, like=like, scope=scope, **options)
451
+ return object_list(
452
+ object_type=object_type.value,
453
+ like=like,
454
+ scope=scope,
455
+ terse=None,
456
+ limit=None,
457
+ **options,
458
+ )
452
459
 
453
460
 
454
461
  @app.command("drop", requires_connection=True)
@@ -349,6 +349,7 @@ def user_to_sql_type_mapper(user_provided_type: str) -> str:
349
349
  "FLOAT4",
350
350
  "FLOAT8",
351
351
  ),
352
+ ("DECFLOAT", ""): ("DECFLOAT",),
352
353
  ("TIMESTAMP_NTZ", ""): ("TIMESTAMP_NTZ", "TIMESTAMPNTZ", "DATETIME"),
353
354
  ("TIMESTAMP_LTZ", ""): ("TIMESTAMP_LTZ", "TIMESTAMPLTZ"),
354
355
  ("TIMESTAMP_TZ", ""): ("TIMESTAMP_TZ", "TIMESTAMPTZ"),
@@ -153,13 +153,37 @@ class AnacondaPackages:
153
153
  ):
154
154
  """Saves requirements to a file in format accepted by Snowflake SQL commands."""
155
155
  log.info("Writing requirements into file %s", file_path.path)
156
- formatted_requirements = []
156
+
157
+ # Deduplicate requirements by package name, keeping the first occurrence
158
+ seen_packages = set()
159
+ deduplicated_requirements = []
160
+ duplicate_packages = set()
161
+
157
162
  for requirement in requirements:
158
163
  if requirement.name and requirement.name in self._packages:
159
- snowflake_name = self._packages[requirement.name].snowflake_name
160
- formatted_requirements.append(
161
- snowflake_name + requirement.formatted_specs
162
- )
164
+ if requirement.name in seen_packages:
165
+ duplicate_packages.add(requirement.name)
166
+ log.warning(
167
+ "Duplicate package '%s' found in Anaconda requirements. "
168
+ "Ignoring: %s",
169
+ requirement.name,
170
+ requirement.name_and_version,
171
+ )
172
+ else:
173
+ seen_packages.add(requirement.name)
174
+ deduplicated_requirements.append(requirement)
175
+
176
+ if duplicate_packages:
177
+ log.warning(
178
+ "Found duplicate Anaconda packages: %s. "
179
+ "Consider consolidating package versions in requirements.txt.",
180
+ ", ".join(sorted(duplicate_packages)),
181
+ )
182
+
183
+ formatted_requirements = []
184
+ for requirement in deduplicated_requirements:
185
+ snowflake_name = self._packages[requirement.name].snowflake_name
186
+ formatted_requirements.append(snowflake_name + requirement.formatted_specs)
163
187
 
164
188
  if formatted_requirements:
165
189
  file_path.write_text("\n".join(formatted_requirements))
@@ -255,14 +255,55 @@ def split_downloaded_dependencies(
255
255
  anaconda_packages: AnacondaPackages,
256
256
  skip_version_check: bool,
257
257
  ) -> SplitDownloadedDependenciesResult:
258
- packages_metadata: Dict[str, WheelMetadata] = {
259
- meta.name: meta
258
+ # Build metadata for all downloaded wheels
259
+ all_wheels_metadata = [
260
+ meta
260
261
  for meta in (
261
262
  WheelMetadata.from_wheel(wheel_path)
262
263
  for wheel_path in downloads_dir.glob("*.whl")
263
264
  )
264
265
  if meta is not None
265
- }
266
+ ]
267
+
268
+ # Detect and handle duplicate packages
269
+ packages_metadata: Dict[str, WheelMetadata] = {}
270
+ duplicate_packages = set()
271
+
272
+ for meta in all_wheels_metadata:
273
+ if meta.name in packages_metadata:
274
+ duplicate_packages.add(meta.name)
275
+ log.warning(
276
+ "Multiple versions of package '%s' found in dependencies. "
277
+ "Using: %s, Ignoring: %s",
278
+ meta.name,
279
+ packages_metadata[meta.name].wheel_path.name,
280
+ meta.wheel_path.name,
281
+ )
282
+ else:
283
+ packages_metadata[meta.name] = meta
284
+
285
+ if duplicate_packages:
286
+ log.warning(
287
+ "Found duplicate packages: %s. This may cause deployment issues. "
288
+ "Consider pinning package versions in requirements.txt to avoid conflicts.",
289
+ ", ".join(sorted(duplicate_packages)),
290
+ )
291
+
292
+ # Remove duplicate wheel files to prevent them from being extracted
293
+ for meta in all_wheels_metadata:
294
+ if (
295
+ meta.name in duplicate_packages
296
+ and meta not in packages_metadata.values()
297
+ ):
298
+ try:
299
+ meta.wheel_path.unlink()
300
+ log.debug("Removed duplicate wheel file: %s", meta.wheel_path.name)
301
+ except Exception as e:
302
+ log.warning(
303
+ "Failed to remove duplicate wheel file %s: %s",
304
+ meta.wheel_path.name,
305
+ e,
306
+ )
266
307
  available_in_snowflake_dependencies: Dict = {}
267
308
  unavailable_dependencies: Dict = {}
268
309
 
@@ -313,11 +313,12 @@ class ServiceManager(SqlExecutionMixin):
313
313
 
314
314
  if new_log_records:
315
315
  dedup_log_records = new_logs_only(prev_log_records, new_log_records)
316
- for log in dedup_log_records:
317
- yield filter_log_timestamp(log, include_timestamps)
316
+ if dedup_log_records:
317
+ for log in dedup_log_records:
318
+ yield filter_log_timestamp(log, include_timestamps)
318
319
 
319
- prev_timestamp = dedup_log_records[-1].split(" ", 1)[0]
320
- prev_log_records = dedup_log_records
320
+ prev_timestamp = dedup_log_records[-1].split(" ", 1)[0]
321
+ prev_log_records = dedup_log_records
321
322
 
322
323
  time.sleep(interval_seconds)
323
324
 
@@ -11,6 +11,7 @@ TYPES = (
11
11
  "DATETIME",
12
12
  "DEC",
13
13
  "DECIMAL",
14
+ "DECFLOAT",
14
15
  "DOUBLE",
15
16
  "FLOAT",
16
17
  "INT",
@@ -1,3 +1,4 @@
1
+ from contextlib import contextmanager
1
2
  from logging import getLogger
2
3
  from typing import Iterable
3
4
 
@@ -10,6 +11,7 @@ from prompt_toolkit.lexers import PygmentsLexer
10
11
  from snowflake.cli._app.printing import print_result
11
12
  from snowflake.cli._plugins.sql.lexer import CliLexer, cli_completer
12
13
  from snowflake.cli._plugins.sql.manager import SqlManager
14
+ from snowflake.cli._plugins.sql.repl_commands import detect_command
13
15
  from snowflake.cli.api.cli_global_context import get_cli_context_manager
14
16
  from snowflake.cli.api.console import cli_console
15
17
  from snowflake.cli.api.output.types import MultipleResults, QueryResult
@@ -28,6 +30,21 @@ EXIT_KEYWORDS = ("exit", "quit")
28
30
  log.debug("setting history file to: %s", HISTORY_FILE.as_posix())
29
31
 
30
32
 
33
+ @contextmanager
34
+ def repl_context(repl_instance):
35
+ """Context manager for REPL execution that handles CLI context registration."""
36
+ context_manager = get_cli_context_manager()
37
+ context_manager.is_repl = True
38
+ context_manager.repl_instance = repl_instance
39
+
40
+ try:
41
+ yield
42
+ finally:
43
+ # Clean up REPL context
44
+ context_manager.is_repl = False
45
+ context_manager.repl_instance = None
46
+
47
+
31
48
  class Repl:
32
49
  """Basic REPL implementation for the Snowflake CLI."""
33
50
 
@@ -45,7 +62,6 @@ class Repl:
45
62
  `retain_comments` how to handle comments in queries
46
63
  """
47
64
  super().__init__()
48
- setattr(get_cli_context_manager(), "is_repl", True)
49
65
  self._data = data or {}
50
66
  self._retain_comments = retain_comments
51
67
  self._template_syntax_config = template_syntax_config
@@ -56,6 +72,7 @@ class Repl:
56
72
  self._yes_no_keybindings = self._setup_yn_key_bindings()
57
73
  self._sql_manager = sql_manager
58
74
  self.session = PromptSession(history=self._history)
75
+ self._next_input: str | None = None
59
76
 
60
77
  def _setup_key_bindings(self) -> KeyBindings:
61
78
  """Key bindings for repl. Helps detecting ; at end of buffer."""
@@ -65,22 +82,52 @@ class Repl:
65
82
  def not_searching():
66
83
  return not is_searching()
67
84
 
85
+ @kb.add(Keys.BracketedPaste)
86
+ def _(event):
87
+ """Handle bracketed paste - normalize line endings and strip trailing whitespace."""
88
+ pasted_data = event.data
89
+ # Normalize line endings: \r\n -> \n, \r -> \n
90
+ normalized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
91
+ # Strip trailing whitespace
92
+ cleaned_data = normalized_data.rstrip()
93
+ buffer = event.app.current_buffer
94
+ buffer.insert_text(cleaned_data)
95
+ log.debug(
96
+ "handled paste operation, normalized line endings and stripped trailing whitespace"
97
+ )
98
+
68
99
  @kb.add(Keys.Enter, filter=not_searching)
69
100
  def _(event):
70
- """Handle Enter key press."""
101
+ """Handle Enter key press with intelligent execution logic.
102
+
103
+ Execution priority:
104
+ 1. Exit keywords (exit, quit) - execute immediately
105
+ 2. REPL commands (starting with !) - execute immediately
106
+ 3. SQL with trailing semicolon - execute immediately
107
+ 4. All other input - add new line for multi-line editing
108
+ """
71
109
  buffer = event.app.current_buffer
72
- stripped_buffer = buffer.text.strip()
110
+ buffer_text = buffer.text
111
+ stripped_text = buffer_text.strip()
73
112
 
74
- if stripped_buffer:
113
+ if stripped_text:
75
114
  log.debug("evaluating repl input")
76
115
  cursor_position = buffer.cursor_position
77
- ends_with_semicolon = buffer.text.endswith(";")
116
+ ends_with_semicolon = stripped_text.endswith(";")
117
+ is_command = detect_command(stripped_text) is not None
78
118
 
79
- if stripped_buffer.lower() in EXIT_KEYWORDS:
80
- log.debug("exit keyword detected %r", stripped_buffer)
119
+ meaningful_content_end = len(buffer_text.rstrip())
120
+ cursor_at_meaningful_end = cursor_position >= meaningful_content_end
121
+
122
+ if stripped_text.lower() in EXIT_KEYWORDS:
123
+ log.debug("exit keyword detected %r", stripped_text)
124
+ buffer.validate_and_handle()
125
+
126
+ elif is_command:
127
+ log.debug("command detected, submitting input")
81
128
  buffer.validate_and_handle()
82
129
 
83
- elif ends_with_semicolon and cursor_position >= len(stripped_buffer):
130
+ elif ends_with_semicolon and cursor_at_meaningful_end:
84
131
  log.debug("semicolon detected, submitting input")
85
132
  buffer.validate_and_handle()
86
133
 
@@ -118,16 +165,27 @@ class Repl:
118
165
 
119
166
  return kb
120
167
 
121
- def repl_propmpt(self, msg: str = " > ") -> str:
122
- """Regular repl prompt."""
123
- return self.session.prompt(
124
- msg,
125
- lexer=self._lexer,
126
- completer=self._completer,
127
- multiline=True,
128
- wrap_lines=True,
129
- key_bindings=self._repl_key_bindings,
130
- )
168
+ def repl_prompt(self, msg: str = " > ") -> str:
169
+ """Regular repl prompt with support for pre-filled input.
170
+
171
+ Checks for queued input from commands like !edit and uses it as
172
+ default text in the prompt. The queued input is cleared after use.
173
+ """
174
+ default_text = self._next_input
175
+
176
+ try:
177
+ return self.session.prompt(
178
+ msg,
179
+ lexer=self._lexer,
180
+ completer=self._completer,
181
+ multiline=True,
182
+ wrap_lines=True,
183
+ key_bindings=self._repl_key_bindings,
184
+ default=default_text or "",
185
+ )
186
+ finally:
187
+ if self._next_input == default_text:
188
+ self._next_input = None
131
189
 
132
190
  def yn_prompt(self, msg: str) -> str:
133
191
  """Yes/No prompt."""
@@ -142,7 +200,7 @@ class Repl:
142
200
 
143
201
  @property
144
202
  def _welcome_banner(self) -> str:
145
- return f"Welcome to Snowflake-CLI REPL\nType 'exit' or 'quit' to leave"
203
+ return "Welcome to Snowflake-CLI REPL\nType 'exit' or 'quit' to leave"
146
204
 
147
205
  def _initialize_connection(self):
148
206
  """Early connection for possible fast fail."""
@@ -163,12 +221,13 @@ class Repl:
163
221
  return cursors
164
222
 
165
223
  def run(self):
166
- try:
167
- cli_console.panel(self._welcome_banner)
168
- self._initialize_connection()
169
- self._repl_loop()
170
- except (KeyboardInterrupt, EOFError):
171
- cli_console.message("\n[bold orange_red1]Leaving REPL, bye ...")
224
+ with repl_context(self):
225
+ try:
226
+ cli_console.panel(self._welcome_banner)
227
+ self._initialize_connection()
228
+ self._repl_loop()
229
+ except (KeyboardInterrupt, EOFError):
230
+ cli_console.message("\n[bold orange_red1]Leaving REPL, bye ...")
172
231
 
173
232
  def _repl_loop(self):
174
233
  """Main REPL loop. Handles input and query execution.
@@ -178,7 +237,7 @@ class Repl:
178
237
  """
179
238
  while True:
180
239
  try:
181
- user_input = self.repl_propmpt().strip()
240
+ user_input = self.repl_prompt().strip()
182
241
 
183
242
  if not user_input:
184
243
  continue
@@ -210,6 +269,21 @@ class Repl:
210
269
  except Exception as e:
211
270
  cli_console.warning(f"\nError occurred: {e}")
212
271
 
272
+ def set_next_input(self, text: str) -> None:
273
+ """Set the text that will be used as the next REPL input."""
274
+ self._next_input = text
275
+ log.debug("Next input has been set")
276
+
277
+ @property
278
+ def next_input(self) -> str | None:
279
+ """Get the next input text that will be used in the prompt."""
280
+ return self._next_input
281
+
282
+ @property
283
+ def history(self) -> FileHistory:
284
+ """Get the FileHistory instance used by the REPL."""
285
+ return self._history
286
+
213
287
  def ask_yn(self, question: str) -> bool:
214
288
  """Asks user a Yes/No question."""
215
289
  try: