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.
- snowflake/cli/__about__.py +1 -1
- snowflake/cli/_app/auth/__init__.py +13 -0
- snowflake/cli/_app/auth/errors.py +28 -0
- snowflake/cli/_app/auth/oidc_providers.py +393 -0
- snowflake/cli/_app/cli_app.py +0 -1
- snowflake/cli/_app/constants.py +10 -0
- snowflake/cli/_app/printing.py +153 -19
- snowflake/cli/_app/snow_connector.py +35 -0
- snowflake/cli/_plugins/auth/__init__.py +4 -2
- snowflake/cli/_plugins/auth/keypair/commands.py +2 -0
- snowflake/cli/_plugins/auth/oidc/__init__.py +13 -0
- snowflake/cli/_plugins/auth/oidc/commands.py +47 -0
- snowflake/cli/_plugins/auth/oidc/manager.py +66 -0
- snowflake/cli/_plugins/auth/oidc/plugin_spec.py +30 -0
- snowflake/cli/_plugins/connection/commands.py +37 -3
- snowflake/cli/_plugins/dbt/commands.py +37 -8
- snowflake/cli/_plugins/dbt/manager.py +144 -12
- snowflake/cli/_plugins/dcm/commands.py +102 -136
- snowflake/cli/_plugins/dcm/manager.py +136 -89
- snowflake/cli/_plugins/logs/commands.py +7 -0
- snowflake/cli/_plugins/logs/manager.py +21 -1
- snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +3 -1
- snowflake/cli/_plugins/notebook/notebook_entity.py +2 -0
- snowflake/cli/_plugins/notebook/notebook_entity_model.py +8 -1
- snowflake/cli/_plugins/object/command_aliases.py +16 -1
- snowflake/cli/_plugins/object/commands.py +27 -1
- snowflake/cli/_plugins/object/manager.py +12 -1
- snowflake/cli/_plugins/snowpark/commands.py +8 -1
- snowflake/cli/_plugins/snowpark/common.py +1 -0
- snowflake/cli/_plugins/snowpark/package/anaconda_packages.py +29 -5
- snowflake/cli/_plugins/snowpark/package_utils.py +44 -3
- snowflake/cli/_plugins/spcs/services/manager.py +5 -4
- snowflake/cli/_plugins/sql/lexer/types.py +1 -0
- snowflake/cli/_plugins/sql/repl.py +100 -26
- snowflake/cli/_plugins/sql/repl_commands.py +607 -0
- snowflake/cli/_plugins/sql/statement_reader.py +44 -20
- snowflake/cli/api/artifacts/bundle_map.py +32 -2
- snowflake/cli/api/artifacts/regex_resolver.py +54 -0
- snowflake/cli/api/artifacts/upload.py +5 -1
- snowflake/cli/api/artifacts/utils.py +12 -1
- snowflake/cli/api/cli_global_context.py +7 -0
- snowflake/cli/api/commands/decorators.py +7 -0
- snowflake/cli/api/commands/flags.py +26 -0
- snowflake/cli/api/config.py +24 -0
- snowflake/cli/api/connections.py +1 -0
- snowflake/cli/api/console/abc.py +13 -2
- snowflake/cli/api/console/console.py +20 -0
- snowflake/cli/api/constants.py +9 -0
- snowflake/cli/api/entities/utils.py +10 -6
- snowflake/cli/api/feature_flags.py +1 -0
- snowflake/cli/api/identifiers.py +18 -1
- snowflake/cli/api/project/schemas/entities/entities.py +0 -6
- snowflake/cli/api/rendering/sql_templates.py +2 -0
- snowflake/cli/api/utils/dict_utils.py +42 -1
- {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/METADATA +15 -41
- {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/RECORD +59 -52
- snowflake/cli/_plugins/dcm/dcm_project_entity_model.py +0 -59
- snowflake/cli/_plugins/sql/snowsql_commands.py +0 -331
- {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/WHEEL +0 -0
- {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
317
|
-
|
|
316
|
+
if dedup_log_records:
|
|
317
|
+
for log in dedup_log_records:
|
|
318
|
+
yield filter_log_timestamp(log, include_timestamps)
|
|
318
319
|
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
110
|
+
buffer_text = buffer.text
|
|
111
|
+
stripped_text = buffer_text.strip()
|
|
73
112
|
|
|
74
|
-
if
|
|
113
|
+
if stripped_text:
|
|
75
114
|
log.debug("evaluating repl input")
|
|
76
115
|
cursor_position = buffer.cursor_position
|
|
77
|
-
ends_with_semicolon =
|
|
116
|
+
ends_with_semicolon = stripped_text.endswith(";")
|
|
117
|
+
is_command = detect_command(stripped_text) is not None
|
|
78
118
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
122
|
-
"""Regular repl prompt.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
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:
|