tinybird 0.0.1.dev6__py3-none-any.whl → 0.0.1.dev7__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.
Potentially problematic release.
This version of tinybird might be problematic. Click here for more details.
- tinybird/tb/modules/branch.py +0 -21
- tinybird/tb/modules/build.py +7 -18
- tinybird/tb/modules/cli.py +11 -131
- tinybird/tb/modules/common.py +14 -2
- tinybird/tb/modules/create.py +10 -14
- tinybird/tb/modules/datafile/build.py +2103 -0
- tinybird/tb/modules/datafile/build_common.py +118 -0
- tinybird/tb/modules/datafile/build_datasource.py +403 -0
- tinybird/tb/modules/datafile/build_pipe.py +648 -0
- tinybird/tb/modules/datafile/common.py +897 -0
- tinybird/tb/modules/datafile/diff.py +197 -0
- tinybird/tb/modules/datafile/exceptions.py +23 -0
- tinybird/tb/modules/datafile/format_common.py +66 -0
- tinybird/tb/modules/datafile/format_datasource.py +160 -0
- tinybird/tb/modules/datafile/format_pipe.py +195 -0
- tinybird/tb/modules/datafile/parse_datasource.py +41 -0
- tinybird/tb/modules/datafile/parse_pipe.py +69 -0
- tinybird/tb/modules/datafile/pipe_checker.py +560 -0
- tinybird/tb/modules/datafile/pull.py +157 -0
- tinybird/tb/modules/datasource.py +1 -1
- tinybird/tb/modules/fmt.py +4 -1
- tinybird/tb/modules/pipe.py +8 -2
- tinybird/tb/modules/prompts.py +1 -1
- tinybird/tb/modules/workspace.py +1 -1
- {tinybird-0.0.1.dev6.dist-info → tinybird-0.0.1.dev7.dist-info}/METADATA +1 -1
- {tinybird-0.0.1.dev6.dist-info → tinybird-0.0.1.dev7.dist-info}/RECORD +29 -16
- tinybird/tb/modules/datafile.py +0 -6122
- {tinybird-0.0.1.dev6.dist-info → tinybird-0.0.1.dev7.dist-info}/WHEEL +0 -0
- {tinybird-0.0.1.dev6.dist-info → tinybird-0.0.1.dev7.dist-info}/entry_points.txt +0 -0
- {tinybird-0.0.1.dev6.dist-info → tinybird-0.0.1.dev7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, Dict, List, Optional, Set
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import requests
|
|
9
|
+
from croniter import croniter
|
|
10
|
+
|
|
11
|
+
from tinybird.client import DoesNotExistException, TinyB
|
|
12
|
+
from tinybird.feedback_manager import FeedbackManager
|
|
13
|
+
from tinybird.tb.modules.common import getenv_bool, requests_delete, requests_get, wait_job
|
|
14
|
+
from tinybird.tb.modules.datafile.common import ON_DEMAND, CopyModes, CopyParameters, PipeNodeTypes, PipeTypes
|
|
15
|
+
from tinybird.tb.modules.datafile.pipe_checker import PipeCheckerRunner
|
|
16
|
+
from tinybird.tb.modules.exceptions import CLIPipeException
|
|
17
|
+
from tinybird.tb.modules.table import format_pretty_table
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def new_pipe(
|
|
21
|
+
p,
|
|
22
|
+
tb_client: TinyB,
|
|
23
|
+
force: bool = False,
|
|
24
|
+
check: bool = True,
|
|
25
|
+
populate: bool = False,
|
|
26
|
+
populate_subset=None,
|
|
27
|
+
populate_condition=None,
|
|
28
|
+
unlink_on_populate_error: bool = False,
|
|
29
|
+
wait_populate: bool = False,
|
|
30
|
+
skip_tokens: bool = False,
|
|
31
|
+
ignore_sql_errors: bool = False,
|
|
32
|
+
only_response_times: bool = False,
|
|
33
|
+
run_tests: bool = False,
|
|
34
|
+
as_standard: bool = False,
|
|
35
|
+
tests_to_run: int = 0,
|
|
36
|
+
tests_relative_change: float = 0.01,
|
|
37
|
+
tests_to_sample_by_params: int = 0,
|
|
38
|
+
tests_filter_by: Optional[List[str]] = None,
|
|
39
|
+
tests_failfast: bool = False,
|
|
40
|
+
tests_ignore_order: bool = False,
|
|
41
|
+
tests_validate_processed_bytes: bool = False,
|
|
42
|
+
override_datasource: bool = False,
|
|
43
|
+
tests_check_requests_from_branch: bool = False,
|
|
44
|
+
config: Any = None,
|
|
45
|
+
fork_downstream: Optional[bool] = False,
|
|
46
|
+
fork: Optional[bool] = False,
|
|
47
|
+
):
|
|
48
|
+
# TODO use tb_client instead of calling the urls directly.
|
|
49
|
+
host = tb_client.host
|
|
50
|
+
token = tb_client.token
|
|
51
|
+
|
|
52
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
53
|
+
|
|
54
|
+
cli_params = {}
|
|
55
|
+
cli_params["cli_version"] = tb_client.version
|
|
56
|
+
cli_params["description"] = p.get("description", "")
|
|
57
|
+
cli_params["ignore_sql_errors"] = "true" if ignore_sql_errors else "false"
|
|
58
|
+
|
|
59
|
+
r: requests.Response = await requests_get(f"{host}/v0/pipes/{p['name']}?{urlencode(cli_params)}", headers=headers)
|
|
60
|
+
|
|
61
|
+
current_pipe = r.json() if r.status_code == 200 else None
|
|
62
|
+
pipe_exists = current_pipe is not None
|
|
63
|
+
|
|
64
|
+
is_materialized = any([node.get("params", {}).get("type", None) == "materialized" for node in p["nodes"]])
|
|
65
|
+
copy_node = next((node for node in p["nodes"] if node.get("params", {}).get("type", None) == "copy"), None)
|
|
66
|
+
sink_node = next((node for node in p["nodes"] if node.get("params", {}).get("type", None) == "sink"), None)
|
|
67
|
+
stream_node = next((node for node in p["nodes"] if node.get("params", {}).get("type", None) == "stream"), None)
|
|
68
|
+
|
|
69
|
+
for node in p["nodes"]:
|
|
70
|
+
if node["params"]["name"] == p["name"]:
|
|
71
|
+
raise click.ClickException(FeedbackManager.error_pipe_node_same_name(name=p["name"]))
|
|
72
|
+
|
|
73
|
+
if pipe_exists:
|
|
74
|
+
if force or run_tests:
|
|
75
|
+
# TODO: this should create a different node and rename it to the final one on success
|
|
76
|
+
if check and not populate:
|
|
77
|
+
if not is_materialized and not copy_node and not sink_node and not stream_node:
|
|
78
|
+
await check_pipe(
|
|
79
|
+
p,
|
|
80
|
+
host,
|
|
81
|
+
token,
|
|
82
|
+
populate,
|
|
83
|
+
tb_client,
|
|
84
|
+
only_response_times=only_response_times,
|
|
85
|
+
limit=tests_to_run,
|
|
86
|
+
relative_change=tests_relative_change,
|
|
87
|
+
sample_by_params=tests_to_sample_by_params,
|
|
88
|
+
matches=tests_filter_by,
|
|
89
|
+
failfast=tests_failfast,
|
|
90
|
+
validate_processed_bytes=tests_validate_processed_bytes,
|
|
91
|
+
ignore_order=tests_ignore_order,
|
|
92
|
+
token_for_requests_to_check=(
|
|
93
|
+
await get_token_from_main_branch(tb_client)
|
|
94
|
+
if not tests_check_requests_from_branch
|
|
95
|
+
else None
|
|
96
|
+
),
|
|
97
|
+
current_pipe=current_pipe,
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
if is_materialized:
|
|
101
|
+
await check_materialized(
|
|
102
|
+
p,
|
|
103
|
+
host,
|
|
104
|
+
token,
|
|
105
|
+
tb_client,
|
|
106
|
+
override_datasource=override_datasource,
|
|
107
|
+
current_pipe=current_pipe,
|
|
108
|
+
)
|
|
109
|
+
if copy_node:
|
|
110
|
+
await check_copy_pipe(pipe=current_pipe, copy_node=copy_node, tb_client=tb_client)
|
|
111
|
+
if sink_node:
|
|
112
|
+
await check_sink_pipe(pipe=current_pipe, sink_node=sink_node, tb_client=tb_client)
|
|
113
|
+
if stream_node:
|
|
114
|
+
await check_stream_pipe(pipe=current_pipe, stream_node=stream_node, tb_client=tb_client)
|
|
115
|
+
if run_tests:
|
|
116
|
+
logging.info(f"skipping force override of {p['name']}")
|
|
117
|
+
return
|
|
118
|
+
else:
|
|
119
|
+
raise click.ClickException(FeedbackManager.error_pipe_already_exists(pipe=p["name"]))
|
|
120
|
+
elif not pipe_exists and check:
|
|
121
|
+
if is_materialized:
|
|
122
|
+
await check_materialized(
|
|
123
|
+
p, host, token, tb_client, override_datasource=override_datasource, current_pipe=current_pipe
|
|
124
|
+
)
|
|
125
|
+
if copy_node:
|
|
126
|
+
await check_copy_pipe(pipe=current_pipe, copy_node=copy_node, tb_client=tb_client)
|
|
127
|
+
|
|
128
|
+
params = {}
|
|
129
|
+
params.update(cli_params)
|
|
130
|
+
if force:
|
|
131
|
+
params["force"] = "true"
|
|
132
|
+
if populate:
|
|
133
|
+
params["populate"] = "true"
|
|
134
|
+
if populate_condition:
|
|
135
|
+
params["populate_condition"] = populate_condition
|
|
136
|
+
if populate_subset:
|
|
137
|
+
params["populate_subset"] = populate_subset
|
|
138
|
+
params["unlink_on_populate_error"] = "true" if unlink_on_populate_error else "false"
|
|
139
|
+
params["branch_mode"] = "fork" if fork_downstream or fork else "None"
|
|
140
|
+
|
|
141
|
+
body = {"name": p["name"], "description": p.get("description", "")}
|
|
142
|
+
|
|
143
|
+
def parse_node(node):
|
|
144
|
+
if "params" in node:
|
|
145
|
+
node.update(node["params"])
|
|
146
|
+
if node.get("type", "") == "materialized" and override_datasource:
|
|
147
|
+
node["override_datasource"] = "true"
|
|
148
|
+
del node["params"]
|
|
149
|
+
return node
|
|
150
|
+
|
|
151
|
+
if p["nodes"]:
|
|
152
|
+
body["nodes"] = [parse_node(n) for n in p["nodes"]]
|
|
153
|
+
|
|
154
|
+
if copy_node:
|
|
155
|
+
body["target_datasource"] = copy_node.get("target_datasource", None)
|
|
156
|
+
# We will update the schedule cron later
|
|
157
|
+
body["schedule_cron"] = None
|
|
158
|
+
|
|
159
|
+
if sink_node:
|
|
160
|
+
body.update(sink_node.get("export_params", {}))
|
|
161
|
+
|
|
162
|
+
if stream_node:
|
|
163
|
+
body.update(stream_node.get("export_params", {}))
|
|
164
|
+
|
|
165
|
+
post_headers = {"Content-Type": "application/json"}
|
|
166
|
+
|
|
167
|
+
post_headers.update(headers)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
data = await tb_client._req(
|
|
171
|
+
f"/v0/pipes?{urlencode(params)}", method="POST", headers=post_headers, data=json.dumps(body)
|
|
172
|
+
)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
raise click.ClickException(FeedbackManager.error_pushing_pipe(pipe=p["name"], error=str(e)))
|
|
175
|
+
|
|
176
|
+
datasource = data.get("datasource", None)
|
|
177
|
+
|
|
178
|
+
if datasource and populate and not copy_node:
|
|
179
|
+
job_url = data.get("job", {}).get("job_url", None)
|
|
180
|
+
job_id = data.get("job", {}).get("job_id", None)
|
|
181
|
+
if populate_subset:
|
|
182
|
+
click.echo(FeedbackManager.info_populate_subset_job_url(url=job_url, subset=populate_subset))
|
|
183
|
+
elif populate_condition:
|
|
184
|
+
click.echo(
|
|
185
|
+
FeedbackManager.info_populate_condition_job_url(url=job_url, populate_condition=populate_condition)
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
click.echo(FeedbackManager.info_populate_job_url(url=job_url))
|
|
189
|
+
|
|
190
|
+
if wait_populate:
|
|
191
|
+
result = await wait_job(tb_client, job_id, job_url, "Populating")
|
|
192
|
+
click.echo(FeedbackManager.info_populate_job_result(result=result))
|
|
193
|
+
else:
|
|
194
|
+
if data.get("type") == "default" and not skip_tokens and not as_standard and not copy_node and not sink_node:
|
|
195
|
+
# FIXME: set option to add last node as endpoint in the API
|
|
196
|
+
endpoint_node = next(
|
|
197
|
+
(node for node in data.get("nodes", []) if node.get("type") == "endpoint"), data.get("nodes", [])[-1]
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
data = await tb_client._req(
|
|
201
|
+
f"/v0/pipes/{p['name']}/nodes/{endpoint_node.get('id')}/endpoint?{urlencode(cli_params)}",
|
|
202
|
+
method="POST",
|
|
203
|
+
headers=headers,
|
|
204
|
+
)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
raise Exception(
|
|
207
|
+
FeedbackManager.error_creating_endpoint(
|
|
208
|
+
node=endpoint_node.get("name"), pipe=p["name"], error=str(e)
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if copy_node:
|
|
213
|
+
pipe_id = data["id"]
|
|
214
|
+
node = next((node for node in data["nodes"] if node["node_type"] == "copy"), None)
|
|
215
|
+
if node:
|
|
216
|
+
copy_params = {"pipe_name_or_id": pipe_id, "node_id": node["id"]}
|
|
217
|
+
try:
|
|
218
|
+
target_datasource = copy_node.get(CopyParameters.TARGET_DATASOURCE, None)
|
|
219
|
+
schedule_cron = copy_node.get(CopyParameters.COPY_SCHEDULE, None)
|
|
220
|
+
mode = copy_node.get("mode", CopyModes.APPEND)
|
|
221
|
+
schedule_cron = None if schedule_cron == ON_DEMAND else schedule_cron
|
|
222
|
+
current_target_datasource_id = data["copy_target_datasource"]
|
|
223
|
+
target_datasource_response = await tb_client.get_datasource(target_datasource)
|
|
224
|
+
target_datasource_to_send = (
|
|
225
|
+
target_datasource
|
|
226
|
+
if target_datasource_response.get("id", target_datasource) != current_target_datasource_id
|
|
227
|
+
else None
|
|
228
|
+
)
|
|
229
|
+
copy_params[CopyParameters.TARGET_DATASOURCE] = target_datasource_to_send
|
|
230
|
+
current_schedule = data.get("schedule", {})
|
|
231
|
+
current_schedule_cron = current_schedule.get("cron", None) if current_schedule else None
|
|
232
|
+
schedule_cron_should_be_removed = current_schedule_cron and not schedule_cron
|
|
233
|
+
copy_params["schedule_cron"] = "None" if schedule_cron_should_be_removed else schedule_cron
|
|
234
|
+
copy_params["mode"] = mode
|
|
235
|
+
await tb_client.pipe_update_copy(**copy_params)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
raise Exception(
|
|
238
|
+
FeedbackManager.error_setting_copy_node(node=copy_node.get("name"), pipe=p["name"], error=str(e))
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if p["tokens"] and not skip_tokens and not as_standard and data.get("type") in ["endpoint", "copy"]:
|
|
242
|
+
# search for token with specified name and adds it if not found or adds permissions to it
|
|
243
|
+
t = None
|
|
244
|
+
for tk in p["tokens"]:
|
|
245
|
+
token_name = tk["token_name"]
|
|
246
|
+
t = await tb_client.get_token_by_name(token_name)
|
|
247
|
+
if t:
|
|
248
|
+
scopes = [f"PIPES:{tk['permissions']}:{p['name']}"]
|
|
249
|
+
for x in t["scopes"]:
|
|
250
|
+
sc = x["type"] if "resource" not in x else f"{x['type']}:{x['resource']}"
|
|
251
|
+
scopes.append(sc)
|
|
252
|
+
try:
|
|
253
|
+
r = await tb_client.alter_tokens(token_name, scopes)
|
|
254
|
+
token = r["token"] # type: ignore
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise click.ClickException(FeedbackManager.error_creating_pipe(error=e))
|
|
257
|
+
else:
|
|
258
|
+
token_name = tk["token_name"]
|
|
259
|
+
click.echo(FeedbackManager.info_create_not_found_token(token=token_name))
|
|
260
|
+
try:
|
|
261
|
+
r = await tb_client.create_token(
|
|
262
|
+
token_name, [f"PIPES:{tk['permissions']}:{p['name']}"], "P", p["name"]
|
|
263
|
+
)
|
|
264
|
+
token = r["token"] # type: ignore
|
|
265
|
+
except Exception as e:
|
|
266
|
+
raise click.ClickException(FeedbackManager.error_creating_pipe(error=e))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def get_token_from_main_branch(branch_tb_client: TinyB) -> Optional[str]:
|
|
270
|
+
token_from_main_branch = None
|
|
271
|
+
current_workspace = await branch_tb_client.workspace_info()
|
|
272
|
+
# current workspace is a branch
|
|
273
|
+
if current_workspace.get("main"):
|
|
274
|
+
response = await branch_tb_client.user_workspaces()
|
|
275
|
+
workspaces = response["workspaces"]
|
|
276
|
+
prod_workspace = next(
|
|
277
|
+
(workspace for workspace in workspaces if workspace["id"] == current_workspace["main"]), None
|
|
278
|
+
)
|
|
279
|
+
if prod_workspace:
|
|
280
|
+
token_from_main_branch = prod_workspace.get("token")
|
|
281
|
+
return token_from_main_branch
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def check_pipe(
|
|
285
|
+
pipe,
|
|
286
|
+
host: str,
|
|
287
|
+
token: str,
|
|
288
|
+
populate: bool,
|
|
289
|
+
cl: TinyB,
|
|
290
|
+
limit: int = 0,
|
|
291
|
+
relative_change: float = 0.01,
|
|
292
|
+
sample_by_params: int = 0,
|
|
293
|
+
only_response_times=False,
|
|
294
|
+
matches: Optional[List[str]] = None,
|
|
295
|
+
failfast: bool = False,
|
|
296
|
+
validate_processed_bytes: bool = False,
|
|
297
|
+
ignore_order: bool = False,
|
|
298
|
+
token_for_requests_to_check: Optional[str] = None,
|
|
299
|
+
current_pipe: Optional[Dict[str, Any]] = None,
|
|
300
|
+
):
|
|
301
|
+
checker_pipe = deepcopy(pipe)
|
|
302
|
+
checker_pipe["name"] = f"{checker_pipe['name']}__checker"
|
|
303
|
+
|
|
304
|
+
if current_pipe:
|
|
305
|
+
pipe_type = current_pipe["type"]
|
|
306
|
+
if pipe_type == PipeTypes.COPY:
|
|
307
|
+
await cl.pipe_remove_copy(current_pipe["id"], current_pipe["copy_node"])
|
|
308
|
+
if pipe_type == PipeTypes.DATA_SINK:
|
|
309
|
+
await cl.pipe_remove_sink(current_pipe["id"], current_pipe["sink_node"])
|
|
310
|
+
if pipe_type == PipeTypes.STREAM:
|
|
311
|
+
await cl.pipe_remove_stream(current_pipe["id"], current_pipe["stream_node"])
|
|
312
|
+
|
|
313
|
+
# In case of doing --force for a materialized view, checker is being created as standard pipe
|
|
314
|
+
for node in checker_pipe["nodes"]:
|
|
315
|
+
node["params"]["type"] = PipeNodeTypes.STANDARD
|
|
316
|
+
|
|
317
|
+
if populate:
|
|
318
|
+
raise click.ClickException(FeedbackManager.error_check_pipes_populate())
|
|
319
|
+
|
|
320
|
+
runner = PipeCheckerRunner(pipe["name"], host)
|
|
321
|
+
headers = (
|
|
322
|
+
{"Authorization": f"Bearer {token_for_requests_to_check}"}
|
|
323
|
+
if token_for_requests_to_check
|
|
324
|
+
else {"Authorization": f"Bearer {token}"}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
sql_for_coverage, sql_latest_requests = runner.get_sqls_for_requests_to_check(
|
|
328
|
+
matches or [], sample_by_params, limit
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
params = {"q": sql_for_coverage if limit == 0 and sample_by_params > 0 else sql_latest_requests}
|
|
332
|
+
r: requests.Response = await requests_get(
|
|
333
|
+
f"{host}/v0/sql?{urlencode(params)}", headers=headers, verify=not getenv_bool("TB_DISABLE_SSL_CHECKS", False)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# If we get a timeout, fallback to just the last requests
|
|
337
|
+
|
|
338
|
+
if not r or r.status_code == 408:
|
|
339
|
+
params = {"q": sql_latest_requests}
|
|
340
|
+
r = await requests_get(
|
|
341
|
+
f"{host}/v0/sql?{urlencode(params)}",
|
|
342
|
+
headers=headers,
|
|
343
|
+
verify=not getenv_bool("TB_DISABLE_SSL_CHECKS", False),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if not r or r.status_code != 200:
|
|
347
|
+
raise click.ClickException(FeedbackManager.error_check_pipes_api(pipe=pipe["name"]))
|
|
348
|
+
|
|
349
|
+
pipe_requests_to_check: List[Dict[str, Any]] = []
|
|
350
|
+
for row in r.json().get("data", []):
|
|
351
|
+
for i in range(len(row["endpoint_url"])):
|
|
352
|
+
pipe_requests_to_check += [
|
|
353
|
+
{
|
|
354
|
+
"endpoint_url": f"{host}{row['endpoint_url'][i]}",
|
|
355
|
+
"pipe_request_params": row["pipe_request_params"][i],
|
|
356
|
+
"http_method": row["http_method"],
|
|
357
|
+
}
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
if not pipe_requests_to_check:
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
await new_pipe(checker_pipe, cl, force=True, check=False, populate=populate)
|
|
364
|
+
|
|
365
|
+
runner_response = runner.run_pipe_checker(
|
|
366
|
+
pipe_requests_to_check,
|
|
367
|
+
checker_pipe["name"],
|
|
368
|
+
token,
|
|
369
|
+
only_response_times,
|
|
370
|
+
ignore_order,
|
|
371
|
+
validate_processed_bytes,
|
|
372
|
+
relative_change,
|
|
373
|
+
failfast,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
if runner_response.metrics_summary and runner_response.metrics_timing:
|
|
378
|
+
column_names_tests = ["Test Run", "Test Passed", "Test Failed", "% Test Passed", "% Test Failed"]
|
|
379
|
+
click.echo("\n==== Test Metrics ====\n")
|
|
380
|
+
click.echo(
|
|
381
|
+
format_pretty_table(
|
|
382
|
+
[
|
|
383
|
+
[
|
|
384
|
+
runner_response.metrics_summary["run"],
|
|
385
|
+
runner_response.metrics_summary["passed"],
|
|
386
|
+
runner_response.metrics_summary["failed"],
|
|
387
|
+
runner_response.metrics_summary["percentage_passed"],
|
|
388
|
+
runner_response.metrics_summary["percentage_failed"],
|
|
389
|
+
]
|
|
390
|
+
],
|
|
391
|
+
column_names=column_names_tests,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
column_names_timing = ["Timing Metric (s)", "Current", "New"]
|
|
396
|
+
click.echo("\n==== Response Time Metrics ====\n")
|
|
397
|
+
click.echo(
|
|
398
|
+
format_pretty_table(
|
|
399
|
+
[
|
|
400
|
+
[metric, runner_response.metrics_timing[metric][0], runner_response.metrics_timing[metric][1]]
|
|
401
|
+
for metric in [
|
|
402
|
+
"min response time",
|
|
403
|
+
"max response time",
|
|
404
|
+
"mean response time",
|
|
405
|
+
"median response time",
|
|
406
|
+
"p90 response time",
|
|
407
|
+
"min read bytes",
|
|
408
|
+
"max read bytes",
|
|
409
|
+
"mean read bytes",
|
|
410
|
+
"median read bytes",
|
|
411
|
+
"p90 read bytes",
|
|
412
|
+
]
|
|
413
|
+
],
|
|
414
|
+
column_names=column_names_timing,
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
if not runner_response.was_successfull:
|
|
421
|
+
for failure in runner_response.failed:
|
|
422
|
+
try:
|
|
423
|
+
click.echo("==== Test FAILED ====\n")
|
|
424
|
+
click.echo(failure["name"])
|
|
425
|
+
click.echo(FeedbackManager.error_check_pipe(error=failure["error"]))
|
|
426
|
+
click.echo("=====================\n\n\n")
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|
|
429
|
+
raise RuntimeError("Invalid results, you can bypass checks by running push with the --no-check flag")
|
|
430
|
+
|
|
431
|
+
# Only delete if no errors, so we can check results after failure
|
|
432
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
433
|
+
r = await requests_delete(f"{host}/v0/pipes/{checker_pipe['name']}", headers=headers)
|
|
434
|
+
if r.status_code != 204:
|
|
435
|
+
click.echo(FeedbackManager.warning_check_pipe(content=r.content))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
async def check_materialized(pipe, host, token, cl, override_datasource=False, current_pipe=None):
|
|
439
|
+
checker_pipe = deepcopy(pipe)
|
|
440
|
+
checker_pipe["name"] = f"{checker_pipe['name']}__checker"
|
|
441
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
442
|
+
|
|
443
|
+
if current_pipe:
|
|
444
|
+
from_copy_to_materialized = current_pipe["type"] == "copy"
|
|
445
|
+
if from_copy_to_materialized:
|
|
446
|
+
await cl.pipe_remove_copy(current_pipe["id"], current_pipe["copy_node"])
|
|
447
|
+
|
|
448
|
+
materialized_node = None
|
|
449
|
+
for node in checker_pipe["nodes"]:
|
|
450
|
+
if node["params"]["type"] == "materialized":
|
|
451
|
+
materialized_node = deepcopy(node)
|
|
452
|
+
materialized_node["params"]["override_datasource"] = "true" if override_datasource else "false"
|
|
453
|
+
node["params"]["type"] = "standard"
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
pipe_created = False
|
|
457
|
+
await new_pipe(
|
|
458
|
+
checker_pipe, cl, force=True, check=False, populate=False, skip_tokens=True, ignore_sql_errors=False
|
|
459
|
+
)
|
|
460
|
+
pipe_created = True
|
|
461
|
+
response = await cl.analyze_pipe_node(checker_pipe["name"], materialized_node, dry_run="true")
|
|
462
|
+
if response.get("warnings"):
|
|
463
|
+
show_materialized_view_warnings(response["warnings"])
|
|
464
|
+
|
|
465
|
+
except Exception as e:
|
|
466
|
+
raise click.ClickException(FeedbackManager.error_while_check_materialized(error=str(e)))
|
|
467
|
+
finally:
|
|
468
|
+
if pipe_created:
|
|
469
|
+
r = await requests_delete(f"{host}/v0/pipes/{checker_pipe['name']}", headers=headers)
|
|
470
|
+
if r.status_code != 204:
|
|
471
|
+
click.echo(FeedbackManager.warning_check_pipe(content=r.content))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
async def check_copy_pipe(pipe, copy_node, tb_client: TinyB):
|
|
475
|
+
target_datasource = copy_node["params"].get("target_datasource", None)
|
|
476
|
+
if not target_datasource:
|
|
477
|
+
raise CLIPipeException(FeedbackManager.error_creating_copy_pipe_target_datasource_required())
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
await tb_client.get_datasource(target_datasource)
|
|
481
|
+
except DoesNotExistException:
|
|
482
|
+
raise CLIPipeException(
|
|
483
|
+
FeedbackManager.error_creating_copy_pipe_target_datasource_not_found(target_datasource=target_datasource)
|
|
484
|
+
)
|
|
485
|
+
except Exception as e:
|
|
486
|
+
raise CLIPipeException(FeedbackManager.error_exception(error=e))
|
|
487
|
+
|
|
488
|
+
schedule_cron = copy_node["params"].get(CopyParameters.COPY_SCHEDULE, None)
|
|
489
|
+
is_valid_cron = not schedule_cron or (
|
|
490
|
+
schedule_cron and (schedule_cron == ON_DEMAND or croniter.is_valid(schedule_cron))
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if not is_valid_cron:
|
|
494
|
+
raise CLIPipeException(FeedbackManager.error_creating_copy_pipe_invalid_cron(schedule_cron=schedule_cron))
|
|
495
|
+
|
|
496
|
+
mode = copy_node["params"].get("mode", CopyModes.APPEND)
|
|
497
|
+
is_valid_mode = CopyModes.is_valid(mode)
|
|
498
|
+
|
|
499
|
+
if not is_valid_mode:
|
|
500
|
+
raise CLIPipeException(FeedbackManager.error_creating_copy_pipe_invalid_mode(mode=mode))
|
|
501
|
+
|
|
502
|
+
if not pipe:
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
pipe_name = pipe["name"]
|
|
506
|
+
pipe_type = pipe["type"]
|
|
507
|
+
|
|
508
|
+
if pipe_type == PipeTypes.ENDPOINT:
|
|
509
|
+
await tb_client.pipe_remove_endpoint(pipe_name, pipe["endpoint"])
|
|
510
|
+
|
|
511
|
+
if pipe_type == PipeTypes.DATA_SINK:
|
|
512
|
+
await tb_client.pipe_remove_sink(pipe_name, pipe["sink_node"])
|
|
513
|
+
|
|
514
|
+
if pipe_type == PipeTypes.STREAM:
|
|
515
|
+
await tb_client.pipe_remove_stream(pipe_name, pipe["stream_node"])
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
async def check_sink_pipe(pipe, sink_node, tb_client: TinyB):
|
|
519
|
+
if not sink_node["export_params"]:
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
if not pipe:
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
pipe_name = pipe["name"]
|
|
526
|
+
pipe_type = pipe["type"]
|
|
527
|
+
|
|
528
|
+
schedule_cron = sink_node["export_params"].get("schedule_cron", "")
|
|
529
|
+
is_valid_cron = not schedule_cron or (schedule_cron and croniter.is_valid(schedule_cron))
|
|
530
|
+
|
|
531
|
+
if not is_valid_cron:
|
|
532
|
+
raise CLIPipeException(FeedbackManager.error_creating_sink_pipe_invalid_cron(schedule_cron=schedule_cron))
|
|
533
|
+
|
|
534
|
+
if pipe_type == PipeTypes.ENDPOINT:
|
|
535
|
+
await tb_client.pipe_remove_endpoint(pipe_name, pipe["endpoint"])
|
|
536
|
+
|
|
537
|
+
if pipe_type == PipeTypes.COPY:
|
|
538
|
+
await tb_client.pipe_remove_copy(pipe_name, pipe["copy_node"])
|
|
539
|
+
|
|
540
|
+
if pipe_type == PipeTypes.STREAM:
|
|
541
|
+
await tb_client.pipe_remove_stream(pipe_name, pipe["stream_node"])
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
async def check_stream_pipe(pipe, stream_node, tb_client: TinyB):
|
|
545
|
+
if not stream_node["params"]:
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
if not pipe:
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
pipe_name = pipe["name"]
|
|
552
|
+
pipe_type = pipe["type"]
|
|
553
|
+
|
|
554
|
+
if pipe_type == PipeTypes.ENDPOINT:
|
|
555
|
+
await tb_client.pipe_remove_endpoint(pipe_name, pipe["endpoint"])
|
|
556
|
+
|
|
557
|
+
if pipe_type == PipeTypes.COPY:
|
|
558
|
+
await tb_client.pipe_remove_copy(pipe_name, pipe["copy_node"])
|
|
559
|
+
|
|
560
|
+
if pipe_type == PipeTypes.DATA_SINK:
|
|
561
|
+
await tb_client.pipe_remove_sink(pipe_name, pipe["sink_node"])
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def show_materialized_view_warnings(warnings):
|
|
565
|
+
"""
|
|
566
|
+
>>> show_materialized_view_warnings([{'code': 'SIM', 'weight': 1}])
|
|
567
|
+
|
|
568
|
+
>>> show_materialized_view_warnings([{'code': 'SIM', 'weight': 1}, {'code': 'HUGE_JOIN', 'weight': 2}, {'text': "Column 'number' is present in the GROUP BY but not in the SELECT clause. This might indicate a not valid Materialized View, please make sure you aggregate and GROUP BY in the topmost query.", 'code': 'GROUP_BY', 'weight': 100, 'documentation': 'https://tinybird.co/docs/guides/materialized-views.html#use-the-same-alias-in-select-and-group-by'}])
|
|
569
|
+
⚠️ Column 'number' is present in the GROUP BY but not in the SELECT clause. This might indicate a not valid Materialized View, please make sure you aggregate and GROUP BY in the topmost query. For more information read https://tinybird.co/docs/guides/materialized-views.html#use-the-same-alias-in-select-and-group-by or contact us at support@tinybird.co
|
|
570
|
+
>>> show_materialized_view_warnings([{'code': 'SINGLE_JOIN', 'weight': 300}, {'text': "Column 'number' is present in the GROUP BY but not in the SELECT clause. This might indicate a not valid Materialized View, please make sure you aggregate and GROUP BY in the topmost query.", 'code': 'GROUP_BY', 'weight': 100, 'documentation': 'https://tinybird.co/docs/guides/materialized-views.html#use-the-same-alias-in-select-and-group-by'}])
|
|
571
|
+
⚠️ Column 'number' is present in the GROUP BY but not in the SELECT clause. This might indicate a not valid Materialized View, please make sure you aggregate and GROUP BY in the topmost query. For more information read https://tinybird.co/docs/guides/materialized-views.html#use-the-same-alias-in-select-and-group-by or contact us at support@tinybird.co
|
|
572
|
+
"""
|
|
573
|
+
excluded_warnings = ["SIM", "SIM_UNKNOWN", "HUGE_JOIN"]
|
|
574
|
+
sorted_warnings = sorted(warnings, key=lambda warning: warning["weight"])
|
|
575
|
+
most_important_warning = {}
|
|
576
|
+
for warning in sorted_warnings:
|
|
577
|
+
if warning.get("code") and warning["code"] not in excluded_warnings:
|
|
578
|
+
most_important_warning = warning
|
|
579
|
+
break
|
|
580
|
+
if most_important_warning:
|
|
581
|
+
click.echo(
|
|
582
|
+
FeedbackManager.single_warning_materialized_pipe(
|
|
583
|
+
content=most_important_warning["text"], docs_url=most_important_warning["documentation"]
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def is_endpoint_with_no_dependencies(
|
|
589
|
+
resource: Dict[str, Any], dep_map: Dict[str, Set[str]], to_run: Dict[str, Dict[str, Any]]
|
|
590
|
+
) -> bool:
|
|
591
|
+
if not resource or resource.get("resource") == "datasources":
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
for node in resource.get("nodes", []):
|
|
595
|
+
# FIXME: https://gitlab.com/tinybird/analytics/-/issues/2391
|
|
596
|
+
if node.get("params", {}).get("type", "").lower() in [
|
|
597
|
+
PipeNodeTypes.MATERIALIZED,
|
|
598
|
+
PipeNodeTypes.COPY,
|
|
599
|
+
PipeNodeTypes.DATA_SINK,
|
|
600
|
+
PipeNodeTypes.STREAM,
|
|
601
|
+
]:
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
for key, values in dep_map.items():
|
|
605
|
+
if resource["resource_name"] in values:
|
|
606
|
+
r = to_run.get(key, None)
|
|
607
|
+
if not r:
|
|
608
|
+
continue
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
deps = dep_map.get(resource["resource_name"])
|
|
612
|
+
if not deps:
|
|
613
|
+
return True
|
|
614
|
+
|
|
615
|
+
for dep in deps:
|
|
616
|
+
r = to_run.get(dep, None)
|
|
617
|
+
if is_endpoint(r) or is_materialized(r):
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
return True
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def is_endpoint(resource: Optional[Dict[str, Any]]) -> bool:
|
|
624
|
+
if resource and len(resource.get("tokens", [])) != 0 and resource.get("resource") == "pipes":
|
|
625
|
+
return True
|
|
626
|
+
return False
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def is_materialized(resource: Optional[Dict[str, Any]]) -> bool:
|
|
630
|
+
if not resource:
|
|
631
|
+
return False
|
|
632
|
+
|
|
633
|
+
is_materialized = any(
|
|
634
|
+
[node.get("params", {}).get("type", None) == "materialized" for node in resource.get("nodes", []) or []]
|
|
635
|
+
)
|
|
636
|
+
return is_materialized
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def get_target_materialized_data_source_name(resource: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
640
|
+
if not resource:
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
for node in resource.get("nodes", []):
|
|
644
|
+
# FIXME: https://gitlab.com/tinybird/analytics/-/issues/2391
|
|
645
|
+
if node.get("params", {}).get("type", "").lower() == PipeNodeTypes.MATERIALIZED:
|
|
646
|
+
return node.get("params")["datasource"].split("__v")[0]
|
|
647
|
+
|
|
648
|
+
return None
|