tinybird 0.0.1.dev0__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 (45) hide show
  1. tinybird/__cli__.py +8 -0
  2. tinybird/ch_utils/constants.py +244 -0
  3. tinybird/ch_utils/engine.py +855 -0
  4. tinybird/check_pypi.py +25 -0
  5. tinybird/client.py +1281 -0
  6. tinybird/config.py +117 -0
  7. tinybird/connectors.py +428 -0
  8. tinybird/context.py +23 -0
  9. tinybird/datafile.py +5589 -0
  10. tinybird/datatypes.py +434 -0
  11. tinybird/feedback_manager.py +1022 -0
  12. tinybird/git_settings.py +145 -0
  13. tinybird/sql.py +865 -0
  14. tinybird/sql_template.py +2343 -0
  15. tinybird/sql_template_fmt.py +281 -0
  16. tinybird/sql_toolset.py +350 -0
  17. tinybird/syncasync.py +682 -0
  18. tinybird/tb_cli.py +25 -0
  19. tinybird/tb_cli_modules/auth.py +252 -0
  20. tinybird/tb_cli_modules/branch.py +1043 -0
  21. tinybird/tb_cli_modules/cicd.py +434 -0
  22. tinybird/tb_cli_modules/cli.py +1571 -0
  23. tinybird/tb_cli_modules/common.py +2082 -0
  24. tinybird/tb_cli_modules/config.py +344 -0
  25. tinybird/tb_cli_modules/connection.py +803 -0
  26. tinybird/tb_cli_modules/datasource.py +900 -0
  27. tinybird/tb_cli_modules/exceptions.py +91 -0
  28. tinybird/tb_cli_modules/fmt.py +91 -0
  29. tinybird/tb_cli_modules/job.py +85 -0
  30. tinybird/tb_cli_modules/pipe.py +858 -0
  31. tinybird/tb_cli_modules/regions.py +9 -0
  32. tinybird/tb_cli_modules/tag.py +100 -0
  33. tinybird/tb_cli_modules/telemetry.py +310 -0
  34. tinybird/tb_cli_modules/test.py +107 -0
  35. tinybird/tb_cli_modules/tinyunit/tinyunit.py +340 -0
  36. tinybird/tb_cli_modules/tinyunit/tinyunit_lib.py +71 -0
  37. tinybird/tb_cli_modules/token.py +349 -0
  38. tinybird/tb_cli_modules/workspace.py +269 -0
  39. tinybird/tb_cli_modules/workspace_members.py +212 -0
  40. tinybird/tornado_template.py +1194 -0
  41. tinybird-0.0.1.dev0.dist-info/METADATA +2815 -0
  42. tinybird-0.0.1.dev0.dist-info/RECORD +45 -0
  43. tinybird-0.0.1.dev0.dist-info/WHEEL +5 -0
  44. tinybird-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  45. tinybird-0.0.1.dev0.dist-info/top_level.txt +4 -0
@@ -0,0 +1,1571 @@
1
+ # This is a command file for our CLI. Please keep it clean.
2
+ #
3
+ # - If it makes sense and only when strictly necessary, you can create utility functions in this file.
4
+ # - But please, **do not** interleave utility functions and command definitions.
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import pprint
10
+ import re
11
+ import shutil
12
+ import sys
13
+ from os import environ, getcwd
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
16
+
17
+ import click
18
+ import humanfriendly
19
+ from click import Context
20
+ from packaging import version
21
+
22
+ import tinybird.context as context
23
+ from tinybird.client import (
24
+ AuthException,
25
+ AuthNoTokenException,
26
+ DoesNotExistException,
27
+ OperationCanNotBePerformed,
28
+ TinyB,
29
+ )
30
+ from tinybird.config import CURRENT_VERSION, SUPPORTED_CONNECTORS, VERSION, FeatureFlags, get_config
31
+ from tinybird.datafile import (
32
+ AlreadyExistsException,
33
+ CLIGitRelease,
34
+ CLIGitReleaseException,
35
+ Datafile,
36
+ ParseException,
37
+ build_graph,
38
+ create_release,
39
+ diff_command,
40
+ folder_pull,
41
+ folder_push,
42
+ get_project_filenames,
43
+ get_resource_versions,
44
+ has_internal_datafiles,
45
+ parse_datasource,
46
+ parse_pipe,
47
+ parse_token,
48
+ wait_job,
49
+ )
50
+ from tinybird.feedback_manager import FeedbackManager
51
+ from tinybird.tb_cli_modules.cicd import check_cicd_exists, init_cicd
52
+ from tinybird.tb_cli_modules.common import (
53
+ OLDEST_ROLLBACK,
54
+ CatchAuthExceptions,
55
+ CLIException,
56
+ _get_tb_client,
57
+ coro,
58
+ create_tb_client,
59
+ echo_safe_humanfriendly_tables_format_smart_table,
60
+ folder_init,
61
+ get_current_main_workspace,
62
+ getenv_bool,
63
+ is_major_semver,
64
+ is_post_semver,
65
+ load_connector_config,
66
+ remove_release,
67
+ try_update_config_with_remote,
68
+ )
69
+ from tinybird.tb_cli_modules.config import CLIConfig
70
+ from tinybird.tb_cli_modules.telemetry import add_telemetry_event
71
+
72
+ __old_click_echo = click.echo
73
+ __old_click_secho = click.secho
74
+ DEFAULT_PATTERNS: List[Tuple[str, Union[str, Callable[[str], str]]]] = [
75
+ (r"p\.ey[A-Za-z0-9-_\.]+", lambda v: f"{v[:4]}...{v[-8:]}")
76
+ ]
77
+
78
+
79
+ @click.group(cls=CatchAuthExceptions, context_settings={"help_option_names": ["-h", "--help"]})
80
+ @click.option(
81
+ "--debug/--no-debug",
82
+ default=False,
83
+ help="Prints internal representation, can be combined with any command to get more information.",
84
+ )
85
+ @click.option("--token", help="Use auth token, defaults to TB_TOKEN envvar, then to the .tinyb file")
86
+ @click.option("--host", help="Use custom host, defaults to TB_HOST envvar, then to https://api.tinybird.co")
87
+ @click.option("--semver", help="Semver of a Release to run the command. Example: 1.0.0", hidden=True)
88
+ @click.option("--gcp-project-id", help="The Google Cloud project ID", hidden=True)
89
+ @click.option(
90
+ "--gcs-bucket", help="The Google Cloud Storage bucket to write temp files when using the connectors", hidden=True
91
+ )
92
+ @click.option(
93
+ "--google-application-credentials",
94
+ envvar="GOOGLE_APPLICATION_CREDENTIALS",
95
+ help="Set GOOGLE_APPLICATION_CREDENTIALS",
96
+ hidden=True,
97
+ )
98
+ @click.option("--sf-account", help="The Snowflake Account (e.g. your-domain.west-europe.azure)", hidden=True)
99
+ @click.option("--sf-warehouse", help="The Snowflake warehouse name", hidden=True)
100
+ @click.option("--sf-database", help="The Snowflake database name", hidden=True)
101
+ @click.option("--sf-schema", help="The Snowflake schema name", hidden=True)
102
+ @click.option("--sf-role", help="The Snowflake role name", hidden=True)
103
+ @click.option("--sf-user", help="The Snowflake user name", hidden=True)
104
+ @click.option("--sf-password", help="The Snowflake password", hidden=True)
105
+ @click.option(
106
+ "--sf-storage-integration",
107
+ help="The Snowflake GCS storage integration name (leave empty to auto-generate one)",
108
+ hidden=True,
109
+ )
110
+ @click.option("--sf-stage", help="The Snowflake GCS stage name (leave empty to auto-generate one)", hidden=True)
111
+ @click.option(
112
+ "--with-headers", help="Flag to enable connector to export with headers", is_flag=True, default=False, hidden=True
113
+ )
114
+ @click.option(
115
+ "--version-warning/--no-version-warning",
116
+ envvar="TB_VERSION_WARNING",
117
+ default=True,
118
+ help="Don't print version warning message if there's a new available version. You can use TB_VERSION_WARNING envar",
119
+ )
120
+ @click.option("--show-tokens", is_flag=True, default=False, help="Enable the output of tokens")
121
+ @click.version_option(version=VERSION)
122
+ @click.pass_context
123
+ @coro
124
+ async def cli(
125
+ ctx: Context,
126
+ debug: bool,
127
+ token: str,
128
+ host: str,
129
+ semver: str,
130
+ gcp_project_id: str,
131
+ gcs_bucket: str,
132
+ google_application_credentials: str,
133
+ sf_account: str,
134
+ sf_warehouse: str,
135
+ sf_database: str,
136
+ sf_schema: str,
137
+ sf_role: str,
138
+ sf_user: str,
139
+ sf_password: str,
140
+ sf_storage_integration: str,
141
+ sf_stage,
142
+ with_headers: bool,
143
+ version_warning: bool,
144
+ show_tokens: bool,
145
+ ) -> None:
146
+ """
147
+ Use `OBFUSCATE_REGEX_PATTERN` and `OBFUSCATE_PATTERN_SEPARATOR` environment variables to define a regex pattern and a separator (in case of a single string with multiple regex) to obfuscate secrets in the CLI output.
148
+ """
149
+
150
+ # We need to unpatch for our tests not to break
151
+ if show_tokens:
152
+ __unpatch_click_output()
153
+ else:
154
+ __patch_click_output()
155
+
156
+ if getenv_bool("TB_DISABLE_SSL_CHECKS", False):
157
+ click.echo(FeedbackManager.warning_disabled_ssl_checks())
158
+
159
+ # ensure that ctx.obj exists and is a dict (in case `cli()` is called)
160
+ # by means other than the `if` block below
161
+ if not environ.get("PYTEST", None) and version_warning and not token:
162
+ from tinybird.check_pypi import CheckPypi
163
+
164
+ latest_version = await CheckPypi().get_latest_version()
165
+
166
+ if "x.y.z" in CURRENT_VERSION:
167
+ click.echo(FeedbackManager.warning_development_cli())
168
+
169
+ if "x.y.z" not in CURRENT_VERSION and latest_version != CURRENT_VERSION:
170
+ click.echo(FeedbackManager.warning_update_version(latest_version=latest_version))
171
+ click.echo(FeedbackManager.warning_current_version(current_version=CURRENT_VERSION))
172
+
173
+ if debug:
174
+ logging.basicConfig(level=logging.DEBUG)
175
+
176
+ config_temp = CLIConfig.get_project_config()
177
+ if token:
178
+ config_temp.set_token(token)
179
+ if host:
180
+ config_temp.set_host(host)
181
+ if semver:
182
+ config_temp.set_semver(semver)
183
+ if token or host or semver:
184
+ await try_update_config_with_remote(config_temp, auto_persist=False, raise_on_errors=False)
185
+
186
+ # Overwrite token and host with env vars manually, without resorting to click.
187
+ #
188
+ # We need this to avoid confusing the new config class about where are
189
+ # token and host coming from (we need to show the proper origin in
190
+ # `tb auth info`)
191
+ if not token and "TB_TOKEN" in os.environ:
192
+ token = os.environ.get("TB_TOKEN", "")
193
+ if not host and "TB_HOST" in os.environ:
194
+ host = os.environ.get("TB_HOST", "")
195
+ if not semver and "TB_SEMVER" in os.environ:
196
+ semver = os.environ.get("TB_SEMVER", "")
197
+
198
+ config = await get_config(host, token, semver)
199
+ client = _get_tb_client(config.get("token", None), config["host"])
200
+
201
+ # If they have passed a token or host as paramter and it's different that record in .tinyb, refresh the workspace id
202
+ if token or host:
203
+ try:
204
+ workspace = await client.workspace_info()
205
+ config["id"] = workspace.get("id", "")
206
+ # If we can not get this info, we continue with the id on the file
207
+ except (AuthNoTokenException, AuthException):
208
+ pass
209
+
210
+ ctx.ensure_object(dict)["config"] = config
211
+
212
+ if ctx.invoked_subcommand == "auth":
213
+ return
214
+
215
+ from tinybird.connectors import create_connector
216
+
217
+ if gcp_project_id and gcs_bucket and google_application_credentials and not sf_account:
218
+ bq_config = {
219
+ "project_id": gcp_project_id,
220
+ "bucket_name": gcs_bucket,
221
+ "service_account": google_application_credentials,
222
+ "with_headers": with_headers,
223
+ }
224
+ ctx.ensure_object(dict)["bigquery"] = create_connector("bigquery", bq_config)
225
+ if (
226
+ sf_account
227
+ and sf_warehouse
228
+ and sf_database
229
+ and sf_schema
230
+ and sf_role
231
+ and sf_user
232
+ and sf_password
233
+ and gcs_bucket
234
+ and google_application_credentials
235
+ and gcp_project_id
236
+ ):
237
+ sf_config = {
238
+ "account": sf_account,
239
+ "warehouse": sf_warehouse,
240
+ "database": sf_database,
241
+ "schema": sf_schema,
242
+ "role": sf_role,
243
+ "user": sf_user,
244
+ "password": sf_password,
245
+ "storage_integration": sf_storage_integration,
246
+ "stage": sf_stage,
247
+ "bucket_name": gcs_bucket,
248
+ "service_account": google_application_credentials,
249
+ "project_id": gcp_project_id,
250
+ "with_headers": with_headers,
251
+ }
252
+ ctx.ensure_object(dict)["snowflake"] = create_connector("snowflake", sf_config)
253
+
254
+ logging.debug("debug enabled")
255
+
256
+ ctx.ensure_object(dict)["client"] = _get_tb_client(config.get("token", None), config["host"], semver)
257
+
258
+ for connector in SUPPORTED_CONNECTORS:
259
+ load_connector_config(ctx, connector, debug, check_uninstalled=True)
260
+
261
+
262
+ @cli.command()
263
+ @click.option(
264
+ "--generate-datasources",
265
+ is_flag=True,
266
+ default=False,
267
+ help="Generate datasources based on CSV, NDJSON and Parquet files in this folder",
268
+ )
269
+ @click.option(
270
+ "--folder",
271
+ default=None,
272
+ type=click.Path(exists=True, file_okay=False),
273
+ help="Folder where datafiles will be placed",
274
+ )
275
+ @click.option("-f", "--force", is_flag=True, default=False, help="Overrides existing files")
276
+ @click.option(
277
+ "-ir",
278
+ "--ignore-remote",
279
+ is_flag=True,
280
+ default=False,
281
+ help="Ignores remote files not present in the local data project on git init",
282
+ )
283
+ @click.option(
284
+ "--git",
285
+ is_flag=True,
286
+ default=False,
287
+ help="Init workspace with git releases. Generates CI/CD files for your git provider",
288
+ )
289
+ @click.option(
290
+ "--override-commit",
291
+ default=None,
292
+ help="Use this option to manually override the reference commit of your workspace. This is useful if a commit is not recognized in your git log, such as after a force push (git push -f).",
293
+ )
294
+ @click.option(
295
+ "--cicd", is_flag=True, default=False, help="Generates only CI/CD files for your git provider", hidden=True
296
+ )
297
+ @click.pass_context
298
+ @coro
299
+ async def init(
300
+ ctx: Context,
301
+ generate_datasources: bool,
302
+ folder: Optional[str],
303
+ force: bool,
304
+ ignore_remote: bool,
305
+ git: bool,
306
+ override_commit: Optional[str],
307
+ cicd: Optional[bool],
308
+ ) -> None:
309
+ """Initialize folder layout."""
310
+ client: TinyB = ctx.ensure_object(dict)["client"]
311
+ config = CLIConfig.get_project_config()
312
+ if config.get("token") is None:
313
+ raise AuthNoTokenException
314
+ folder = folder if folder else getcwd()
315
+
316
+ workspaces: List[Dict[str, Any]] = (await client.user_workspaces_and_branches()).get("workspaces", [])
317
+ current_ws: Dict[str, Any] = next(
318
+ (workspace for workspace in workspaces if config and workspace.get("id", ".") == config.get("id", "..")), {}
319
+ )
320
+
321
+ if current_ws.get("is_branch"):
322
+ raise CLIException(FeedbackManager.error_not_allowed_in_branch())
323
+
324
+ await folder_init(client, folder, generate_datasources, generate_releases=True, force=force)
325
+
326
+ if not (git or override_commit or cicd):
327
+ return
328
+
329
+ sync_git = git or override_commit
330
+ cli_git_release = None
331
+ error = False
332
+ final_response = None
333
+ try:
334
+ cli_git_release = CLIGitRelease(path=folder)
335
+ except CLIGitReleaseException:
336
+ raise CLIGitReleaseException(FeedbackManager.error_no_git_repo_for_init(repo_path=folder))
337
+
338
+ if sync_git:
339
+ if not cli_git_release.is_main_branch() and not override_commit:
340
+ raise CLIGitReleaseException(FeedbackManager.error_no_git_main_branch())
341
+
342
+ if not cli_git_release.is_dottinyb_ignored():
343
+ raise CLIGitReleaseException(
344
+ FeedbackManager.error_dottinyb_not_ignored(git_working_dir=f"{cli_git_release.working_dir()}/")
345
+ )
346
+ else:
347
+ click.echo(FeedbackManager.info_dottinyb_already_ignored())
348
+
349
+ if (
350
+ os.path.exists(f"{cli_git_release.working_dir()}/.diff_tmp")
351
+ and not cli_git_release.is_dotdifftemp_ignored()
352
+ ):
353
+ raise CLIGitReleaseException(
354
+ FeedbackManager.error_dotdiff_not_ignored(git_working_dir=f"{cli_git_release.working_dir()}/")
355
+ )
356
+ else:
357
+ click.echo(FeedbackManager.info_dotdifftemp_already_ignored())
358
+
359
+ if "release" not in current_ws:
360
+ raise CLIGitReleaseException(FeedbackManager.error_no_correct_token_for_init())
361
+
362
+ # If we have a release and we are not overriding the commit, we check if we have a release already
363
+ elif current_ws.get("release") and not override_commit:
364
+ final_response = FeedbackManager.error_release_already_set(
365
+ workspace=current_ws["name"], commit=current_ws["release"]["commit"]
366
+ )
367
+ error = True
368
+
369
+ elif override_commit:
370
+ if click.confirm(
371
+ FeedbackManager.prompt_init_git_release_force(
372
+ current_commit=current_ws["release"]["commit"], new_commit=override_commit
373
+ )
374
+ ):
375
+ try:
376
+ release = await cli_git_release.update_release(client, current_ws, override_commit)
377
+ except Exception as exc:
378
+ raise CLIGitReleaseException(FeedbackManager.error_exception(error=str(exc)))
379
+
380
+ final_response = FeedbackManager.success_init_git_release(
381
+ workspace_name=current_ws["name"], release_commit=release["commit"]
382
+ )
383
+ else:
384
+ return
385
+
386
+ else:
387
+ click.echo(FeedbackManager.info_no_git_release_yet(workspace=current_ws["name"]))
388
+ click.echo(FeedbackManager.info_diff_resources_for_git_init())
389
+ changed = await diff_command(
390
+ [], True, client, with_print=False, verbose=False, clean_up=True, progress_bar=True
391
+ )
392
+ changed = {
393
+ k: v
394
+ for k, v in changed.items()
395
+ if v is not None and (not ignore_remote or v not in ["remote", "shared"])
396
+ }
397
+ if changed:
398
+ tb_pull_command = "tb pull --force"
399
+ click.echo(FeedbackManager.warning_git_release_init_with_diffs())
400
+ if click.confirm(FeedbackManager.prompt_init_git_release_pull(pull_command=tb_pull_command)):
401
+ await folder_pull(client, folder, auto=True, match=None, force=True)
402
+
403
+ else:
404
+ raise CLIGitReleaseException(
405
+ FeedbackManager.error_diff_resources_for_git_init(
406
+ workspace=current_ws["name"], pull_command=tb_pull_command
407
+ )
408
+ )
409
+ else:
410
+ click.echo(FeedbackManager.info_git_release_init_without_diffs(workspace=current_ws["name"]))
411
+ if cli_git_release.is_dirty_to_init():
412
+ raise CLIGitReleaseException(
413
+ FeedbackManager.error_commit_changes_to_init_release(
414
+ path=cli_git_release.path, git_output=cli_git_release.status()
415
+ )
416
+ )
417
+ try:
418
+ release = await cli_git_release.update_release(client, current_ws)
419
+ except Exception as exc:
420
+ raise CLIGitReleaseException(FeedbackManager.error_exception(error=str(exc)))
421
+
422
+ final_response = FeedbackManager.success_init_git_release(
423
+ workspace_name=current_ws["name"], release_commit=release["commit"]
424
+ )
425
+
426
+ if not override_commit:
427
+ cicd_provider = await check_cicd_exists(cli_git_release.working_dir())
428
+ if not force and cicd_provider:
429
+ if cicd:
430
+ final_response = FeedbackManager.error_cicd_already_exists(provider=cicd_provider.name)
431
+ error = True
432
+ else:
433
+ click.echo(FeedbackManager.info_cicd_already_exists(provider=cicd_provider.name))
434
+ else:
435
+ if cicd:
436
+ data_project_dir = os.path.relpath(folder, cli_git_release.working_dir())
437
+ await init_cicd(client, path=cli_git_release.working_dir(), data_project_dir=data_project_dir)
438
+
439
+ if final_response:
440
+ if error:
441
+ raise CLIException(final_response)
442
+ click.echo(final_response)
443
+
444
+
445
+ @cli.command()
446
+ @click.argument("filenames", type=click.Path(exists=True), nargs=-1, default=None)
447
+ @click.option("--debug", is_flag=True, default=False, help="Print internal representation")
448
+ def check(filenames: List[str], debug: bool) -> None:
449
+ """Check file syntax."""
450
+
451
+ if not filenames:
452
+ filenames = get_project_filenames(".")
453
+
454
+ def process(filenames: Iterable):
455
+ parser_matrix = {".pipe": parse_pipe, ".datasource": parse_datasource, ".token": parse_token}
456
+ incl_suffix = ".incl"
457
+ try:
458
+ for filename in filenames:
459
+ if os.path.isdir(filename):
460
+ process(filenames=get_project_filenames(filename))
461
+
462
+ click.echo(FeedbackManager.info_processing_file(filename=filename))
463
+
464
+ file_suffix = Path(filename).suffix
465
+ if file_suffix == incl_suffix:
466
+ click.echo(FeedbackManager.info_ignoring_incl_file(filename=filename))
467
+ continue
468
+
469
+ doc: Datafile
470
+ parser = parser_matrix.get(file_suffix)
471
+ if not parser:
472
+ raise ParseException(FeedbackManager.error_unsupported_datafile(extension=file_suffix))
473
+
474
+ doc = parser(filename)
475
+
476
+ click.echo(FeedbackManager.success_processing_file(filename=filename))
477
+ if debug:
478
+ pp = pprint.PrettyPrinter()
479
+ for x in doc.nodes:
480
+ pp.pprint(x)
481
+
482
+ except ParseException as e:
483
+ raise CLIException(FeedbackManager.error_exception(error=e))
484
+
485
+ process(filenames=filenames)
486
+
487
+
488
+ @cli.command()
489
+ @click.option(
490
+ "--dry-run",
491
+ is_flag=True,
492
+ default=False,
493
+ help="Run the command without creating resources on the Tinybird account or any side effect",
494
+ )
495
+ @click.option(
496
+ "--check/--no-check", is_flag=True, default=True, help="Enable/Disable output checking, enabled by default"
497
+ )
498
+ @click.option("--push-deps", is_flag=True, default=False, help="Push dependencies, disabled by default")
499
+ @click.option(
500
+ "--only-changes",
501
+ is_flag=True,
502
+ default=False,
503
+ help="Push only the resources that have changed compared to the destination workspace",
504
+ )
505
+ @click.option(
506
+ "--debug",
507
+ is_flag=True,
508
+ default=False,
509
+ help="Prints internal representation, can be combined with any command to get more information.",
510
+ )
511
+ @click.option("-f", "--force", is_flag=True, default=False, help="Override pipes when they already exist")
512
+ @click.option(
513
+ "--override-datasource",
514
+ is_flag=True,
515
+ default=False,
516
+ help="When pushing a pipe with a Materialized node if the target Data Source exists it will try to override it.",
517
+ )
518
+ @click.option("--populate", is_flag=True, default=False, help="Populate materialized nodes when pushing them")
519
+ @click.option(
520
+ "--subset",
521
+ type=float,
522
+ default=None,
523
+ help="Populate with a subset percent of the data (limited to a maximum of 2M rows), this is useful to quickly test a materialized node with some data. The subset must be greater than 0 and lower than 0.1. A subset of 0.1 means a 10 percent of the data in the source Data Source will be used to populate the materialized view. Use it together with --populate, it has precedence over --sql-condition",
524
+ )
525
+ @click.option(
526
+ "--sql-condition",
527
+ type=str,
528
+ default=None,
529
+ help="Populate with a SQL condition to be applied to the trigger Data Source of the Materialized View. For instance, `--sql-condition='date == toYYYYMM(now())'` it'll populate taking all the rows from the trigger Data Source which `date` is the current month. Use it together with --populate. --sql-condition is not taken into account if the --subset param is present. Including in the ``sql_condition`` any column present in the Data Source ``engine_sorting_key`` will make the populate job process less data.",
530
+ )
531
+ @click.option(
532
+ "--unlink-on-populate-error",
533
+ is_flag=True,
534
+ default=False,
535
+ help="If the populate job fails the Materialized View is unlinked and new data won't be ingested in the Materialized View. First time a populate job fails, the Materialized View is always unlinked.",
536
+ )
537
+ @click.option("--fixtures", is_flag=True, default=False, help="Append fixtures to data sources")
538
+ @click.option(
539
+ "--wait",
540
+ is_flag=True,
541
+ default=False,
542
+ help="To be used along with --populate command. Waits for populate jobs to finish, showing a progress bar. Disabled by default.",
543
+ )
544
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
545
+ @click.option(
546
+ "--only-response-times", is_flag=True, default=False, help="Checks only response times, when --force push a pipe"
547
+ )
548
+ @click.argument("filenames", type=click.Path(exists=True), nargs=-1, default=None)
549
+ @click.option("--workspace_map", nargs=2, type=str, multiple=True)
550
+ @click.option(
551
+ "--workspace",
552
+ nargs=2,
553
+ type=str,
554
+ multiple=True,
555
+ help="add a workspace path to the list of external workspaces, usage: --workspace name path/to/folder",
556
+ )
557
+ @click.option(
558
+ "--no-versions",
559
+ is_flag=True,
560
+ default=False,
561
+ help="when set, resource dependency versions are not used, it pushes the dependencies as-is",
562
+ )
563
+ @click.option(
564
+ "-l", "--limit", type=click.IntRange(0, 100), default=0, required=False, help="Number of requests to validate"
565
+ )
566
+ @click.option(
567
+ "--sample-by-params",
568
+ type=click.IntRange(1, 100),
569
+ default=1,
570
+ required=False,
571
+ help="When set, we will aggregate the pipe_stats_rt requests by extractURLParameterNames(assumeNotNull(url)) and for each combination we will take a sample of N requests",
572
+ )
573
+ @click.option(
574
+ "-ff", "--failfast", is_flag=True, default=False, help="When set, the checker will exit as soon one test fails"
575
+ )
576
+ @click.option(
577
+ "--ignore-order", is_flag=True, default=False, help="When set, the checker will ignore the order of list properties"
578
+ )
579
+ @click.option(
580
+ "--validate-processed-bytes",
581
+ is_flag=True,
582
+ default=False,
583
+ help="When set, the checker will validate that the new version doesn't process more than 25% than the current version",
584
+ )
585
+ @click.option(
586
+ "--check-requests-from-main",
587
+ is_flag=True,
588
+ default=False,
589
+ help="When set, the checker will get Main Workspace requests",
590
+ hidden=True,
591
+ )
592
+ @click.option(
593
+ "--folder",
594
+ default=".",
595
+ help="Folder from where to execute the command. By default the current folder",
596
+ hidden=True,
597
+ type=click.types.STRING,
598
+ )
599
+ @click.option(
600
+ "--user_token",
601
+ is_flag=False,
602
+ default=None,
603
+ help="The user token is required for sharing a datasource that contains the SHARED_WITH entry.",
604
+ type=click.types.STRING,
605
+ )
606
+ @click.pass_context
607
+ @coro
608
+ async def push(
609
+ ctx: Context,
610
+ filenames: Optional[List[str]],
611
+ dry_run: bool,
612
+ check: bool,
613
+ push_deps: bool,
614
+ only_changes: bool,
615
+ debug: bool,
616
+ force: bool,
617
+ override_datasource: bool,
618
+ populate: bool,
619
+ subset: Optional[float],
620
+ sql_condition: Optional[str],
621
+ unlink_on_populate_error: bool,
622
+ fixtures: bool,
623
+ wait: bool,
624
+ yes: bool,
625
+ only_response_times: bool,
626
+ workspace_map,
627
+ workspace,
628
+ no_versions: bool,
629
+ limit: int,
630
+ sample_by_params: int,
631
+ failfast: bool,
632
+ ignore_order: bool,
633
+ validate_processed_bytes: bool,
634
+ check_requests_from_main: bool,
635
+ folder: str,
636
+ user_token: Optional[str],
637
+ ) -> None:
638
+ """Push files to Tinybird."""
639
+
640
+ ignore_sql_errors = FeatureFlags.ignore_sql_errors()
641
+ context.disable_template_security_validation.set(True)
642
+
643
+ is_internal = has_internal_datafiles(folder)
644
+
645
+ await folder_push(
646
+ create_tb_client(ctx),
647
+ filenames,
648
+ dry_run,
649
+ check,
650
+ push_deps,
651
+ only_changes,
652
+ debug=debug,
653
+ force=force,
654
+ override_datasource=override_datasource,
655
+ populate=populate,
656
+ populate_subset=subset,
657
+ populate_condition=sql_condition,
658
+ unlink_on_populate_error=unlink_on_populate_error,
659
+ upload_fixtures=fixtures,
660
+ wait=wait,
661
+ ignore_sql_errors=ignore_sql_errors,
662
+ skip_confirmation=yes,
663
+ only_response_times=only_response_times,
664
+ workspace_map=dict(workspace_map),
665
+ workspace_lib_paths=workspace,
666
+ no_versions=no_versions,
667
+ run_tests=False,
668
+ tests_to_run=limit,
669
+ tests_sample_by_params=sample_by_params,
670
+ tests_failfast=failfast,
671
+ tests_ignore_order=ignore_order,
672
+ tests_validate_processed_bytes=validate_processed_bytes,
673
+ tests_check_requests_from_branch=check_requests_from_main,
674
+ folder=folder,
675
+ config=CLIConfig.get_project_config(),
676
+ user_token=user_token,
677
+ is_internal=is_internal,
678
+ )
679
+ return
680
+
681
+
682
+ @cli.command()
683
+ @click.option(
684
+ "--folder", default=None, type=click.Path(exists=True, file_okay=False), help="Folder where files will be placed"
685
+ )
686
+ @click.option(
687
+ "--auto/--no-auto",
688
+ is_flag=True,
689
+ default=True,
690
+ help="Saves datafiles automatically into their default directories (/datasources or /pipes). Default is True",
691
+ )
692
+ @click.option("--match", default=None, help="Retrieve any resourcing matching the pattern. eg --match _test")
693
+ @click.option("-f", "--force", is_flag=True, default=False, help="Override existing files")
694
+ @click.option("--fmt", is_flag=True, default=False, help="Format files before saving")
695
+ @click.pass_context
696
+ @coro
697
+ async def pull(ctx: Context, folder: str, auto: bool, match: Optional[str], force: bool, fmt: bool) -> None:
698
+ """Retrieve latest versions for project files from Tinybird."""
699
+
700
+ client = ctx.ensure_object(dict)["client"]
701
+ folder = folder if folder else getcwd()
702
+
703
+ return await folder_pull(client, folder, auto, match, force, fmt=fmt)
704
+
705
+
706
+ @cli.command()
707
+ @click.option("--no-deps", is_flag=True, default=False, help="Print only data sources with no pipes using them")
708
+ @click.option("--match", default=None, help="Retrieve any resource matching the pattern")
709
+ @click.option("--pipe", default=None, help="Retrieve any resource used by pipe")
710
+ @click.option("--datasource", default=None, help="Retrieve resources depending on this Data Source")
711
+ @click.option(
712
+ "--check-for-partial-replace",
713
+ is_flag=True,
714
+ default=False,
715
+ help="Retrieve dependant Data Sources that will have their data replaced if a partial replace is executed in the Data Source selected",
716
+ )
717
+ @click.option("--recursive", is_flag=True, default=False, help="Calculate recursive dependencies")
718
+ @click.pass_context
719
+ @coro
720
+ async def dependencies(
721
+ ctx: Context,
722
+ no_deps: bool,
723
+ match: Optional[str],
724
+ pipe: Optional[str],
725
+ datasource: Optional[str],
726
+ check_for_partial_replace: bool,
727
+ recursive: bool,
728
+ ) -> None:
729
+ """Print all data sources dependencies."""
730
+
731
+ client = ctx.ensure_object(dict)["client"]
732
+
733
+ response = await client.datasource_dependencies(
734
+ no_deps, match, pipe, datasource, check_for_partial_replace, recursive
735
+ )
736
+ for ds in response["dependencies"]:
737
+ click.echo(FeedbackManager.info_dependency_list(dependency=ds))
738
+ for pipe in response["dependencies"][ds]:
739
+ click.echo(FeedbackManager.info_dependency_list_item(dependency=pipe))
740
+ if "incompatible_datasources" in response and len(response["incompatible_datasources"]):
741
+ click.echo(FeedbackManager.info_no_compatible_dependencies_found())
742
+ for ds in response["incompatible_datasources"]:
743
+ click.echo(FeedbackManager.info_dependency_list(dependency=ds))
744
+ raise CLIException(FeedbackManager.error_partial_replace_cant_be_executed(datasource=datasource))
745
+
746
+
747
+ @cli.command(
748
+ name="diff",
749
+ short_help="Diffs local datafiles to the corresponding remote files in the workspace. For the case of .datasource files it just diffs VERSION and SCHEMA, since ENGINE, KAFKA or other metadata is considered immutable.",
750
+ )
751
+ @click.argument("filename", type=click.Path(exists=True), nargs=-1, required=False)
752
+ @click.option(
753
+ "--fmt/--no-fmt",
754
+ is_flag=True,
755
+ default=True,
756
+ help="Format files before doing the diff, default is True so both files match the format",
757
+ )
758
+ @click.option("--no-color", is_flag=True, default=False, help="Don't colorize diff")
759
+ @click.option(
760
+ "--no-verbose", is_flag=True, default=False, help="List the resources changed not the content of the diff"
761
+ )
762
+ @click.option(
763
+ "--main",
764
+ is_flag=True,
765
+ default=False,
766
+ help="Diffs local datafiles to the corresponding remote files in the main workspace. Only works when authenticated on a Branch.",
767
+ hidden=True,
768
+ )
769
+ @click.pass_context
770
+ @coro
771
+ async def diff(
772
+ ctx: Context, filename: Optional[Tuple], fmt: bool, no_color: bool, no_verbose: bool, main: bool
773
+ ) -> None:
774
+ only_resources_changed = no_verbose
775
+ client: TinyB = ctx.ensure_object(dict)["client"]
776
+
777
+ if not main:
778
+ changed = await diff_command(
779
+ list(filename) if filename else None, fmt, client, no_color, with_print=not only_resources_changed
780
+ )
781
+ else:
782
+ config = CLIConfig.get_project_config()
783
+
784
+ response = await client.user_workspaces_and_branches()
785
+ ws_client = None
786
+ for workspace in response["workspaces"]:
787
+ if config["id"] == workspace["id"]:
788
+ if not workspace.get("is_branch"):
789
+ raise CLIException(FeedbackManager.error_not_a_branch())
790
+
791
+ origin = workspace["main"]
792
+ workspace = await get_current_main_workspace(config)
793
+
794
+ if not workspace:
795
+ raise CLIException(FeedbackManager.error_workspace(workspace=origin))
796
+
797
+ ws_client = _get_tb_client(workspace["token"], config["host"])
798
+ break
799
+
800
+ if not ws_client:
801
+ raise CLIException(FeedbackManager.error_workspace(workspace=origin))
802
+ changed = await diff_command(
803
+ list(filename) if filename else None, fmt, ws_client, no_color, with_print=not only_resources_changed
804
+ )
805
+
806
+ if only_resources_changed:
807
+ click.echo("\n")
808
+ for resource, status in dict(sorted(changed.items(), key=lambda item: str(item[1]))).items():
809
+ if status is None:
810
+ continue
811
+ status = "changed" if status not in ["remote", "local", "shared"] else status
812
+ click.echo(f"{status}: {resource}")
813
+
814
+
815
+ @cli.command()
816
+ @click.argument("query", required=False)
817
+ @click.option("--rows_limit", default=100, help="Max number of rows retrieved")
818
+ @click.option("--pipeline", default=None, help="The name of the Pipe to run the SQL Query")
819
+ @click.option("--pipe", default=None, help="The path to the .pipe file to run the SQL Query of a specific NODE")
820
+ @click.option("--node", default=None, help="The NODE name")
821
+ @click.option(
822
+ "--format",
823
+ "format_",
824
+ type=click.Choice(["json", "csv", "human"], case_sensitive=False),
825
+ default="human",
826
+ help="Output format",
827
+ )
828
+ @click.option("--stats/--no-stats", default=False, help="Show query stats")
829
+ @click.pass_context
830
+ @coro
831
+ async def sql(
832
+ ctx: Context,
833
+ query: str,
834
+ rows_limit: int,
835
+ pipeline: Optional[str],
836
+ pipe: Optional[str],
837
+ node: Optional[str],
838
+ format_: str,
839
+ stats: bool,
840
+ ) -> None:
841
+ """Run SQL query over data sources and pipes."""
842
+
843
+ client = ctx.ensure_object(dict)["client"]
844
+ req_format = "CSVWithNames" if format_ == "csv" else "JSON"
845
+ res = None
846
+ try:
847
+ if query:
848
+ q = query.lower().strip()
849
+ if q.startswith("insert"):
850
+ click.echo(FeedbackManager.info_append_data())
851
+ raise CLIException(FeedbackManager.error_invalid_query())
852
+ if q.startswith("delete"):
853
+ raise CLIException(FeedbackManager.error_invalid_query())
854
+ res = await client.query(
855
+ f"SELECT * FROM ({query}) LIMIT {rows_limit} FORMAT {req_format}", pipeline=pipeline
856
+ )
857
+ elif pipe and node:
858
+ datasources: List[Dict[str, Any]] = await client.datasources()
859
+ pipes: List[Dict[str, Any]] = await client.pipes()
860
+
861
+ existing_resources: List[str] = [x["name"] for x in datasources] + [x["name"] for x in pipes]
862
+ resource_versions = get_resource_versions(existing_resources)
863
+ filenames = [pipe]
864
+
865
+ # build graph to get new versions for all the files involved in the query
866
+ # dependencies need to be processed always to get the versions
867
+ dependencies_graph = await build_graph(
868
+ filenames,
869
+ client,
870
+ dir_path=".",
871
+ process_dependencies=True,
872
+ skip_connectors=True,
873
+ )
874
+
875
+ # update existing versions
876
+ latest_datasource_versions = resource_versions.copy()
877
+
878
+ for dep in dependencies_graph.to_run.values():
879
+ ds = dep["resource_name"]
880
+ if dep["version"] is not None:
881
+ latest_datasource_versions[ds] = dep["version"]
882
+
883
+ # build the graph again with the rigth version
884
+ dependencies_graph = await build_graph(
885
+ filenames,
886
+ client,
887
+ dir_path=".",
888
+ resource_versions=latest_datasource_versions,
889
+ verbose=False,
890
+ )
891
+ query = ""
892
+ for _, elem in dependencies_graph.to_run.items():
893
+ for _node in elem["nodes"]:
894
+ if _node["params"]["name"].lower() == node.lower():
895
+ query = "".join(_node["sql"])
896
+ pipeline = pipe.split("/")[-1].split(".pipe")[0]
897
+ res = await client.query(
898
+ f"SELECT * FROM ({query}) LIMIT {rows_limit} FORMAT {req_format}", pipeline=pipeline
899
+ )
900
+
901
+ except AuthNoTokenException:
902
+ raise
903
+ except Exception as e:
904
+ raise CLIException(FeedbackManager.error_exception(error=str(e)))
905
+
906
+ if isinstance(res, dict) and "error" in res:
907
+ raise CLIException(FeedbackManager.error_exception(error=res["error"]))
908
+
909
+ if stats:
910
+ stats_query = f"SELECT * FROM ({query}) LIMIT {rows_limit} FORMAT JSON"
911
+ stats_res = await client.query(stats_query, pipeline=pipeline)
912
+ stats_dict = stats_res["statistics"]
913
+ seconds = stats_dict["elapsed"]
914
+ rows_read = humanfriendly.format_number(stats_dict["rows_read"])
915
+ bytes_read = humanfriendly.format_size(stats_dict["bytes_read"])
916
+ click.echo(FeedbackManager.info_query_stats(seconds=seconds, rows=rows_read, bytes=bytes_read))
917
+
918
+ if format_ == "csv":
919
+ click.echo(res)
920
+ elif isinstance(res, dict) and "data" in res and res["data"]:
921
+ if format_ == "json":
922
+ click.echo(json.dumps(res, indent=8))
923
+ else:
924
+ dd = []
925
+ for d in res["data"]:
926
+ dd.append(d.values())
927
+ echo_safe_humanfriendly_tables_format_smart_table(dd, column_names=res["data"][0].keys())
928
+ else:
929
+ click.echo(FeedbackManager.info_no_rows())
930
+
931
+
932
+ @cli.command(
933
+ name="materialize",
934
+ short_help="Given a local Pipe datafile (.pipe) and a node name it generates the target Data Source and materialized Pipe ready to be pushed and guides you through the process to create the materialized view",
935
+ )
936
+ @click.argument("filename", type=click.Path(exists=True))
937
+ @click.argument("target_datasource", default=None, required=False)
938
+ @click.option("--push-deps", is_flag=True, default=False, help="Push dependencies, disabled by default")
939
+ @click.option("--workspace_map", nargs=2, type=str, multiple=True, hidden=True)
940
+ @click.option(
941
+ "--workspace",
942
+ nargs=2,
943
+ type=str,
944
+ multiple=True,
945
+ help="add a workspace path to the list of external workspaces, usage: --workspace name path/to/folder",
946
+ )
947
+ @click.option(
948
+ "--no-versions",
949
+ is_flag=True,
950
+ default=False,
951
+ help="when set, resource dependency versions are not used, it pushes the dependencies as-is",
952
+ )
953
+ @click.option("--verbose", is_flag=True, default=False, help="Prints more log")
954
+ @click.option("--force-populate", default=None, required=False, help="subset or full", hidden=True)
955
+ @click.option(
956
+ "--unlink-on-populate-error",
957
+ is_flag=True,
958
+ default=False,
959
+ help="If the populate job fails the Materialized View is unlinked and new data won't be ingested in the Materialized View. First time a populate job fails, the Materialized View is always unlinked.",
960
+ )
961
+ @click.option("--override-pipe", is_flag=True, default=False, help="Override pipe if exists or prompt", hidden=True)
962
+ @click.option(
963
+ "--override-datasource", is_flag=True, default=False, help="Override data source if exists or prompt", hidden=True
964
+ )
965
+ @click.pass_context
966
+ @coro
967
+ async def materialize(
968
+ ctx: Context,
969
+ filename: str,
970
+ push_deps: bool,
971
+ workspace_map: List[str],
972
+ workspace: List[str],
973
+ no_versions: bool,
974
+ verbose: bool,
975
+ force_populate: Optional[str],
976
+ unlink_on_populate_error: bool,
977
+ override_pipe: bool,
978
+ override_datasource: bool,
979
+ target_datasource: Optional[str] = None,
980
+ ) -> None:
981
+ deprecation_notice = FeedbackManager.warning_deprecated(
982
+ warning="'tb materialize' is deprecated. To create a Materialized View in a guided way you can use the UI: `Create Pipe` and use the `Create Materialized View` wizard. Finally download the resulting `.pipe` and `.datasource` files."
983
+ )
984
+ click.echo(deprecation_notice)
985
+ cl = create_tb_client(ctx)
986
+
987
+ async def _try_push_pipe_to_analyze(pipe_name):
988
+ try:
989
+ to_run = await folder_push(
990
+ cl,
991
+ filenames=[filename],
992
+ dry_run=False,
993
+ check=False,
994
+ push_deps=push_deps,
995
+ debug=False,
996
+ force=False,
997
+ workspace_map=dict(workspace_map),
998
+ workspace_lib_paths=workspace,
999
+ no_versions=no_versions,
1000
+ run_tests=False,
1001
+ as_standard=True,
1002
+ raise_on_exists=True,
1003
+ verbose=verbose,
1004
+ )
1005
+ except AlreadyExistsException as e:
1006
+ if "Datasource" in str(e):
1007
+ click.echo(str(e))
1008
+ return
1009
+ if override_pipe or click.confirm(FeedbackManager.info_pipe_exists(name=pipe_name)):
1010
+ to_run = await folder_push(
1011
+ cl,
1012
+ filenames=[filename],
1013
+ dry_run=False,
1014
+ check=False,
1015
+ push_deps=push_deps,
1016
+ debug=False,
1017
+ force=True,
1018
+ workspace_map=dict(workspace_map),
1019
+ workspace_lib_paths=workspace,
1020
+ no_versions=no_versions,
1021
+ run_tests=False,
1022
+ as_standard=True,
1023
+ verbose=verbose,
1024
+ )
1025
+ else:
1026
+ return
1027
+ except click.ClickException as ex:
1028
+ # HACK: By now, datafile raises click.ClickException instead of
1029
+ # CLIException to avoid circular imports. Thats we need to trace
1030
+ # the error here.
1031
+ #
1032
+ # Once we do a big refactor in datafile, we can get rid of this
1033
+ # snippet.
1034
+ msg: str = str(ex)
1035
+ add_telemetry_event("datafile_error", error=msg)
1036
+ click.echo(msg)
1037
+
1038
+ return to_run
1039
+
1040
+ def _choose_node_name(pipe):
1041
+ node = pipe["nodes"][0]
1042
+ materialized_nodes = [node for node in pipe["nodes"] if node["type"].lower() == "materialized"]
1043
+
1044
+ if len(materialized_nodes) == 1:
1045
+ node = materialized_nodes[0]
1046
+
1047
+ if len(pipe["nodes"]) > 1 and len(materialized_nodes) != 1:
1048
+ for index, node in enumerate(pipe["nodes"], start=1):
1049
+ click.echo(f" [{index}] Materialize node with name => {node['name']}")
1050
+ option = click.prompt(FeedbackManager.prompt_choose_node(), default=len(pipe["nodes"]))
1051
+ node = pipe["nodes"][option - 1]
1052
+ node_name = node["name"]
1053
+ return node, node_name
1054
+
1055
+ def _choose_target_datasource_name(pipe, node, node_name):
1056
+ return target_datasource or node.get("datasource", None) or f'mv_{pipe["resource_name"]}_{node_name}'
1057
+
1058
+ def _save_local_backup_pipe(pipe):
1059
+ pipe_bak = f"{filename}_bak"
1060
+ shutil.copyfile(filename, pipe_bak)
1061
+ pipe_file_name = f"{pipe['resource_name']}.pipe"
1062
+ click.echo(FeedbackManager.info_pipe_backup_created(name=pipe_bak))
1063
+ return pipe_file_name
1064
+
1065
+ def _save_local_datasource(datasource_name, ds_datafile):
1066
+ base = Path("datasources")
1067
+ if not base.exists():
1068
+ base = Path()
1069
+ file_name = f"{datasource_name}.datasource"
1070
+ f = base / file_name
1071
+ with open(f"{f}", "w") as file:
1072
+ file.write(ds_datafile)
1073
+
1074
+ click.echo(FeedbackManager.success_generated_local_file(file=f))
1075
+ return f
1076
+
1077
+ async def _try_push_datasource(datasource_name, f):
1078
+ exists = False
1079
+ try:
1080
+ exists = await cl.get_datasource(datasource_name)
1081
+ except Exception:
1082
+ pass
1083
+
1084
+ if exists:
1085
+ click.echo(FeedbackManager.info_materialize_push_datasource_exists(name=f.name))
1086
+ if override_datasource or click.confirm(FeedbackManager.info_materialize_push_datasource_override(name=f)):
1087
+ try:
1088
+ await cl.datasource_delete(datasource_name, force=True)
1089
+ except DoesNotExistException:
1090
+ pass
1091
+
1092
+ filename = str(f.absolute())
1093
+ to_run = await folder_push(
1094
+ cl,
1095
+ filenames=[filename],
1096
+ push_deps=push_deps,
1097
+ workspace_map=dict(workspace_map),
1098
+ workspace_lib_paths=workspace,
1099
+ no_versions=no_versions,
1100
+ verbose=verbose,
1101
+ )
1102
+ return to_run
1103
+
1104
+ def _save_local_pipe(pipe_file_name, pipe_datafile, pipe):
1105
+ base = Path("pipes")
1106
+ if not base.exists():
1107
+ base = Path()
1108
+ f_pipe = base / pipe_file_name
1109
+
1110
+ with open(f"{f_pipe}", "w") as file:
1111
+ if pipe["version"] is not None and pipe["version"] >= 0:
1112
+ pipe_datafile = f"VERSION {pipe['version']} \n {pipe_datafile}"
1113
+ matches = re.findall(r"([^\s\.]*__v\d+)", pipe_datafile)
1114
+ for match in set(matches):
1115
+ m = match.split("__v")[0]
1116
+ if m in pipe_datafile:
1117
+ pipe_datafile = pipe_datafile.replace(match, m)
1118
+ file.write(pipe_datafile)
1119
+
1120
+ click.echo(FeedbackManager.success_generated_local_file(file=f_pipe))
1121
+ return f_pipe
1122
+
1123
+ async def _try_push_pipe(f_pipe):
1124
+ if override_pipe:
1125
+ option = 2
1126
+ else:
1127
+ click.echo(FeedbackManager.info_materialize_push_pipe_skip(name=f_pipe.name))
1128
+ click.echo(FeedbackManager.info_materialize_push_pipe_override(name=f_pipe.name))
1129
+ option = click.prompt(FeedbackManager.prompt_choose(), default=1)
1130
+ force = True
1131
+ check = option == 1
1132
+
1133
+ filename = str(f_pipe.absolute())
1134
+ to_run = await folder_push(
1135
+ cl,
1136
+ filenames=[filename],
1137
+ dry_run=False,
1138
+ check=check,
1139
+ push_deps=push_deps,
1140
+ debug=False,
1141
+ force=force,
1142
+ unlink_on_populate_error=unlink_on_populate_error,
1143
+ workspace_map=dict(workspace_map),
1144
+ workspace_lib_paths=workspace,
1145
+ no_versions=no_versions,
1146
+ run_tests=False,
1147
+ verbose=verbose,
1148
+ )
1149
+ return to_run
1150
+
1151
+ async def _populate(pipe, node_name, f_pipe):
1152
+ if force_populate or click.confirm(FeedbackManager.prompt_populate(file=f_pipe)):
1153
+ if not force_populate:
1154
+ click.echo(FeedbackManager.info_materialize_populate_partial())
1155
+ click.echo(FeedbackManager.info_materialize_populate_full())
1156
+ option = click.prompt(FeedbackManager.prompt_choose(), default=1)
1157
+ else:
1158
+ option = 1 if force_populate == "subset" else 2
1159
+ populate = False
1160
+ populate_subset = False
1161
+ if option == 1:
1162
+ populate_subset = 0.1
1163
+ populate = True
1164
+ elif option == 2:
1165
+ populate = True
1166
+
1167
+ if populate:
1168
+ response = await cl.populate_node(
1169
+ pipe["name"],
1170
+ node_name,
1171
+ populate_subset=populate_subset,
1172
+ unlink_on_populate_error=unlink_on_populate_error,
1173
+ )
1174
+ if "job" not in response:
1175
+ raise CLIException(response)
1176
+
1177
+ job_url = response.get("job", {}).get("job_url", None)
1178
+ job_id = response.get("job", {}).get("job_id", None)
1179
+ click.echo(FeedbackManager.info_populate_job_url(url=job_url))
1180
+ wait_populate = True
1181
+ if wait_populate:
1182
+ await wait_job(cl, job_id, job_url, "Populating")
1183
+
1184
+ click.echo(FeedbackManager.warning_beta_tester())
1185
+ pipe_name = os.path.basename(filename).rsplit(".", 1)[0]
1186
+ click.echo(FeedbackManager.info_before_push_materialize(name=filename))
1187
+ try:
1188
+ # extracted the materialize logic to local functions so the workflow is more readable
1189
+ to_run = await _try_push_pipe_to_analyze(pipe_name)
1190
+
1191
+ if to_run is None:
1192
+ return
1193
+
1194
+ pipe = to_run[pipe_name.split("/")[-1]]
1195
+ node, node_name = _choose_node_name(pipe)
1196
+ datasource_name = _choose_target_datasource_name(pipe, node, node_name)
1197
+
1198
+ click.echo(FeedbackManager.info_before_materialize(name=pipe["name"]))
1199
+ analysis = await cl.analyze_pipe_node(pipe["name"], node, datasource_name=datasource_name)
1200
+ ds_datafile = analysis["analysis"]["datasource"]["datafile"]
1201
+ pipe_datafile = analysis["analysis"]["pipe"]["datafile"]
1202
+
1203
+ pipe_file_name = _save_local_backup_pipe(pipe)
1204
+ f = _save_local_datasource(datasource_name, ds_datafile)
1205
+ await _try_push_datasource(datasource_name, f)
1206
+
1207
+ f_pipe = _save_local_pipe(pipe_file_name, pipe_datafile, pipe)
1208
+ await _try_push_pipe(f_pipe)
1209
+ await _populate(pipe, node_name, f_pipe)
1210
+ click.echo(FeedbackManager.success_created_matview(name=datasource_name))
1211
+ except AuthNoTokenException:
1212
+ raise
1213
+ except Exception as e:
1214
+ raise CLIException(FeedbackManager.error_exception(error=str(e)))
1215
+
1216
+
1217
+ def __patch_click_output():
1218
+ import re
1219
+
1220
+ CUSTOM_PATTERNS: List[str] = []
1221
+
1222
+ _env_patterns = os.getenv("OBFUSCATE_REGEX_PATTERN", None)
1223
+ if _env_patterns:
1224
+ CUSTOM_PATTERNS = _env_patterns.split(os.getenv("OBFUSCATE_PATTERN_SEPARATOR", "|"))
1225
+
1226
+ def _obfuscate(msg: Any, *args: Any, **kwargs: Any) -> Any:
1227
+ for pattern in CUSTOM_PATTERNS:
1228
+ msg = re.sub(pattern, "****...****", str(msg))
1229
+
1230
+ for pattern, substitution in DEFAULT_PATTERNS:
1231
+ if isinstance(substitution, str):
1232
+ msg = re.sub(pattern, substitution, str(msg))
1233
+ else:
1234
+ msg = re.sub(pattern, lambda m: substitution(m.group(0)), str(msg)) # noqa
1235
+ return msg
1236
+
1237
+ def _obfuscate_echo(msg: Any, *args: Any, **kwargs: Any) -> None:
1238
+ msg = _obfuscate(msg, *args, **kwargs)
1239
+ __old_click_echo(msg, *args, **kwargs)
1240
+
1241
+ def _obfuscate_secho(msg: Any, *args: Any, **kwargs: Any) -> None:
1242
+ msg = _obfuscate(msg, *args, **kwargs)
1243
+ __old_click_secho(msg, *args, **kwargs)
1244
+
1245
+ click.echo = lambda msg, *args, **kwargs: _obfuscate_echo(msg, *args, **kwargs)
1246
+ click.secho = lambda msg, *args, **kwargs: _obfuscate_secho(msg, *args, **kwargs)
1247
+
1248
+
1249
+ def __unpatch_click_output():
1250
+ click.echo = __old_click_echo
1251
+ click.secho = __old_click_echo
1252
+
1253
+
1254
+ @cli.command(short_help="Learn how to include info about the CLI in your shell PROMPT")
1255
+ @click.pass_context
1256
+ @coro
1257
+ async def prompt(_ctx: Context) -> None:
1258
+ click.secho("Follow these instructions => https://www.tinybird.co/docs/cli.html#configure-the-shell-prompt")
1259
+
1260
+
1261
+ @cli.command()
1262
+ @click.option(
1263
+ "--dry-run",
1264
+ is_flag=True,
1265
+ default=False,
1266
+ help="Run the command without creating resources on the Tinybird account or any side effect",
1267
+ )
1268
+ @click.option(
1269
+ "--debug",
1270
+ is_flag=True,
1271
+ default=False,
1272
+ help="Prints internal representation, can be combined with any command to get more information.",
1273
+ )
1274
+ @click.option(
1275
+ "--override-datasource",
1276
+ is_flag=True,
1277
+ default=False,
1278
+ help="When pushing a pipe with a Materialized node if the target Data Source exists it will try to override it.",
1279
+ )
1280
+ @click.option("--populate", is_flag=True, default=False, help="Populate materialized nodes when pushing them")
1281
+ @click.option(
1282
+ "--subset",
1283
+ type=float,
1284
+ default=None,
1285
+ help="Populate with a subset percent of the data (limited to a maximum of 2M rows), this is useful to quickly test a materialized node with some data. The subset must be greater than 0 and lower than 0.1. A subset of 0.1 means a 10 percent of the data in the source Data Source will be used to populate the materialized view. Use it together with --populate, it has precedence over --sql-condition",
1286
+ )
1287
+ @click.option(
1288
+ "--sql-condition",
1289
+ type=str,
1290
+ default=None,
1291
+ help="Populate with a SQL condition to be applied to the trigger Data Source of the Materialized View. For instance, `--sql-condition='date == toYYYYMM(now())'` it'll populate taking all the rows from the trigger Data Source which `date` is the current month. Use it together with --populate. --sql-condition is not taken into account if the --subset param is present. Including in the ``sql_condition`` any column present in the Data Source ``engine_sorting_key`` will make the populate job process less data.",
1292
+ )
1293
+ @click.option(
1294
+ "--unlink-on-populate-error",
1295
+ is_flag=True,
1296
+ default=False,
1297
+ help="If the populate job fails the Materialized View is unlinked and new data won't be ingested in the Materialized View. First time a populate job fails, the Materialized View is always unlinked.",
1298
+ )
1299
+ @click.option(
1300
+ "--wait",
1301
+ is_flag=True,
1302
+ default=False,
1303
+ help="To be used along with --populate command. Waits for populate jobs to finish, showing a progress bar. Disabled by default.",
1304
+ )
1305
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
1306
+ @click.option("--workspace_map", nargs=2, type=str, multiple=True)
1307
+ @click.option(
1308
+ "--workspace",
1309
+ nargs=2,
1310
+ type=str,
1311
+ multiple=True,
1312
+ help="add a workspace path to the list of external workspaces, usage: --workspace name path/to/folder",
1313
+ )
1314
+ @click.option(
1315
+ "--folder",
1316
+ default=".",
1317
+ help="Folder from where to execute the command. By default the current folder",
1318
+ hidden=True,
1319
+ type=click.types.STRING,
1320
+ )
1321
+ @click.option(
1322
+ "--user_token",
1323
+ is_flag=False,
1324
+ default=None,
1325
+ help="The user token is required for sharing a datasource that contains the SHARED_WITH entry.",
1326
+ type=click.types.STRING,
1327
+ )
1328
+ @click.option(
1329
+ "--fork-downstream",
1330
+ is_flag=True,
1331
+ default=False,
1332
+ help="Creates new versions of the dependent downstream resources to the changed resources.",
1333
+ hidden=True,
1334
+ )
1335
+ @click.option(
1336
+ "--fork",
1337
+ is_flag=True,
1338
+ default=False,
1339
+ help="Creates new versions of the changed resources. Use --fork-downstream to fork also the downstream dependencies of the changed resources.",
1340
+ hidden=True,
1341
+ )
1342
+ # this is added to use tb deploy in dry run mode. It's temprary => https://gitlab.com/tinybird/analytics/-/issues/12551
1343
+ @click.option(
1344
+ "--use-main",
1345
+ is_flag=True,
1346
+ default=False,
1347
+ help="Use main commit instead of release commit",
1348
+ hidden=True,
1349
+ )
1350
+ @click.option(
1351
+ "--skip-head-outdated",
1352
+ is_flag=True,
1353
+ default=False,
1354
+ help="Allows to deploy any commit without checking if the branch is rebased to the workspace commit",
1355
+ hidden=True,
1356
+ )
1357
+ @click.pass_context
1358
+ @coro
1359
+ async def deploy(
1360
+ ctx: Context,
1361
+ dry_run: bool,
1362
+ debug: bool,
1363
+ override_datasource: bool,
1364
+ populate: bool,
1365
+ subset: Optional[float],
1366
+ sql_condition: Optional[str],
1367
+ unlink_on_populate_error: bool,
1368
+ wait: bool,
1369
+ yes: bool,
1370
+ workspace_map,
1371
+ workspace,
1372
+ folder: str,
1373
+ user_token: Optional[str],
1374
+ fork_downstream: bool,
1375
+ fork: bool,
1376
+ use_main: bool,
1377
+ skip_head_outdated: bool,
1378
+ ) -> None:
1379
+ """Deploy in Tinybird pushing resources changed from last git commit deployed.
1380
+
1381
+ Usage: tb deploy
1382
+ """
1383
+ try:
1384
+ ignore_sql_errors = FeatureFlags.ignore_sql_errors()
1385
+ context.disable_template_security_validation.set(True)
1386
+
1387
+ is_internal = has_internal_datafiles(folder)
1388
+
1389
+ client: TinyB = ctx.ensure_object(dict)["client"]
1390
+ config = CLIConfig.get_project_config()
1391
+ workspaces: List[Dict[str, Any]] = (await client.user_workspaces_and_branches()).get("workspaces", [])
1392
+ current_ws: Dict[str, Any] = next(
1393
+ (workspace for workspace in workspaces if config and workspace.get("id", ".") == config.get("id", "..")), {}
1394
+ )
1395
+
1396
+ semver = config.get("semver")
1397
+ auto_promote = getenv_bool("TB_AUTO_PROMOTE", True)
1398
+ release = current_ws.get("release", {})
1399
+ current_semver: Optional[str] = None
1400
+ if release and isinstance(release, dict):
1401
+ current_semver = release.get("semver")
1402
+
1403
+ if not current_semver:
1404
+ click.echo(FeedbackManager.error_init_release(workspace=current_ws.get("name")))
1405
+ sys.exit(1)
1406
+
1407
+ release_created = False
1408
+ new_release = False
1409
+ check_backfill_required = False
1410
+
1411
+ # semver is now optional, if not sent force the bump
1412
+ if not semver:
1413
+ semver = current_semver
1414
+ else:
1415
+ click.echo(FeedbackManager.warning_deprecated_releases())
1416
+
1417
+ if semver and current_semver:
1418
+ new_version = version.parse(semver.split("-snapshot")[0])
1419
+ current_version = version.parse(current_semver.split("-snapshot")[0])
1420
+ show_feedback = new_version != current_version
1421
+ if show_feedback:
1422
+ if dry_run:
1423
+ click.echo(
1424
+ FeedbackManager.info_dry_releases_detected(current_semver=current_version, semver=new_version)
1425
+ )
1426
+ else:
1427
+ click.echo(
1428
+ FeedbackManager.info_releases_detected(current_semver=current_version, semver=new_version)
1429
+ )
1430
+
1431
+ if new_version == current_version:
1432
+ if current_version.post is None:
1433
+ semver = str(current_version) + "-1"
1434
+ new_version = version.Version(semver)
1435
+ else:
1436
+ semver = f"{current_version.major}.{current_version.minor}.{current_version.micro}-{current_version.post + 1}"
1437
+ new_version = version.Version(semver)
1438
+
1439
+ if is_post_semver(new_version, current_version):
1440
+ if show_feedback:
1441
+ if dry_run:
1442
+ click.echo(FeedbackManager.info_dry_local_release(version=new_version))
1443
+ else:
1444
+ click.echo(FeedbackManager.info_local_release(version=new_version))
1445
+ yes = True
1446
+ auto_promote = False
1447
+ new_release = False
1448
+ elif is_major_semver(new_version, current_version):
1449
+ if show_feedback:
1450
+ if dry_run:
1451
+ click.echo(FeedbackManager.info_dry_major_release(version=new_version))
1452
+ else:
1453
+ click.echo(FeedbackManager.info_major_release(version=new_version))
1454
+ auto_promote = False
1455
+ new_release = True
1456
+ else:
1457
+ # allows TB_AUTO_PROMOTE=0 so release is left in preview
1458
+ auto_promote = getenv_bool("TB_AUTO_PROMOTE", True)
1459
+ if auto_promote:
1460
+ if show_feedback:
1461
+ if dry_run:
1462
+ click.echo(
1463
+ FeedbackManager.info_dry_minor_patch_release_with_autopromote(version=new_version)
1464
+ )
1465
+ else:
1466
+ click.echo(FeedbackManager.info_minor_patch_release_with_autopromote(version=new_version))
1467
+ else:
1468
+ if show_feedback:
1469
+ if dry_run:
1470
+ click.echo(FeedbackManager.info_dry_minor_patch_release_no_autopromote(version=new_version))
1471
+ else:
1472
+ click.echo(FeedbackManager.info_minor_patch_release_no_autopromote(version=new_version))
1473
+ new_release = True
1474
+
1475
+ if new_release:
1476
+ if not dry_run:
1477
+ try:
1478
+ await create_release(client, config, semver)
1479
+ except OperationCanNotBePerformed as e:
1480
+ raise CLIException(FeedbackManager.error_exception(error=str(e)))
1481
+ release_created = True
1482
+ fork_downstream = True
1483
+ # allows TB_CHECK_BACKFILL_REQUIRED=0 so it is not checked
1484
+ check_backfill_required = getenv_bool("TB_CHECK_BACKFILL_REQUIRED", True)
1485
+ try:
1486
+ tb_client = create_tb_client(ctx)
1487
+ if dry_run:
1488
+ config.set_semver(None)
1489
+ tb_client.semver = None
1490
+ await folder_push(
1491
+ tb_client=tb_client,
1492
+ dry_run=dry_run,
1493
+ check=False,
1494
+ push_deps=True,
1495
+ debug=debug,
1496
+ force=True,
1497
+ git_release=True,
1498
+ override_datasource=override_datasource,
1499
+ populate=populate,
1500
+ populate_subset=subset,
1501
+ populate_condition=sql_condition,
1502
+ unlink_on_populate_error=unlink_on_populate_error,
1503
+ upload_fixtures=False,
1504
+ wait=wait,
1505
+ ignore_sql_errors=ignore_sql_errors,
1506
+ skip_confirmation=yes,
1507
+ workspace_map=dict(workspace_map),
1508
+ workspace_lib_paths=workspace,
1509
+ run_tests=False,
1510
+ folder=folder,
1511
+ config=config,
1512
+ user_token=user_token,
1513
+ fork_downstream=fork_downstream,
1514
+ fork=fork,
1515
+ is_internal=is_internal,
1516
+ release_created=release_created,
1517
+ auto_promote=auto_promote,
1518
+ check_backfill_required=check_backfill_required,
1519
+ use_main=use_main,
1520
+ check_outdated=not skip_head_outdated,
1521
+ )
1522
+ except Exception as e:
1523
+ if release_created and not dry_run:
1524
+ await client.release_failed(config["id"], semver)
1525
+ raise e
1526
+
1527
+ if release_created:
1528
+ try:
1529
+ if not dry_run:
1530
+ await client.release_preview(config["id"], semver)
1531
+ click.echo(FeedbackManager.success_release_preview(semver=semver))
1532
+ else:
1533
+ click.echo(FeedbackManager.success_dry_release_preview(semver=semver))
1534
+ if auto_promote:
1535
+ try:
1536
+ force_remove = getenv_bool("TB_FORCE_REMOVE_OLDEST_ROLLBACK", False)
1537
+ if dry_run:
1538
+ click.echo(FeedbackManager.warning_dry_remove_oldest_rollback(semver=semver))
1539
+ else:
1540
+ click.echo(FeedbackManager.warning_remove_oldest_rollback(semver=semver))
1541
+ try:
1542
+ await remove_release(dry_run, config, OLDEST_ROLLBACK, client, force=force_remove)
1543
+ except Exception as e:
1544
+ click.echo(FeedbackManager.error_remove_oldest_rollback(error=str(e), semver=semver))
1545
+ sys.exit(1)
1546
+
1547
+ if not dry_run:
1548
+ release = await client.release_promote(config["id"], semver)
1549
+ click.echo(FeedbackManager.success_release_promote(semver=semver))
1550
+ click.echo(FeedbackManager.success_git_release(release_commit=release["commit"]))
1551
+ else:
1552
+ click.echo(FeedbackManager.success_dry_release_promote(semver=semver))
1553
+ click.echo(FeedbackManager.success_dry_git_release(release_commit=release["commit"]))
1554
+ except Exception as e:
1555
+ raise CLIException(FeedbackManager.error_exception(error=str(e)))
1556
+ except Exception as e:
1557
+ raise CLIException(FeedbackManager.error_exception(error=str(e)))
1558
+
1559
+ if not new_release:
1560
+ if dry_run:
1561
+ if show_feedback:
1562
+ click.echo(FeedbackManager.success_dry_release_update(semver=current_semver, new_semver=semver))
1563
+ else:
1564
+ client.semver = None
1565
+ await client.update_release_semver(config["id"], current_semver, semver)
1566
+ if show_feedback:
1567
+ click.echo(FeedbackManager.success_release_update(semver=current_semver, new_semver=semver))
1568
+ except AuthNoTokenException:
1569
+ raise
1570
+ except Exception as e:
1571
+ raise CLIException(str(e))