tinybird 0.0.1.dev5__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.

Files changed (55) hide show
  1. tinybird/__cli__.py +7 -8
  2. tinybird/tb/cli.py +28 -0
  3. tinybird/{tb_cli_modules → tb/modules}/auth.py +5 -5
  4. tinybird/{tb_cli_modules → tb/modules}/branch.py +5 -25
  5. tinybird/{tb_cli_modules → tb/modules}/build.py +10 -21
  6. tinybird/tb/modules/cicd.py +271 -0
  7. tinybird/{tb_cli_modules → tb/modules}/cli.py +20 -140
  8. tinybird/tb/modules/common.py +2110 -0
  9. tinybird/tb/modules/config.py +352 -0
  10. tinybird/{tb_cli_modules → tb/modules}/connection.py +4 -4
  11. tinybird/{tb_cli_modules → tb/modules}/create.py +20 -20
  12. tinybird/tb/modules/datafile/build.py +2103 -0
  13. tinybird/tb/modules/datafile/build_common.py +118 -0
  14. tinybird/tb/modules/datafile/build_datasource.py +403 -0
  15. tinybird/tb/modules/datafile/build_pipe.py +648 -0
  16. tinybird/tb/modules/datafile/common.py +897 -0
  17. tinybird/tb/modules/datafile/diff.py +197 -0
  18. tinybird/tb/modules/datafile/exceptions.py +23 -0
  19. tinybird/tb/modules/datafile/format_common.py +66 -0
  20. tinybird/tb/modules/datafile/format_datasource.py +160 -0
  21. tinybird/tb/modules/datafile/format_pipe.py +195 -0
  22. tinybird/tb/modules/datafile/parse_datasource.py +41 -0
  23. tinybird/tb/modules/datafile/parse_pipe.py +69 -0
  24. tinybird/tb/modules/datafile/pipe_checker.py +560 -0
  25. tinybird/tb/modules/datafile/pull.py +157 -0
  26. tinybird/{tb_cli_modules → tb/modules}/datasource.py +7 -6
  27. tinybird/tb/modules/exceptions.py +91 -0
  28. tinybird/{tb_cli_modules → tb/modules}/fmt.py +6 -3
  29. tinybird/{tb_cli_modules → tb/modules}/job.py +3 -3
  30. tinybird/{tb_cli_modules → tb/modules}/llm.py +1 -1
  31. tinybird/{tb_cli_modules → tb/modules}/local.py +9 -5
  32. tinybird/{tb_cli_modules → tb/modules}/mock.py +5 -5
  33. tinybird/{tb_cli_modules → tb/modules}/pipe.py +11 -5
  34. tinybird/{tb_cli_modules → tb/modules}/prompts.py +1 -1
  35. tinybird/tb/modules/regions.py +9 -0
  36. tinybird/{tb_cli_modules → tb/modules}/tag.py +2 -2
  37. tinybird/tb/modules/telemetry.py +310 -0
  38. tinybird/{tb_cli_modules → tb/modules}/test.py +5 -5
  39. tinybird/{tb_cli_modules → tb/modules}/tinyunit/tinyunit.py +1 -1
  40. tinybird/{tb_cli_modules → tb/modules}/token.py +3 -3
  41. tinybird/{tb_cli_modules → tb/modules}/workspace.py +5 -5
  42. tinybird/{tb_cli_modules → tb/modules}/workspace_members.py +4 -4
  43. tinybird/tb_cli_modules/common.py +9 -25
  44. tinybird/tb_cli_modules/config.py +0 -8
  45. {tinybird-0.0.1.dev5.dist-info → tinybird-0.0.1.dev7.dist-info}/METADATA +1 -1
  46. tinybird-0.0.1.dev7.dist-info/RECORD +71 -0
  47. tinybird-0.0.1.dev7.dist-info/entry_points.txt +2 -0
  48. tinybird/datafile.py +0 -6123
  49. tinybird/tb_cli.py +0 -28
  50. tinybird-0.0.1.dev5.dist-info/RECORD +0 -52
  51. tinybird-0.0.1.dev5.dist-info/entry_points.txt +0 -2
  52. /tinybird/{tb_cli_modules → tb/modules}/table.py +0 -0
  53. /tinybird/{tb_cli_modules → tb/modules}/tinyunit/tinyunit_lib.py +0 -0
  54. {tinybird-0.0.1.dev5.dist-info → tinybird-0.0.1.dev7.dist-info}/WHEEL +0 -0
  55. {tinybird-0.0.1.dev5.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