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.
- 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 +94 -4
- snowflake/cli/_plugins/dcm/manager.py +87 -33
- 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/snowpark/package/commands.py +1 -1
- snowflake/cli/_plugins/streamlit/commands.py +23 -4
- snowflake/cli/_plugins/streamlit/streamlit_entity.py +89 -46
- 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 +14 -2
- snowflake/cli/api/exceptions.py +1 -1
- snowflake/cli/api/feature_flags.py +1 -3
- snowflake/cli/api/output/types.py +6 -0
- snowflake/cli/api/utils/types.py +20 -1
- {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/METADATA +10 -5
- {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/RECORD +32 -29
- {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/WHEEL +1 -1
- {snowflake_cli-3.13.1.dist-info → snowflake_cli-3.15.0.dist-info}/entry_points.txt +0 -0
- {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(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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")
|