snowflake-cli 3.14.0__py3-none-any.whl → 3.15.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 (30) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/dev/docs/project_definition_generate_json_schema.py +2 -2
  3. snowflake/cli/_app/printing.py +14 -12
  4. snowflake/cli/_app/snow_connector.py +59 -9
  5. snowflake/cli/_plugins/dbt/commands.py +37 -7
  6. snowflake/cli/_plugins/dbt/manager.py +81 -53
  7. snowflake/cli/_plugins/dcm/commands.py +38 -0
  8. snowflake/cli/_plugins/dcm/manager.py +8 -0
  9. snowflake/cli/_plugins/dcm/reporters.py +462 -0
  10. snowflake/cli/_plugins/dcm/styles.py +26 -0
  11. snowflake/cli/_plugins/dcm/utils.py +88 -0
  12. snowflake/cli/_plugins/git/manager.py +24 -22
  13. snowflake/cli/_plugins/object/command_aliases.py +7 -1
  14. snowflake/cli/_plugins/object/commands.py +12 -2
  15. snowflake/cli/_plugins/object/manager.py +7 -2
  16. snowflake/cli/_plugins/snowpark/commands.py +8 -1
  17. snowflake/cli/_plugins/streamlit/streamlit_entity.py +8 -1
  18. snowflake/cli/api/commands/decorators.py +1 -1
  19. snowflake/cli/api/commands/flags.py +30 -5
  20. snowflake/cli/api/console/abc.py +7 -3
  21. snowflake/cli/api/console/console.py +10 -3
  22. snowflake/cli/api/exceptions.py +1 -1
  23. snowflake/cli/api/feature_flags.py +1 -0
  24. snowflake/cli/api/output/types.py +6 -0
  25. snowflake/cli/api/utils/types.py +20 -1
  26. {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/METADATA +9 -4
  27. {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/RECORD +30 -27
  28. {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/WHEEL +0 -0
  29. {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/entry_points.txt +0 -0
  30. {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,462 @@
1
+ # Copyright (c) 2024 Snowflake Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import json
15
+ import logging
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ from typing import Any, Dict, Generic, Iterator, List, Optional, TypeVar
20
+
21
+ from rich.text import Text
22
+ from snowflake.cli._plugins.dcm import styles
23
+ from snowflake.cli.api.console.console import cli_console
24
+ from snowflake.cli.api.exceptions import CliError
25
+ from snowflake.cli.api.sanitizers import sanitize_for_terminal
26
+ from snowflake.connector.cursor import SnowflakeCursor
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ class Reporter(ABC, Generic[T]):
34
+ def __init__(self) -> None:
35
+ self.command_name = ""
36
+
37
+ @abstractmethod
38
+ def extract_data(self, result_json: Dict[str, Any]) -> List[Dict[str, Any]]:
39
+ """Extract the relevant data from the result JSON."""
40
+ ...
41
+
42
+ @abstractmethod
43
+ def parse_data(self, data: List[Dict[str, Any]]) -> Iterator[T]:
44
+ """Parse raw data into domain objects."""
45
+ ...
46
+
47
+ @abstractmethod
48
+ def print_renderables(self, data: Iterator[T]) -> None:
49
+ """Print Rich renderables for the parsed data."""
50
+ ...
51
+
52
+ @abstractmethod
53
+ def _is_success(self) -> bool:
54
+ """Check if underlying operation passed without errors"""
55
+ ...
56
+
57
+ @abstractmethod
58
+ def _generate_summary_renderables(self) -> List[Text]:
59
+ """Generate a list of rich renderables to be printed as success or error message"""
60
+ ...
61
+
62
+ def print_summary(self) -> None:
63
+ """Print operation summary when the result is successful."""
64
+ renderables = self._generate_summary_renderables()
65
+ cli_console.styled_message("\n")
66
+ for renderable in renderables:
67
+ cli_console.styled_message(renderable.plain, style=renderable.style)
68
+ cli_console.styled_message("\n")
69
+
70
+ def process(self, cursor: SnowflakeCursor) -> None:
71
+ """Process cursor data and print results."""
72
+ row = cursor.fetchone()
73
+ if not row:
74
+ cli_console.styled_message("No data.\n")
75
+ return
76
+
77
+ try:
78
+ result_data = row[0]
79
+ result_json = (
80
+ json.loads(result_data) if isinstance(result_data, str) else result_data
81
+ )
82
+ except IndexError:
83
+ log.debug("Unexpected response format: %s", row)
84
+ raise CliError("Could not process response.")
85
+ except json.JSONDecodeError as e:
86
+ log.debug("Could not decode response: %s", e)
87
+ raise CliError("Could not process response.")
88
+
89
+ raw_data = self.extract_data(result_json)
90
+ parsed_data: Iterator[T] = self.parse_data(raw_data)
91
+ self.print_renderables(parsed_data)
92
+ if self._is_success():
93
+ self.print_summary()
94
+ else:
95
+ message = "".join(
96
+ renderable.plain for renderable in self._generate_summary_renderables()
97
+ )
98
+ raise CliError(message)
99
+
100
+
101
+ class TestStatus(Enum):
102
+ __test__ = False # Prevent pytest collection
103
+
104
+ UNKNOWN = "UNKNOWN"
105
+ PASS = "PASS"
106
+ FAIL = "FAIL"
107
+
108
+
109
+ @dataclass
110
+ class TestRow:
111
+ __test__ = False # Prevent pytest collection
112
+
113
+ table_name: str = "UNKNOWN"
114
+ expectation_name: str = "UNKNOWN"
115
+ status: TestStatus = TestStatus.UNKNOWN
116
+ expectation_expression: str = ""
117
+ metric_name: str = ""
118
+ actual_value: str = ""
119
+
120
+ @classmethod
121
+ def from_dict(cls, data: Dict[str, Any]) -> Optional["TestRow"]:
122
+ def _get(key):
123
+ return sanitize_for_terminal(str(data.get(key, "UNKNOWN")))
124
+
125
+ if not isinstance(data, dict):
126
+ log.debug("Unexpected test entry type: %s", type(data))
127
+ return None
128
+
129
+ row = cls(
130
+ table_name=_get("table_name"),
131
+ expectation_name=_get("expectation_name"),
132
+ expectation_expression=_get("expectation_expression"),
133
+ metric_name=_get("metric_name"),
134
+ actual_value=_get("value"),
135
+ )
136
+
137
+ expectation_violated = data.get("expectation_violated")
138
+ if expectation_violated is True:
139
+ row.status = TestStatus.FAIL
140
+ elif expectation_violated is False:
141
+ row.status = TestStatus.PASS
142
+ else:
143
+ row.status = TestStatus.UNKNOWN
144
+ return row
145
+
146
+
147
+ class TestReporter(Reporter[TestRow]):
148
+ __test__ = False # Prevent pytest collection
149
+
150
+ STATUS_WIDTH = 11
151
+ _DATA_KEY = "expectations"
152
+
153
+ @dataclass
154
+ class Summary:
155
+ passed: int = 0
156
+ failed: int = 0
157
+ unknown: int = 0
158
+
159
+ @property
160
+ def total(self):
161
+ return self.passed + self.failed + self.unknown
162
+
163
+ def __init__(self):
164
+ super().__init__()
165
+ self.command_name = "test"
166
+ self._summary = self.Summary()
167
+
168
+ def extract_data(self, result_json: Dict[str, Any]) -> List[Dict[str, Any]]:
169
+ if not isinstance(result_json, dict):
170
+ log.debug("Unexpected response type: %s, expected dict", type(result_json))
171
+ raise CliError("Could not process response.")
172
+
173
+ expectations = result_json.get(self._DATA_KEY, list())
174
+
175
+ if not isinstance(expectations, list):
176
+ log.warning(
177
+ "Unexpected expectations type: %s, expected list",
178
+ type(expectations),
179
+ )
180
+ raise CliError("Could not process response.")
181
+
182
+ return expectations
183
+
184
+ def parse_data(self, data: List[Dict[str, Any]]) -> Iterator[TestRow]:
185
+ for row in data:
186
+ parsed = TestRow.from_dict(row)
187
+ if parsed is not None:
188
+ if parsed.status == TestStatus.PASS:
189
+ self._summary.passed += 1
190
+ elif parsed.status == TestStatus.FAIL:
191
+ self._summary.failed += 1
192
+ else:
193
+ self._summary.unknown += 1
194
+ yield parsed
195
+
196
+ def print_renderables(self, data: Iterator[TestRow]) -> None:
197
+ for row in data:
198
+ if row.status == TestStatus.PASS:
199
+ status_text = "✓ PASS"
200
+ style = styles.PASS_STYLE
201
+ elif row.status == TestStatus.FAIL:
202
+ status_text = "✗ FAIL"
203
+ style = styles.FAIL_STYLE
204
+ else:
205
+ status_text = "? UNKNOWN"
206
+ style = styles.STATUS_STYLE
207
+
208
+ cli_console.styled_message(
209
+ status_text.ljust(self.STATUS_WIDTH) + " ",
210
+ style=style,
211
+ )
212
+ cli_console.styled_message(row.table_name, style=styles.DOMAIN_STYLE)
213
+ cli_console.styled_message(f" ({row.expectation_name})")
214
+ cli_console.styled_message("\n")
215
+
216
+ if row.status == TestStatus.FAIL:
217
+ cli_console.styled_message(
218
+ f" └─ Expected: {row.expectation_expression}, "
219
+ f"Got: {row.actual_value} (Metric: {row.metric_name})\n"
220
+ )
221
+
222
+ def _generate_summary_renderables(self) -> List[Text]:
223
+ total = self._summary.total
224
+ if total == 0:
225
+ return [Text("No expectations found in the project.")]
226
+
227
+ result = [
228
+ (Text(f"{self._summary.passed} passed", styles.PASS_STYLE)),
229
+ (Text(", ")),
230
+ (Text(f"{self._summary.failed} failed", styles.FAIL_STYLE)),
231
+ ]
232
+ if self._summary.unknown > 0:
233
+ result.append(Text(", "))
234
+ result.append(Text(f"{self._summary.unknown} unknown", styles.FAIL_STYLE))
235
+ result.append(Text(" out of "))
236
+ result.append(Text(f"{total}", styles.BOLD_STYLE))
237
+ result.append(Text(" total."))
238
+ return result
239
+
240
+ def _is_success(self) -> bool:
241
+ return self._summary.failed + self._summary.unknown == 0
242
+
243
+
244
+ class RefreshStatus(Enum):
245
+ UNKNOWN = "UNKNOWN"
246
+ UP_TO_DATE = "UP-TO-DATE"
247
+ REFRESHED = "REFRESHED"
248
+
249
+
250
+ @dataclass
251
+ class RefreshRow:
252
+ dt_name: str = "UNKNOWN"
253
+ status: RefreshStatus = RefreshStatus.UNKNOWN
254
+ _inserted: int = field(default=0, repr=False)
255
+ _deleted: int = field(default=0, repr=False)
256
+
257
+ _EMPTY_STAT = "No new data"
258
+ _STATISTICS_KEY = "statistics"
259
+ _DYNAMIC_TABLE_KEY = "dt_name"
260
+ _INSERTED_KEY = "insertedRows"
261
+ _DELETED_KEY = "deletedRows"
262
+
263
+ @staticmethod
264
+ def _safe_int(value: Any) -> int:
265
+ if value is None:
266
+ return 0
267
+ try:
268
+ return int(value)
269
+ except (ValueError, TypeError):
270
+ log.debug("Could not convert value to int: %r", value)
271
+ return 0
272
+
273
+ @staticmethod
274
+ def _format_number(num: int) -> str:
275
+ abs_num = abs(num)
276
+
277
+ units = [
278
+ (1_000_000_000_000_000_000, "E"), # Quintillions (10^18)
279
+ (1_000_000_000_000_000, "P"), # Quadrillions (10^15)
280
+ (1_000_000_000_000, "T"), # Trillions
281
+ (1_000_000_000, "B"), # Billions
282
+ (1_000_000, "M"), # Millions
283
+ (1_000, "k"), # Thousands
284
+ ]
285
+
286
+ for threshold, suffix in units:
287
+ if abs_num >= threshold:
288
+ value = abs_num / threshold
289
+ if round(value, 1) >= 1000:
290
+ formatted = f"{int(value)}{suffix}"
291
+ else:
292
+ formatted = f"{value:.1f}{suffix}".replace(".0", "")
293
+ return formatted
294
+
295
+ return str(num)
296
+
297
+ @classmethod
298
+ def from_dict(cls, data: Dict[str, Any]) -> Optional["RefreshRow"]:
299
+ if not isinstance(data, dict):
300
+ log.debug("Unexpected table entry type: %s", type(data))
301
+ return None
302
+
303
+ raw_dt_name = data.get(cls._DYNAMIC_TABLE_KEY, "UNKNOWN")
304
+ dt_name = sanitize_for_terminal(str(raw_dt_name))
305
+ row = cls(dt_name=dt_name)
306
+
307
+ statistics = data.get(cls._STATISTICS_KEY)
308
+ if statistics is None:
309
+ return row
310
+
311
+ if isinstance(statistics, dict):
312
+ row.inserted = statistics.get(cls._INSERTED_KEY, 0)
313
+ row.deleted = statistics.get(cls._DELETED_KEY, 0)
314
+ elif isinstance(statistics, str):
315
+ if statistics == cls._EMPTY_STAT:
316
+ row.inserted = 0
317
+ row.deleted = 0
318
+ elif statistics.startswith("{"):
319
+ try:
320
+ stats_data = json.loads(statistics)
321
+ row.inserted = stats_data.get(cls._INSERTED_KEY, 0)
322
+ row.deleted = stats_data.get(cls._DELETED_KEY, 0)
323
+ except json.JSONDecodeError:
324
+ log.debug("Failed to parse statistics JSON: %r", statistics)
325
+ return row
326
+ else:
327
+ log.debug("Unexpected statistics format: %r", statistics)
328
+ return row
329
+
330
+ if row.inserted == 0 and row.deleted == 0:
331
+ row.status = RefreshStatus.UP_TO_DATE
332
+ else:
333
+ row.status = RefreshStatus.REFRESHED
334
+
335
+ return row
336
+
337
+ @property
338
+ def inserted(self) -> int:
339
+ return self._inserted
340
+
341
+ @inserted.setter
342
+ def inserted(self, value: Any) -> None:
343
+ self._inserted = self._safe_int(value)
344
+
345
+ @property
346
+ def deleted(self) -> int:
347
+ return self._deleted
348
+
349
+ @deleted.setter
350
+ def deleted(self, value: Any) -> None:
351
+ self._deleted = self._safe_int(value)
352
+
353
+ @property
354
+ def formatted_inserted(self) -> str:
355
+ if self.status == RefreshStatus.UNKNOWN:
356
+ return ""
357
+ formatted = self._format_number(self._inserted)
358
+ if formatted != "0":
359
+ return "+" + formatted
360
+ return formatted
361
+
362
+ @property
363
+ def formatted_deleted(self) -> str:
364
+ if self.status == RefreshStatus.UNKNOWN:
365
+ return ""
366
+ formatted = self._format_number(self._deleted)
367
+ if formatted != "0":
368
+ return "-" + formatted
369
+ return formatted
370
+
371
+
372
+ class RefreshReporter(Reporter[RefreshRow]):
373
+ STATUS_WIDTH = 11
374
+ STATS_WIDTH = 7
375
+ _DATA_KEY = "refreshed_tables"
376
+
377
+ @dataclass
378
+ class Summary:
379
+ up_to_date: int = 0
380
+ refreshed: int = 0
381
+ unknown: int = 0
382
+
383
+ @property
384
+ def total(self):
385
+ return self.up_to_date + self.refreshed + self.unknown
386
+
387
+ def __init__(self):
388
+ super().__init__()
389
+ self.command_name = "refresh"
390
+ self._summary = self.Summary()
391
+
392
+ def extract_data(self, result_json: Dict[str, Any]) -> List[Dict[str, Any]]:
393
+ if not isinstance(result_json, dict):
394
+ log.debug("Unexpected response type: %s, expected dict", type(result_json))
395
+ raise CliError("Could not process response.")
396
+
397
+ refreshed_tables = result_json.get(self._DATA_KEY, list())
398
+
399
+ if not isinstance(refreshed_tables, list):
400
+ log.warning(
401
+ "Unexpected refreshed_tables type: %s, expected list",
402
+ type(refreshed_tables),
403
+ )
404
+ raise CliError("Could not process response.")
405
+
406
+ return refreshed_tables
407
+
408
+ def parse_data(self, data: List[Dict[str, Any]]) -> Iterator[RefreshRow]:
409
+ for row in data:
410
+ parsed = RefreshRow.from_dict(row)
411
+ if parsed is None:
412
+ self._summary.unknown += 1
413
+ continue
414
+
415
+ if parsed.status == RefreshStatus.UP_TO_DATE:
416
+ self._summary.up_to_date += 1
417
+ elif parsed.status == RefreshStatus.REFRESHED:
418
+ self._summary.refreshed += 1
419
+ else:
420
+ self._summary.unknown += 1
421
+ yield parsed
422
+
423
+ def print_renderables(self, data: Iterator[RefreshRow]) -> None:
424
+ for row in data:
425
+ cli_console.styled_message(
426
+ row.status.value.ljust(self.STATUS_WIDTH) + " ",
427
+ style=styles.STATUS_STYLE,
428
+ )
429
+ cli_console.styled_message(
430
+ row.formatted_inserted.rjust(self.STATS_WIDTH) + " ",
431
+ style=styles.INSERTED_STYLE,
432
+ )
433
+ cli_console.styled_message(
434
+ row.formatted_deleted.rjust(self.STATS_WIDTH) + " ",
435
+ style=styles.REMOVED_STYLE,
436
+ )
437
+ cli_console.styled_message(row.dt_name, style=styles.DOMAIN_STYLE)
438
+ cli_console.styled_message("\n")
439
+
440
+ def _generate_summary_renderables(self) -> List[Text]:
441
+ total = self._summary.total
442
+ if total == 0:
443
+ return [Text("No dynamic tables found in the project.")]
444
+
445
+ parts = []
446
+ if (refreshed := self._summary.refreshed) > 0:
447
+ parts.append(f"{refreshed} refreshed")
448
+ if (up_to_date := self._summary.up_to_date) > 0:
449
+ parts.append(f"{up_to_date} up-to-date")
450
+ if (unknown := self._summary.unknown) > 0:
451
+ parts.append(f"{unknown} unknown")
452
+
453
+ summary = ""
454
+ for i, part in enumerate(parts):
455
+ if i > 0:
456
+ summary += ", "
457
+ summary += part
458
+ summary += "."
459
+ return [Text(summary)]
460
+
461
+ def _is_success(self) -> bool:
462
+ return self._summary.unknown == 0
@@ -0,0 +1,26 @@
1
+ # Copyright (c) 2024 Snowflake Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from rich.style import Style
15
+
16
+ DOMAIN_STYLE = Style(color="cyan")
17
+ BOLD_STYLE = Style(bold=True)
18
+
19
+ # Refresh
20
+ STATUS_STYLE = Style(color="blue")
21
+ REMOVED_STYLE = Style(color="red", italic=True)
22
+ INSERTED_STYLE = Style(color="green", italic=True)
23
+
24
+ # Test
25
+ PASS_STYLE = Style(color="green")
26
+ FAIL_STYLE = Style(color="red")
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2024 Snowflake Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import json
15
+ import os
16
+ from functools import wraps
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from snowflake.cli._plugins.dcm.reporters import RefreshReporter, TestReporter
21
+ from snowflake.cli.api.output.types import EmptyResult
22
+
23
+
24
+ class FakeCursor:
25
+ def __init__(self, data: Any):
26
+ self._data = data
27
+ self._fetched = False
28
+
29
+ def fetchone(self):
30
+ if self._fetched:
31
+ return None
32
+ self._fetched = True
33
+ return (json.dumps(self._data),)
34
+
35
+
36
+ def _get_debug_file_number():
37
+ dcm_debug = os.environ.get("DCM_DEBUG")
38
+ if dcm_debug:
39
+ try:
40
+ return int(dcm_debug)
41
+ except ValueError:
42
+ return None
43
+ return None
44
+
45
+
46
+ def _load_debug_data(command_name: str, file_number: int):
47
+ results_dir = Path.cwd() / "results"
48
+
49
+ debug_file = results_dir / f"{command_name}{file_number}.json"
50
+
51
+ if not debug_file.exists():
52
+ raise FileNotFoundError(f"Debug file not found: {debug_file}")
53
+
54
+ with open(debug_file, "r") as f:
55
+ data = json.load(f)
56
+
57
+ if isinstance(data, list) and len(data) > 0:
58
+ if command_name in ("test", "refresh", "analyze"):
59
+ data = data[0]
60
+
61
+ return data
62
+
63
+
64
+ def mock_dcm_response(command_name: str):
65
+ # testing utility to test different reporting styles on mocked responses without touching the backend
66
+ def decorator(func):
67
+ @wraps(func)
68
+ def wrapper(*args: Any, **kwargs: Any):
69
+ file_number = _get_debug_file_number()
70
+ if file_number is None:
71
+ return func(*args, **kwargs)
72
+
73
+ actual_command = "plan" if command_name == "deploy" else command_name
74
+ data = _load_debug_data(actual_command, file_number)
75
+
76
+ if data is None:
77
+ return func(*args, **kwargs)
78
+
79
+ cursor = FakeCursor(data)
80
+ reporter_mapping = {"refresh": RefreshReporter, "test": TestReporter}
81
+
82
+ reporter = reporter_mapping[command_name]()
83
+ reporter.process(cursor)
84
+ return EmptyResult()
85
+
86
+ return wrapper
87
+
88
+ return decorator
@@ -14,6 +14,7 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import re
17
18
  from pathlib import PurePosixPath
18
19
  from textwrap import dedent
19
20
 
@@ -69,6 +70,16 @@ class GitStagePathParts(StagePathParts):
69
70
 
70
71
 
71
72
  class GitManager(StageManager):
73
+ """
74
+ Git stage manager utilities.
75
+
76
+ The `_QUOTED_OR_TOKEN` regex matches either a quoted span (double quotes
77
+ included) or a run of non-slash characters. We use it to tokenize git stage
78
+ paths while preserving quoted repo or branch names that may contain slashes.
79
+ """
80
+
81
+ _QUOTED_OR_TOKEN = re.compile(r'"[^"]*"|[^/]+')
82
+
72
83
  @staticmethod
73
84
  def build_path(stage_path: str) -> StagePathParts:
74
85
  return StagePath.from_git_str(stage_path)
@@ -114,32 +125,23 @@ class GitManager(StageManager):
114
125
 
115
126
  @staticmethod
116
127
  def split_git_path(path: str):
117
- # Check if path contains quotes and split it accordingly
118
- if '/"' in path and '"/' in path:
119
- if path.count('"') > 2:
128
+ match path.count('"'):
129
+ case 0:
130
+ return GitManager._split_path_without_empty_parts(path)
131
+ case 2 | 4:
132
+ tokens = GitManager._QUOTED_OR_TOKEN.findall(path)
133
+ case _:
120
134
  raise UsageError(
121
- f'Invalid string {path}, too much " in path, expected 2.'
135
+ f'Invalid path "{path}": expected 0, 2, or 4 double quotes.'
122
136
  )
123
137
 
124
- path_parts = path.split('"')
125
- before_quoted_part = GitManager._split_path_without_empty_parts(
126
- path_parts[0]
127
- )
128
-
129
- if path_parts[2] == "/":
130
- after_quoted_part = []
138
+ parts = []
139
+ for token in tokens:
140
+ if token.startswith('"') and token.endswith('"'):
141
+ parts.append(token)
131
142
  else:
132
- after_quoted_part = GitManager._split_path_without_empty_parts(
133
- path_parts[2]
134
- )
135
-
136
- return [
137
- *before_quoted_part,
138
- f'"{path_parts[1]}"',
139
- *after_quoted_part,
140
- ]
141
- else:
142
- return GitManager._split_path_without_empty_parts(path)
143
+ parts.extend(GitManager._split_path_without_empty_parts(token))
144
+ return parts
143
145
 
144
146
  @staticmethod
145
147
  def _split_path_without_empty_parts(path: str):
@@ -27,6 +27,7 @@ from snowflake.cli._plugins.object.commands import (
27
27
  scope_option, # noqa: F401
28
28
  terse_option_,
29
29
  )
30
+ from snowflake.cli.api.commands.flags import IfExistsOption
30
31
  from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
31
32
  from snowflake.cli.api.constants import ObjectType
32
33
  from snowflake.cli.api.identifiers import FQN
@@ -90,10 +91,15 @@ def add_object_command_aliases(
90
91
  if "drop" not in ommit_commands:
91
92
 
92
93
  @app.command("drop", requires_connection=True)
93
- def drop_cmd(name: FQN = name_argument, **options):
94
+ def drop_cmd(
95
+ name: FQN = name_argument,
96
+ if_exists: bool = IfExistsOption(),
97
+ **options,
98
+ ):
94
99
  return drop(
95
100
  object_type=object_type.value.cli_name,
96
101
  object_name=name,
102
+ if_exists=if_exists,
97
103
  **options,
98
104
  )
99
105
 
@@ -21,6 +21,7 @@ from click import ClickException
21
21
  from snowflake.cli._plugins.object.manager import ObjectManager
22
22
  from snowflake.cli.api.commands.flags import (
23
23
  IdentifierType,
24
+ IfExistsOption,
24
25
  IfNotExistsOption,
25
26
  ReplaceOption,
26
27
  like_option,
@@ -148,8 +149,17 @@ def list_(
148
149
  help=f"Drops Snowflake object of given name and type. {SUPPORTED_TYPES_MSG}",
149
150
  requires_connection=True,
150
151
  )
151
- def drop(object_type: str = ObjectArgument, object_name: FQN = NameArgument, **options):
152
- return QueryResult(ObjectManager().drop(object_type=object_type, fqn=object_name))
152
+ def drop(
153
+ object_type: str = ObjectArgument,
154
+ object_name: FQN = NameArgument,
155
+ if_exists: bool = IfExistsOption(),
156
+ **options,
157
+ ):
158
+ return QueryResult(
159
+ ObjectManager().drop(
160
+ object_type=object_type, fqn=object_name, if_exists=if_exists
161
+ )
162
+ )
153
163
 
154
164
 
155
165
  # Image repository is the only supported object that does not have a DESCRIBE command.