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.
- snowflake/cli/__about__.py +1 -1
- snowflake/cli/_app/dev/docs/project_definition_generate_json_schema.py +2 -2
- snowflake/cli/_app/printing.py +14 -12
- snowflake/cli/_app/snow_connector.py +59 -9
- snowflake/cli/_plugins/dbt/commands.py +37 -7
- snowflake/cli/_plugins/dbt/manager.py +81 -53
- snowflake/cli/_plugins/dcm/commands.py +38 -0
- snowflake/cli/_plugins/dcm/manager.py +8 -0
- snowflake/cli/_plugins/dcm/reporters.py +462 -0
- snowflake/cli/_plugins/dcm/styles.py +26 -0
- snowflake/cli/_plugins/dcm/utils.py +88 -0
- snowflake/cli/_plugins/git/manager.py +24 -22
- snowflake/cli/_plugins/object/command_aliases.py +7 -1
- snowflake/cli/_plugins/object/commands.py +12 -2
- snowflake/cli/_plugins/object/manager.py +7 -2
- snowflake/cli/_plugins/snowpark/commands.py +8 -1
- snowflake/cli/_plugins/streamlit/streamlit_entity.py +8 -1
- snowflake/cli/api/commands/decorators.py +1 -1
- snowflake/cli/api/commands/flags.py +30 -5
- snowflake/cli/api/console/abc.py +7 -3
- snowflake/cli/api/console/console.py +10 -3
- snowflake/cli/api/exceptions.py +1 -1
- snowflake/cli/api/feature_flags.py +1 -0
- snowflake/cli/api/output/types.py +6 -0
- snowflake/cli/api/utils/types.py +20 -1
- {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/METADATA +9 -4
- {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/RECORD +30 -27
- {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/WHEEL +0 -0
- {snowflake_cli-3.14.0.dist-info → snowflake_cli-3.15.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
135
|
+
f'Invalid path "{path}": expected 0, 2, or 4 double quotes.'
|
|
122
136
|
)
|
|
123
137
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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(
|
|
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(
|
|
152
|
-
|
|
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.
|