snowflake-cli 3.13.1__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 (32) 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 +94 -4
  8. snowflake/cli/_plugins/dcm/manager.py +87 -33
  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/snowpark/package/commands.py +1 -1
  18. snowflake/cli/_plugins/streamlit/commands.py +23 -4
  19. snowflake/cli/_plugins/streamlit/streamlit_entity.py +89 -46
  20. snowflake/cli/api/commands/decorators.py +1 -1
  21. snowflake/cli/api/commands/flags.py +30 -5
  22. snowflake/cli/api/console/abc.py +7 -3
  23. snowflake/cli/api/console/console.py +14 -2
  24. snowflake/cli/api/exceptions.py +1 -1
  25. snowflake/cli/api/feature_flags.py +1 -3
  26. snowflake/cli/api/output/types.py +6 -0
  27. snowflake/cli/api/utils/types.py +20 -1
  28. {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/METADATA +10 -5
  29. {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/RECORD +32 -29
  30. {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/WHEEL +1 -1
  31. {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/entry_points.txt +0 -0
  32. {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -62,51 +62,59 @@ class DCMProjectManager(SqlExecutionMixin):
62
62
  ObjectType.DCM_PROJECT, project_identifier, "OUTPUT_TMP_STAGE"
63
63
  )
64
64
  stage_manager.create(temp_stage_fqn, temporary=True)
65
- effective_output_path = StagePath.from_stage_str(temp_stage_fqn.identifier)
65
+ effective_output_path = StagePath.from_stage_str(
66
+ temp_stage_fqn.identifier
67
+ ).joinpath("/outputs")
66
68
  temp_stage_for_local_output = (temp_stage_fqn.identifier, Path(output_path))
67
69
  else:
68
70
  effective_output_path = StagePath.from_stage_str(output_path)
69
71
 
70
- yield effective_output_path.absolute_path()
71
-
72
- if should_download_files:
73
- assert temp_stage_for_local_output is not None
74
- stage_path, local_path = temp_stage_for_local_output
75
- stage_manager.get_recursive(stage_path=stage_path, dest_path=local_path)
76
- cli_console.step(f"Plan output saved to: {local_path.resolve()}")
77
- else:
78
- cli_console.step(f"Plan output saved to: {output_path}")
72
+ try:
73
+ yield effective_output_path.absolute_path()
74
+ finally:
75
+ if should_download_files:
76
+ assert temp_stage_for_local_output is not None
77
+ stage_path, local_path = temp_stage_for_local_output
78
+ stage_manager.get_recursive(
79
+ stage_path=effective_output_path.absolute_path(),
80
+ dest_path=local_path,
81
+ )
82
+ cli_console.step(f"Plan output saved to: {local_path.resolve()}")
83
+ else:
84
+ cli_console.step(f"Plan output saved to: {output_path}")
79
85
 
80
- def execute(
86
+ def deploy(
81
87
  self,
82
88
  project_identifier: FQN,
83
89
  from_stage: str,
84
90
  configuration: str | None = None,
85
91
  variables: List[str] | None = None,
86
- dry_run: bool = False,
87
92
  alias: str | None = None,
93
+ skip_plan: bool = False,
94
+ ):
95
+ query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} DEPLOY"
96
+ if alias:
97
+ query += f' AS "{alias}"'
98
+ query += self._get_configuration_and_variables_query(configuration, variables)
99
+ query += self._get_from_stage_query(from_stage)
100
+ if skip_plan:
101
+ query += f" SKIP PLAN"
102
+ return self.execute_query(query=query)
103
+
104
+ def plan(
105
+ self,
106
+ project_identifier: FQN,
107
+ from_stage: str,
108
+ configuration: str | None = None,
109
+ variables: List[str] | None = None,
88
110
  output_path: str | None = None,
89
111
  ):
90
- with self._collect_output(project_identifier, output_path) if (
91
- output_path and dry_run
92
- ) else nullcontext() as output_stage:
93
- query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier}"
94
- if dry_run:
95
- query += " PLAN"
96
- else:
97
- query += " DEPLOY"
98
- if alias:
99
- query += f' AS "{alias}"'
100
- if configuration or variables:
101
- query += f" USING"
102
- if configuration:
103
- query += f" CONFIGURATION {configuration}"
104
- if variables:
105
- query += StageManager.parse_execute_variables(
106
- parse_key_value_variables(variables)
107
- ).removeprefix(" using")
108
- stage_path = StagePath.from_stage_str(from_stage)
109
- query += f" FROM {stage_path.absolute_path()}"
112
+ query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} PLAN"
113
+ query += self._get_configuration_and_variables_query(configuration, variables)
114
+ query += self._get_from_stage_query(from_stage)
115
+ with self._collect_output(
116
+ project_identifier, output_path
117
+ ) if output_path else nullcontext() as output_stage:
110
118
  if output_stage is not None:
111
119
  query += f" OUTPUT_PATH {output_stage}"
112
120
  result = self.execute_query(query=query)
@@ -136,6 +144,50 @@ class DCMProjectManager(SqlExecutionMixin):
136
144
  query += f' "{deployment_name}"'
137
145
  return self.execute_query(query=query)
138
146
 
147
+ def preview(
148
+ self,
149
+ project_identifier: FQN,
150
+ object_identifier: FQN,
151
+ from_stage: str,
152
+ configuration: str | None = None,
153
+ variables: List[str] | None = None,
154
+ limit: int | None = None,
155
+ ):
156
+ query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} PREVIEW {object_identifier.sql_identifier}"
157
+ query += self._get_configuration_and_variables_query(configuration, variables)
158
+ query += self._get_from_stage_query(from_stage)
159
+ if limit is not None:
160
+ query += f" LIMIT {limit}"
161
+ return self.execute_query(query=query)
162
+
163
+ def refresh(self, project_identifier: FQN):
164
+ query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} REFRESH ALL"
165
+ return self.execute_query(query=query)
166
+
167
+ def test(self, project_identifier: FQN):
168
+ query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} TEST ALL"
169
+ return self.execute_query(query=query)
170
+
171
+ @staticmethod
172
+ def _get_from_stage_query(from_stage: str) -> str:
173
+ stage_path = StagePath.from_stage_str(from_stage)
174
+ return f" FROM {stage_path.absolute_path()}"
175
+
176
+ @staticmethod
177
+ def _get_configuration_and_variables_query(
178
+ configuration: str | None, variables: List[str] | None
179
+ ) -> str:
180
+ query = ""
181
+ if configuration or variables:
182
+ query += f" USING"
183
+ if configuration:
184
+ query += f" CONFIGURATION {configuration}"
185
+ if variables:
186
+ query += StageManager.parse_execute_variables(
187
+ parse_key_value_variables(variables)
188
+ ).removeprefix(" using")
189
+ return query
190
+
139
191
  @staticmethod
140
192
  def sync_local_files(
141
193
  project_identifier: FQN, source_directory: str | None = None
@@ -166,7 +218,9 @@ class DCMProjectManager(SqlExecutionMixin):
166
218
 
167
219
  definitions = list(dcm_manifest.get("include_definitions", list()))
168
220
  if MANIFEST_FILE_NAME not in definitions:
169
- definitions.append(MANIFEST_FILE_NAME)
221
+ # append manifest file, but avoid sending it multiple times if
222
+ # there are manifests from previous runs stored in output path
223
+ definitions.append(rf"^{MANIFEST_FILE_NAME}")
170
224
 
171
225
  with cli_console.phase(f"Uploading definition files"):
172
226
  stage_fqn = FQN.from_resource(
@@ -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")