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.
- tinybird/__cli__.py +8 -0
- tinybird/ch_utils/constants.py +244 -0
- tinybird/ch_utils/engine.py +855 -0
- tinybird/check_pypi.py +25 -0
- tinybird/client.py +1281 -0
- tinybird/config.py +117 -0
- tinybird/connectors.py +428 -0
- tinybird/context.py +23 -0
- tinybird/datafile.py +5589 -0
- tinybird/datatypes.py +434 -0
- tinybird/feedback_manager.py +1022 -0
- tinybird/git_settings.py +145 -0
- tinybird/sql.py +865 -0
- tinybird/sql_template.py +2343 -0
- tinybird/sql_template_fmt.py +281 -0
- tinybird/sql_toolset.py +350 -0
- tinybird/syncasync.py +682 -0
- tinybird/tb_cli.py +25 -0
- tinybird/tb_cli_modules/auth.py +252 -0
- tinybird/tb_cli_modules/branch.py +1043 -0
- tinybird/tb_cli_modules/cicd.py +434 -0
- tinybird/tb_cli_modules/cli.py +1571 -0
- tinybird/tb_cli_modules/common.py +2082 -0
- tinybird/tb_cli_modules/config.py +344 -0
- tinybird/tb_cli_modules/connection.py +803 -0
- tinybird/tb_cli_modules/datasource.py +900 -0
- tinybird/tb_cli_modules/exceptions.py +91 -0
- tinybird/tb_cli_modules/fmt.py +91 -0
- tinybird/tb_cli_modules/job.py +85 -0
- tinybird/tb_cli_modules/pipe.py +858 -0
- tinybird/tb_cli_modules/regions.py +9 -0
- tinybird/tb_cli_modules/tag.py +100 -0
- tinybird/tb_cli_modules/telemetry.py +310 -0
- tinybird/tb_cli_modules/test.py +107 -0
- tinybird/tb_cli_modules/tinyunit/tinyunit.py +340 -0
- tinybird/tb_cli_modules/tinyunit/tinyunit_lib.py +71 -0
- tinybird/tb_cli_modules/token.py +349 -0
- tinybird/tb_cli_modules/workspace.py +269 -0
- tinybird/tb_cli_modules/workspace_members.py +212 -0
- tinybird/tornado_template.py +1194 -0
- tinybird-0.0.1.dev0.dist-info/METADATA +2815 -0
- tinybird-0.0.1.dev0.dist-info/RECORD +45 -0
- tinybird-0.0.1.dev0.dist-info/WHEEL +5 -0
- tinybird-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- 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))
|