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,100 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click import Context
|
|
5
|
+
|
|
6
|
+
from tinybird.feedback_manager import FeedbackManager
|
|
7
|
+
from tinybird.tb_cli_modules.cli import cli
|
|
8
|
+
from tinybird.tb_cli_modules.common import coro, echo_safe_humanfriendly_tables_format_smart_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cli.group()
|
|
12
|
+
@click.pass_context
|
|
13
|
+
def tag(ctx: Context) -> None:
|
|
14
|
+
"""Tag commands"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@tag.command(name="ls")
|
|
18
|
+
@click.argument("tag_name", required=False)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
@coro
|
|
21
|
+
async def tag_ls(ctx: Context, tag_name: Optional[str]) -> None:
|
|
22
|
+
"""List all the tags of the current Workspace or the resources associated to a specific tag."""
|
|
23
|
+
|
|
24
|
+
client = ctx.ensure_object(dict)["client"]
|
|
25
|
+
response = await client.get_all_tags()
|
|
26
|
+
|
|
27
|
+
if tag_name:
|
|
28
|
+
the_tag = [tag for tag in response["tags"] if tag["name"] == tag_name]
|
|
29
|
+
|
|
30
|
+
columns = ["name", "id", "type"]
|
|
31
|
+
table = []
|
|
32
|
+
|
|
33
|
+
if len(the_tag) > 0:
|
|
34
|
+
for resource in the_tag[0]["resources"]:
|
|
35
|
+
table.append([resource["name"], resource["id"], resource["type"]])
|
|
36
|
+
|
|
37
|
+
click.echo(FeedbackManager.info_tag_resources(tag_name=tag_name))
|
|
38
|
+
echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
columns = ["tag", "resources"]
|
|
42
|
+
table = []
|
|
43
|
+
|
|
44
|
+
for tag in response["tags"]:
|
|
45
|
+
unique_resources = []
|
|
46
|
+
for resource in tag["resources"]:
|
|
47
|
+
if resource.get("name", "") not in unique_resources:
|
|
48
|
+
unique_resources.append(resource) # Reducing by name in case there are duplicates.
|
|
49
|
+
table.append([tag["name"], len(unique_resources)])
|
|
50
|
+
|
|
51
|
+
click.echo(FeedbackManager.info_tag_list())
|
|
52
|
+
echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@tag.command(name="create")
|
|
56
|
+
@click.argument("tag_name")
|
|
57
|
+
@click.pass_context
|
|
58
|
+
@coro
|
|
59
|
+
async def tag_create(ctx: Context, tag_name: str) -> None:
|
|
60
|
+
"""Create a tag in the current Workspace."""
|
|
61
|
+
|
|
62
|
+
client = ctx.ensure_object(dict)["client"]
|
|
63
|
+
await client.create_tag(name=tag_name)
|
|
64
|
+
|
|
65
|
+
click.echo(FeedbackManager.success_tag_created(tag_name=tag_name))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@tag.command(name="rm")
|
|
69
|
+
@click.argument("tag_name")
|
|
70
|
+
@click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation to delete the tag.")
|
|
71
|
+
@click.pass_context
|
|
72
|
+
@coro
|
|
73
|
+
async def tag_rm(ctx: Context, tag_name: str, yes: bool) -> None:
|
|
74
|
+
"""Remove a tag from the current Workspace."""
|
|
75
|
+
|
|
76
|
+
client = ctx.ensure_object(dict)["client"]
|
|
77
|
+
remove_tag = True
|
|
78
|
+
|
|
79
|
+
if not yes:
|
|
80
|
+
all_tags = await client.get_all_tags()
|
|
81
|
+
the_tag = [tag for tag in all_tags["tags"] if tag["name"] == tag_name]
|
|
82
|
+
if len(the_tag) > 0:
|
|
83
|
+
unique_resources = []
|
|
84
|
+
for resource in the_tag[0]["resources"]:
|
|
85
|
+
if resource.get("name", "") not in unique_resources:
|
|
86
|
+
unique_resources.append(resource) # Reducing by name in case there are duplicates.
|
|
87
|
+
|
|
88
|
+
if len(unique_resources) > 0:
|
|
89
|
+
remove_tag = click.confirm(
|
|
90
|
+
FeedbackManager.warning_tag_remove(tag_name=tag_name, resources_len=len(unique_resources))
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
remove_tag = click.confirm(FeedbackManager.warning_tag_remove_no_resources(tag_name=tag_name))
|
|
94
|
+
else:
|
|
95
|
+
remove_tag = False
|
|
96
|
+
click.echo(FeedbackManager.error_tag_not_found(tag_name=tag_name))
|
|
97
|
+
|
|
98
|
+
if remove_tag:
|
|
99
|
+
await client.delete_tag(tag_name)
|
|
100
|
+
click.echo(FeedbackManager.success_tag_removed(tag_name=tag_name))
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import uuid
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from tinybird.config import CURRENT_VERSION
|
|
17
|
+
|
|
18
|
+
TELEMETRY_TIMEOUT: int = 1
|
|
19
|
+
TELEMETRY_DATASOURCE: str = "tb_cli_telemetry"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_ci_product_name() -> Optional[str]:
|
|
23
|
+
if _is_env_true("TB_DISABLE_CI_DETECTION"):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
CI_CHECKS: List[Tuple[str, Callable[[], bool]]] = [
|
|
27
|
+
("Azure pipelines", lambda: _is_env_true("TF_BUILD")),
|
|
28
|
+
("GitHub Actions", lambda: _is_env_true("GITHUB_ACTIONS")),
|
|
29
|
+
("Appveyor", lambda: _is_env_true("APPVEYOR")),
|
|
30
|
+
("Travis CI", lambda: _is_env_true("TRAVIS")),
|
|
31
|
+
("Circle CI", lambda: _is_env_true("CIRCLECI")),
|
|
32
|
+
("Amazon Web Services CodeBuild", lambda: _is_env_present(["CODEBUILD_BUILD_ID", "AWS_REGION"])),
|
|
33
|
+
("Jenkins", lambda: _is_env_present(["BUILD_ID", "BUILD_URL"])),
|
|
34
|
+
("Google Cloud Build", lambda: _is_env_present(["BUILD_ID", "PROJECT_ID"])),
|
|
35
|
+
("TeamCity", lambda: _is_env_present(["TEAMCITY_VERSION"])),
|
|
36
|
+
("JetBrains Space", lambda: _is_env_present(["JB_SPACE_API_URL"])),
|
|
37
|
+
("Generic CI", lambda: _is_env_true("CI")),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
return next((check[0] for check in CI_CHECKS if check[1]()), None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_ci_environment() -> bool:
|
|
44
|
+
ci_product: Optional[str] = get_ci_product_name()
|
|
45
|
+
return ci_product is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def silence_errors(f: Callable) -> Callable:
|
|
49
|
+
"""Decorator to silence all errors in the decorated
|
|
50
|
+
function.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@functools.wraps(f)
|
|
54
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
55
|
+
try:
|
|
56
|
+
return f(*args, **kwargs)
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_env_true(env_var: str) -> bool:
|
|
64
|
+
"""Checks if `env_var` is `true` or `1`."""
|
|
65
|
+
return os.getenv(env_var, "").lower() in ("true", "1")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_env_present(envs: List[str]) -> bool:
|
|
69
|
+
"""Checks if all of the variables passed in `envs`
|
|
70
|
+
are defined (ie: not empty)
|
|
71
|
+
"""
|
|
72
|
+
return all(os.getenv(env_var, None) is not None for env_var in envs)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _hide_tokens(text: str) -> str:
|
|
76
|
+
"""Cuts any token in a way that they get unusable if leaked,
|
|
77
|
+
but we still can use them for debugging if needed.
|
|
78
|
+
"""
|
|
79
|
+
return re.sub(r"p\.ey[A-Za-z0-9-_\.]+", lambda s: f"{s[0][:10]}...{s[0][-10:]}", text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TelemetryHelper:
|
|
83
|
+
def __init__(self, tb_host: Optional[str] = None, max_enqueued_events: int = 5) -> None:
|
|
84
|
+
self.tb_host = tb_host or os.getenv("TB_CLI_TELEMETRY_HOST", "https://api.tinybird.co")
|
|
85
|
+
self.max_enqueued_events: int = max_enqueued_events
|
|
86
|
+
|
|
87
|
+
self.enabled: bool = True
|
|
88
|
+
self.events: List[Dict[str, Any]] = []
|
|
89
|
+
self.telemetry_token: Optional[str] = None
|
|
90
|
+
|
|
91
|
+
run_id = str(uuid.uuid4())
|
|
92
|
+
|
|
93
|
+
self._defaults: Dict[str, Any] = {
|
|
94
|
+
# Per-event values
|
|
95
|
+
"event": "<the event>",
|
|
96
|
+
"event_data": "<the event data>",
|
|
97
|
+
"timestamp": "<the timestamp>",
|
|
98
|
+
# Static values
|
|
99
|
+
"run_id": run_id,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
self._threads: List[threading.Thread] = []
|
|
103
|
+
self.log(f"Telemetry initialized with run_id: {run_id}")
|
|
104
|
+
|
|
105
|
+
@silence_errors
|
|
106
|
+
def add_event(self, event: str, event_data: Dict[str, Any]) -> None:
|
|
107
|
+
if not self.enabled:
|
|
108
|
+
self.log("Helper is disabled")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if "x.y.z" in CURRENT_VERSION and not _is_env_true("TB_CLI_TELEMETRY_SEND_IN_LOCAL"):
|
|
112
|
+
self.log("Not sending events in local development mode")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Let's save deep copies to not interfere with original objects
|
|
116
|
+
event_dict: Dict[str, Any] = deepcopy(self._defaults)
|
|
117
|
+
event_dict["event"] = event
|
|
118
|
+
event_dict["event_data"] = json.dumps(event_data)
|
|
119
|
+
event_dict["timestamp"] = datetime.utcnow().isoformat()
|
|
120
|
+
|
|
121
|
+
self.events.append(event_dict)
|
|
122
|
+
if len(self.events) >= self.max_enqueued_events:
|
|
123
|
+
self.flush()
|
|
124
|
+
|
|
125
|
+
@silence_errors
|
|
126
|
+
def flush(self, wait: bool = False) -> None:
|
|
127
|
+
if self.enabled and len(self.events) > 0:
|
|
128
|
+
# Take the ownership for the pending events.
|
|
129
|
+
#
|
|
130
|
+
# We need this because the proper flush() is done in
|
|
131
|
+
# a thread to avoid blocking the user and we could send
|
|
132
|
+
# the same event twice if we maintain the same list after
|
|
133
|
+
# during the sending.
|
|
134
|
+
|
|
135
|
+
events: List[Dict[str, Any]] = self.events
|
|
136
|
+
self.events = []
|
|
137
|
+
|
|
138
|
+
self.log(f"Flusing {len(events)} events in a new thread...")
|
|
139
|
+
thread: threading.Thread = threading.Thread(target=self._flush, args=[events])
|
|
140
|
+
self._threads.append(thread)
|
|
141
|
+
thread.start()
|
|
142
|
+
|
|
143
|
+
if wait:
|
|
144
|
+
for t in self._threads:
|
|
145
|
+
t.join()
|
|
146
|
+
if t.is_alive():
|
|
147
|
+
self.log(f"Couldn't wait for the end of the thread {t.name}")
|
|
148
|
+
self._threads.clear()
|
|
149
|
+
|
|
150
|
+
@silence_errors
|
|
151
|
+
def _flush(self, events: List[Dict[str, Any]]) -> None:
|
|
152
|
+
"""Actual flush. This is where we use HFI to ingest events."""
|
|
153
|
+
|
|
154
|
+
timeout: int
|
|
155
|
+
try:
|
|
156
|
+
timeout = int(os.getenv("TB_CLI_TELEMETRY_TIMEOUT", TELEMETRY_TIMEOUT))
|
|
157
|
+
timeout = max(TELEMETRY_TIMEOUT, timeout)
|
|
158
|
+
except ValueError:
|
|
159
|
+
timeout = TELEMETRY_TIMEOUT
|
|
160
|
+
|
|
161
|
+
if not self.telemetry_token:
|
|
162
|
+
self.telemetry_token = os.getenv("TB_CLI_TELEMETRY_TOKEN")
|
|
163
|
+
if self.telemetry_token:
|
|
164
|
+
self.log("Got telemetry token from environment TB_CLI_TELEMETRY_TOKEN")
|
|
165
|
+
|
|
166
|
+
with requests.Session() as session:
|
|
167
|
+
if not self.telemetry_token:
|
|
168
|
+
url: str = f"{self.tb_host}/v0/regions"
|
|
169
|
+
self.log(f"Requesting token from {url}...")
|
|
170
|
+
try:
|
|
171
|
+
r = session.get(url, timeout=timeout)
|
|
172
|
+
regions: List[Dict[str, Any]] = json.loads(r.content.decode())["regions"]
|
|
173
|
+
self.telemetry_token = next(
|
|
174
|
+
(r.get("telemetry_token", None) for r in regions if r["api_host"] == self.tb_host), None
|
|
175
|
+
)
|
|
176
|
+
if self.telemetry_token:
|
|
177
|
+
self.log(f"Got telemetry token from {url}")
|
|
178
|
+
except requests.exceptions.Timeout:
|
|
179
|
+
self.log(f"Disabling due to timeout after {timeout} seconds")
|
|
180
|
+
self.enabled = False
|
|
181
|
+
return
|
|
182
|
+
except Exception as ex:
|
|
183
|
+
self.log(str(ex))
|
|
184
|
+
|
|
185
|
+
if not self.telemetry_token:
|
|
186
|
+
self.log("Disabling due to lack of token")
|
|
187
|
+
self.enabled = False
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
self.log(f"token={self.telemetry_token}")
|
|
191
|
+
|
|
192
|
+
data: str = _hide_tokens("\n".join(json.dumps(e) for e in events))
|
|
193
|
+
|
|
194
|
+
# Note we don't use `wait` as this telemetry isn't a critical
|
|
195
|
+
# operation to support and we don't want to generate overhead
|
|
196
|
+
params: Dict[str, Any] = {"name": TELEMETRY_DATASOURCE, "token": self.telemetry_token}
|
|
197
|
+
url = f"{self.tb_host}/v0/events?{urlencode(params)}"
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
self.log(f"Sending data to {url}...")
|
|
201
|
+
r = session.post(url, data=data, timeout=timeout)
|
|
202
|
+
except requests.exceptions.Timeout:
|
|
203
|
+
self.log(f"Disabling due to timeout after {timeout} seconds")
|
|
204
|
+
self.enabled = False
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
self.log(f"Received status {r.status_code}: {r.text}")
|
|
208
|
+
|
|
209
|
+
if r.status_code == 200 or r.status_code == 202:
|
|
210
|
+
self.log(f"Successfully sent {len(events)} events to {self.tb_host}")
|
|
211
|
+
self.events.clear()
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
if r.status_code in (403, 404):
|
|
215
|
+
self.log(f"Disabling due to {r.status_code} errors")
|
|
216
|
+
self.enabled = False
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
if r.status_code >= 500:
|
|
220
|
+
self.log(f"Disabling telemetry and discarding {len(events)} events")
|
|
221
|
+
self.enabled = False
|
|
222
|
+
|
|
223
|
+
@silence_errors
|
|
224
|
+
def log(self, msg: str) -> None:
|
|
225
|
+
"""Internal logging function to help with development and debugging."""
|
|
226
|
+
if not _is_env_true("TB_CLI_TELEMETRY_DEBUG"):
|
|
227
|
+
return
|
|
228
|
+
print(f"> Telemetry: {msg}") # noqa: T201
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_helper_instance: Optional[TelemetryHelper] = None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@silence_errors
|
|
235
|
+
def init_telemetry() -> None:
|
|
236
|
+
"""Setups the telemetry helper with the config present in `config`.
|
|
237
|
+
If no config is provided, it tries to get it from the passed Click context.
|
|
238
|
+
|
|
239
|
+
We need to call this method any time we suspect the config changes any value.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
telemetry = _get_helper()
|
|
243
|
+
if telemetry:
|
|
244
|
+
telemetry.log("Initialized")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@silence_errors
|
|
248
|
+
def add_telemetry_event(event: str, **kw_event_data: Any) -> None:
|
|
249
|
+
"""Adds a new telemetry event."""
|
|
250
|
+
|
|
251
|
+
telemetry = _get_helper()
|
|
252
|
+
if not telemetry:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
telemetry.add_event(event, dict(**kw_event_data))
|
|
257
|
+
except Exception as ex:
|
|
258
|
+
telemetry.log(str(ex))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@silence_errors
|
|
262
|
+
def add_telemetry_sysinfo_event() -> None:
|
|
263
|
+
"""Collects system info and sends a `system_info` event
|
|
264
|
+
with the data.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
ci_product: Optional[str] = get_ci_product_name()
|
|
268
|
+
|
|
269
|
+
add_telemetry_event(
|
|
270
|
+
"system_info",
|
|
271
|
+
platform=platform.platform(),
|
|
272
|
+
system=platform.system(),
|
|
273
|
+
arch=platform.machine(),
|
|
274
|
+
processor=platform.processor(),
|
|
275
|
+
python_runtime=platform.python_implementation(),
|
|
276
|
+
python_version=platform.python_version(),
|
|
277
|
+
is_ci=ci_product is not None,
|
|
278
|
+
ci_product=ci_product,
|
|
279
|
+
cli_version=CURRENT_VERSION,
|
|
280
|
+
cli_args=sys.argv[1:] if len(sys.argv) > 1 else [],
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@silence_errors
|
|
285
|
+
def flush_telemetry(wait: bool = False) -> None:
|
|
286
|
+
"""Flushes all pending telemetry events."""
|
|
287
|
+
|
|
288
|
+
telemetry = _get_helper()
|
|
289
|
+
if not telemetry:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
telemetry.flush(wait=wait)
|
|
294
|
+
except Exception as ex:
|
|
295
|
+
telemetry.log(str(ex))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@silence_errors
|
|
299
|
+
def _get_helper() -> Optional[TelemetryHelper]:
|
|
300
|
+
"""Returns the shared TelemetryHelper instance."""
|
|
301
|
+
|
|
302
|
+
if _is_env_true("TB_CLI_TELEMETRY_OPTOUT"):
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
global _helper_instance
|
|
306
|
+
|
|
307
|
+
if not _helper_instance:
|
|
308
|
+
_helper_instance = TelemetryHelper()
|
|
309
|
+
|
|
310
|
+
return _helper_instance
|
|
@@ -0,0 +1,107 @@
|
|
|
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 glob
|
|
7
|
+
from typing import Any, Dict, Iterable, List, Tuple
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from tinybird.client import AuthNoTokenException
|
|
12
|
+
from tinybird.feedback_manager import FeedbackManager
|
|
13
|
+
from tinybird.tb_cli_modules.cli import cli
|
|
14
|
+
from tinybird.tb_cli_modules.common import coro, create_tb_client, gather_with_concurrency
|
|
15
|
+
from tinybird.tb_cli_modules.config import CLIConfig
|
|
16
|
+
from tinybird.tb_cli_modules.exceptions import CLIException
|
|
17
|
+
from tinybird.tb_cli_modules.tinyunit.tinyunit import (
|
|
18
|
+
TestSummaryResults,
|
|
19
|
+
generate_file,
|
|
20
|
+
parse_file,
|
|
21
|
+
run_test_file,
|
|
22
|
+
test_run_summary,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cli.group()
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def test(ctx: click.Context) -> None:
|
|
29
|
+
"""Test commands."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@test.command(
|
|
33
|
+
name="run",
|
|
34
|
+
help="Run the test suite, a file, or a test. To skip test to run in branches and CI put them in a 'skip_in_branch' folder.",
|
|
35
|
+
)
|
|
36
|
+
@click.argument("file", nargs=-1)
|
|
37
|
+
@click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose (show results)", type=bool)
|
|
38
|
+
@click.option("--fail", "only_fail", is_flag=True, default=False, help="Showy onl failed/error tests", type=bool)
|
|
39
|
+
@click.option("-c", "--concurrency", help="How many test to run concurrently", default=1, type=click.IntRange(1, 10))
|
|
40
|
+
@click.pass_context
|
|
41
|
+
@coro
|
|
42
|
+
async def test_run(ctx: click.Context, file: Tuple[str, ...], verbose: bool, only_fail: bool, concurrency: int) -> None:
|
|
43
|
+
results: List[TestSummaryResults] = []
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
tb_client = create_tb_client(ctx)
|
|
47
|
+
config = CLIConfig.get_project_config()
|
|
48
|
+
if config.get("token") is None:
|
|
49
|
+
raise AuthNoTokenException
|
|
50
|
+
workspaces: List[Dict[str, Any]] = (await tb_client.user_workspaces_and_branches()).get("workspaces", [])
|
|
51
|
+
current_ws: Dict[str, Any] = next(
|
|
52
|
+
(workspace for workspace in workspaces if config and workspace.get("id", ".") == config.get("id", "..")), {}
|
|
53
|
+
)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise CLIException(FeedbackManager.error_exception(error=e))
|
|
56
|
+
|
|
57
|
+
file_list: Iterable[str] = file if len(file) > 0 else glob.glob("./tests/**/*.y*ml", recursive=True)
|
|
58
|
+
click.echo(FeedbackManager.info_skipping_resource(resource="regression.yaml"))
|
|
59
|
+
file_list = [f for f in file_list if not f.endswith("regression.yaml")]
|
|
60
|
+
final_file_list = []
|
|
61
|
+
for f in file_list:
|
|
62
|
+
if "skip_in_branch" in f and current_ws and current_ws.get("is_branch"):
|
|
63
|
+
click.echo(FeedbackManager.info_skipping_resource(resource=f))
|
|
64
|
+
else:
|
|
65
|
+
final_file_list.append(f)
|
|
66
|
+
file_list = final_file_list
|
|
67
|
+
|
|
68
|
+
async def run_test(tb_client, test_file, results):
|
|
69
|
+
try:
|
|
70
|
+
test_result = await run_test_file(tb_client, test_file)
|
|
71
|
+
results.append(TestSummaryResults(filename=test_file, results=test_result, semver=tb_client.semver))
|
|
72
|
+
except Exception as e:
|
|
73
|
+
if verbose:
|
|
74
|
+
click.echo(FeedbackManager.error_exception(error=e))
|
|
75
|
+
raise CLIException(FeedbackManager.error_running_test(file=test_file))
|
|
76
|
+
|
|
77
|
+
test_tasks = [run_test(tb_client, test_file, results) for test_file in file_list]
|
|
78
|
+
await gather_with_concurrency(concurrency, *test_tasks)
|
|
79
|
+
|
|
80
|
+
if len(results) <= 0:
|
|
81
|
+
click.echo(FeedbackManager.warning_no_test_results())
|
|
82
|
+
else:
|
|
83
|
+
test_run_summary(results, only_fail=only_fail, verbose_level=int(verbose))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@test.command(name="init", help="Initialize a file list with a simple test suite.")
|
|
87
|
+
@click.argument("files", nargs=-1)
|
|
88
|
+
@click.option("--force", is_flag=True, default=False, help="Override existing files")
|
|
89
|
+
@click.pass_context
|
|
90
|
+
@coro
|
|
91
|
+
async def test_init(ctx: click.Context, files: Tuple[str, ...], force: bool) -> None:
|
|
92
|
+
if len(files) == 0:
|
|
93
|
+
files = ("tests/default.yaml",)
|
|
94
|
+
|
|
95
|
+
for file in files:
|
|
96
|
+
generate_file(file, overwrite=force)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@test.command(name="parse", help="Read the contents of a test file list.")
|
|
100
|
+
@click.argument("files", nargs=-1)
|
|
101
|
+
@click.pass_context
|
|
102
|
+
@coro
|
|
103
|
+
async def test_parse(ctx: click.Context, files: Tuple[str, ...]) -> None:
|
|
104
|
+
for f in files:
|
|
105
|
+
click.echo(f"\nFile: {f}")
|
|
106
|
+
for test in parse_file(f):
|
|
107
|
+
click.echo(test)
|