tinybird 0.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tinybird might be problematic. Click here for more details.

Files changed (45) hide show
  1. tinybird/__cli__.py +8 -0
  2. tinybird/ch_utils/constants.py +244 -0
  3. tinybird/ch_utils/engine.py +855 -0
  4. tinybird/check_pypi.py +25 -0
  5. tinybird/client.py +1281 -0
  6. tinybird/config.py +117 -0
  7. tinybird/connectors.py +428 -0
  8. tinybird/context.py +23 -0
  9. tinybird/datafile.py +5589 -0
  10. tinybird/datatypes.py +434 -0
  11. tinybird/feedback_manager.py +1022 -0
  12. tinybird/git_settings.py +145 -0
  13. tinybird/sql.py +865 -0
  14. tinybird/sql_template.py +2343 -0
  15. tinybird/sql_template_fmt.py +281 -0
  16. tinybird/sql_toolset.py +350 -0
  17. tinybird/syncasync.py +682 -0
  18. tinybird/tb_cli.py +25 -0
  19. tinybird/tb_cli_modules/auth.py +252 -0
  20. tinybird/tb_cli_modules/branch.py +1043 -0
  21. tinybird/tb_cli_modules/cicd.py +434 -0
  22. tinybird/tb_cli_modules/cli.py +1571 -0
  23. tinybird/tb_cli_modules/common.py +2082 -0
  24. tinybird/tb_cli_modules/config.py +344 -0
  25. tinybird/tb_cli_modules/connection.py +803 -0
  26. tinybird/tb_cli_modules/datasource.py +900 -0
  27. tinybird/tb_cli_modules/exceptions.py +91 -0
  28. tinybird/tb_cli_modules/fmt.py +91 -0
  29. tinybird/tb_cli_modules/job.py +85 -0
  30. tinybird/tb_cli_modules/pipe.py +858 -0
  31. tinybird/tb_cli_modules/regions.py +9 -0
  32. tinybird/tb_cli_modules/tag.py +100 -0
  33. tinybird/tb_cli_modules/telemetry.py +310 -0
  34. tinybird/tb_cli_modules/test.py +107 -0
  35. tinybird/tb_cli_modules/tinyunit/tinyunit.py +340 -0
  36. tinybird/tb_cli_modules/tinyunit/tinyunit_lib.py +71 -0
  37. tinybird/tb_cli_modules/token.py +349 -0
  38. tinybird/tb_cli_modules/workspace.py +269 -0
  39. tinybird/tb_cli_modules/workspace_members.py +212 -0
  40. tinybird/tornado_template.py +1194 -0
  41. tinybird-0.0.1.dev0.dist-info/METADATA +2815 -0
  42. tinybird-0.0.1.dev0.dist-info/RECORD +45 -0
  43. tinybird-0.0.1.dev0.dist-info/WHEEL +5 -0
  44. tinybird-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  45. tinybird-0.0.1.dev0.dist-info/top_level.txt +4 -0
@@ -0,0 +1,9 @@
1
+ from typing import Optional, TypedDict
2
+
3
+
4
+ class Region(TypedDict):
5
+ name: str
6
+ provider: str
7
+ api_host: str
8
+ host: str
9
+ default_password: Optional[str]
@@ -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)