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,900 @@
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 asyncio
7
+ import json
8
+ import logging
9
+ import re
10
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
11
+
12
+ import click
13
+ import humanfriendly
14
+ from click import Context
15
+
16
+ from tinybird.client import AuthNoTokenException, CanNotBeDeletedException, DoesNotExistException, TinyB
17
+ from tinybird.config import get_display_host
18
+ from tinybird.tb_cli_modules.config import CLIConfig
19
+
20
+ if TYPE_CHECKING:
21
+ from tinybird.connectors import Connector
22
+
23
+ from tinybird.datafile import get_name_version, wait_job
24
+ from tinybird.feedback_manager import FeedbackManager
25
+ from tinybird.tb_cli_modules.branch import warn_if_in_live
26
+ from tinybird.tb_cli_modules.cli import cli
27
+ from tinybird.tb_cli_modules.common import (
28
+ _analyze,
29
+ _generate_datafile,
30
+ ask_for_user_token,
31
+ autocomplete_topics,
32
+ check_user_token,
33
+ coro,
34
+ echo_safe_humanfriendly_tables_format_smart_table,
35
+ get_format_from_filename_or_url,
36
+ load_connector_config,
37
+ push_data,
38
+ sync_data,
39
+ validate_datasource_name,
40
+ validate_kafka_auto_offset_reset,
41
+ validate_kafka_group,
42
+ validate_kafka_topic,
43
+ )
44
+ from tinybird.tb_cli_modules.exceptions import CLIDatasourceException
45
+
46
+
47
+ @cli.group()
48
+ @click.pass_context
49
+ def datasource(ctx):
50
+ """Data Sources commands"""
51
+
52
+
53
+ @datasource.command(name="ls")
54
+ @click.option("--match", default=None, help="Retrieve any resources matching the pattern. eg --match _test")
55
+ @click.option(
56
+ "--format",
57
+ "format_",
58
+ type=click.Choice(["json"], case_sensitive=False),
59
+ default=None,
60
+ help="Force a type of the output",
61
+ )
62
+ @click.pass_context
63
+ @coro
64
+ async def datasource_ls(ctx: Context, match: Optional[str], format_: str):
65
+ """List data sources"""
66
+
67
+ client: TinyB = ctx.ensure_object(dict)["client"]
68
+ ds = await client.datasources()
69
+ columns = ["version", "shared from", "name", "row_count", "size", "created at", "updated at", "connection"]
70
+ table_human_readable = []
71
+ table_machine_readable = []
72
+ pattern = re.compile(match) if match else None
73
+
74
+ for t in ds:
75
+ stats = t.get("stats", None)
76
+ if not stats:
77
+ stats = t.get("statistics", {"bytes": ""})
78
+ if not stats:
79
+ stats = {"bytes": ""}
80
+
81
+ tk = get_name_version(t["name"])
82
+ if pattern and not pattern.search(tk["name"]):
83
+ continue
84
+
85
+ if "." in tk["name"]:
86
+ shared_from, name = tk["name"].split(".")
87
+ else:
88
+ shared_from, name = "", tk["name"]
89
+
90
+ table_human_readable.append(
91
+ (
92
+ tk["version"] if tk["version"] is not None else "",
93
+ shared_from,
94
+ name,
95
+ humanfriendly.format_number(stats.get("row_count")) if stats.get("row_count", None) else "-",
96
+ humanfriendly.format_size(int(stats.get("bytes"))) if stats.get("bytes", None) else "-",
97
+ t["created_at"][:-7],
98
+ t["updated_at"][:-7],
99
+ t.get("service", ""),
100
+ )
101
+ )
102
+ table_machine_readable.append(
103
+ {
104
+ "version": tk["version"] if tk["version"] is not None else "",
105
+ "shared from": shared_from,
106
+ "name": name,
107
+ "row_count": stats.get("row_count", None) or "-",
108
+ "size": stats.get("bytes", None) or "-",
109
+ "created at": t["created_at"][:-7],
110
+ "updated at": t["updated_at"][:-7],
111
+ "connection": t.get("service", ""),
112
+ }
113
+ )
114
+
115
+ if not format_:
116
+ click.echo(FeedbackManager.info_datasources())
117
+ echo_safe_humanfriendly_tables_format_smart_table(table_human_readable, column_names=columns)
118
+ click.echo("\n")
119
+ elif format_ == "json":
120
+ click.echo(json.dumps({"datasources": table_machine_readable}, indent=2))
121
+ else:
122
+ raise CLIDatasourceException(FeedbackManager.error_datasource_ls_type())
123
+
124
+
125
+ @datasource.command(name="append")
126
+ @click.argument("datasource_name")
127
+ @click.argument("url", nargs=-1)
128
+ @click.option(
129
+ "--connector",
130
+ type=click.Choice(["bigquery", "snowflake"], case_sensitive=True),
131
+ help="Import from one of the selected connectors",
132
+ hidden=True,
133
+ )
134
+ @click.option("--sql", default=None, help="Query to extract data from one of the SQL connectors", hidden=True)
135
+ @click.option(
136
+ "--incremental",
137
+ default=None,
138
+ help="It does an incremental append, taking the max value for the date column name provided as a parameter. It only works when the `connector` parameter is passed.",
139
+ hidden=True,
140
+ )
141
+ @click.option(
142
+ "--ignore-empty",
143
+ help="Wheter or not to ignore empty results from the connector",
144
+ is_flag=True,
145
+ default=False,
146
+ hidden=True,
147
+ )
148
+ @click.option("--concurrency", help="How many files to submit concurrently", default=1, hidden=True)
149
+ @click.pass_context
150
+ @coro
151
+ async def datasource_append(
152
+ ctx: Context,
153
+ datasource_name: str,
154
+ url,
155
+ connector: Optional[str],
156
+ sql: Optional[str],
157
+ incremental: Optional[str],
158
+ ignore_empty: bool,
159
+ concurrency: int,
160
+ ):
161
+ """
162
+ Appends data to an existing Data Source from URL, local file or a connector
163
+
164
+ - Load from URL `tb datasource append [datasource_name] https://url_to_csv`
165
+
166
+ - Load from local file `tb datasource append [datasource_name] /path/to/local/file`
167
+
168
+ - Load from connector `tb datasource append [datasource_name] --connector [connector_name] --sql [the_sql_to_extract_from]`
169
+ """
170
+
171
+ if not url and not connector:
172
+ raise CLIDatasourceException(FeedbackManager.error_missing_url_or_connector(datasource=datasource_name))
173
+
174
+ if incremental and not connector:
175
+ raise CLIDatasourceException(FeedbackManager.error_incremental_not_supported())
176
+
177
+ if incremental:
178
+ date = None
179
+ source_column = incremental.split(":")[0]
180
+ dest_column = incremental.split(":")[-1]
181
+ client: TinyB = ctx.obj["client"]
182
+ result = await client.query(f"SELECT max({dest_column}) as inc from {datasource_name} FORMAT JSON")
183
+ try:
184
+ date = result["data"][0]["inc"]
185
+ except Exception as e:
186
+ raise CLIDatasourceException(f"{str(e)}")
187
+ if date:
188
+ sql = f"{sql} WHERE {source_column} > '{date}'"
189
+ await push_data(
190
+ ctx, datasource_name, url, connector, sql, mode="append", ignore_empty=ignore_empty, concurrency=concurrency
191
+ )
192
+
193
+
194
+ @datasource.command(name="replace")
195
+ @click.argument("datasource_name")
196
+ @click.argument("url", nargs=-1)
197
+ @click.option(
198
+ "--connector",
199
+ type=click.Choice(["bigquery", "snowflake"], case_sensitive=True),
200
+ help="Import from one of the selected connectors",
201
+ hidden=True,
202
+ )
203
+ @click.option("--sql", default=None, help="Query to extract data from one of the SQL connectors", hidden=True)
204
+ @click.option("--sql-condition", default=None, help="SQL WHERE condition to replace data", hidden=True)
205
+ @click.option("--skip-incompatible-partition-key", is_flag=True, default=False, hidden=True)
206
+ @click.option(
207
+ "--ignore-empty",
208
+ help="Wheter or not to ignore empty results from the connector",
209
+ is_flag=True,
210
+ default=False,
211
+ hidden=True,
212
+ )
213
+ @click.pass_context
214
+ @coro
215
+ async def datasource_replace(
216
+ ctx,
217
+ datasource_name,
218
+ url,
219
+ connector,
220
+ sql,
221
+ sql_condition,
222
+ skip_incompatible_partition_key,
223
+ ignore_empty: bool,
224
+ ):
225
+ """
226
+ Replaces the data in a data source from a URL, local file or a connector
227
+
228
+ - Replace from URL `tb datasource replace [datasource_name] https://url_to_csv --sql-condition "country='ES'"`
229
+
230
+ - Replace from local file `tb datasource replace [datasource_name] /path/to/local/file --sql-condition "country='ES'"`
231
+
232
+ - Replace from connector `tb datasource replace [datasource_name] --connector [connector_name] --sql [the_sql_to_extract_from] --sql-condition "country='ES'"`
233
+ """
234
+
235
+ if not url and not connector:
236
+ raise CLIDatasourceException(FeedbackManager.error_missing_url_or_connector(datasource=datasource_name))
237
+
238
+ replace_options = set()
239
+ if skip_incompatible_partition_key:
240
+ replace_options.add("skip_incompatible_partition_key")
241
+ await push_data(
242
+ ctx,
243
+ datasource_name,
244
+ url,
245
+ connector,
246
+ sql,
247
+ mode="replace",
248
+ sql_condition=sql_condition,
249
+ replace_options=replace_options,
250
+ ignore_empty=ignore_empty,
251
+ )
252
+
253
+
254
+ @datasource.command(name="analyze")
255
+ @click.argument("url_or_file")
256
+ @click.option(
257
+ "--connector",
258
+ type=click.Choice(["bigquery", "snowflake"], case_sensitive=True),
259
+ help="Use from one of the selected connectors. In this case pass a table name as a parameter instead of a file name or an URL",
260
+ hidden=True,
261
+ )
262
+ @click.pass_context
263
+ @coro
264
+ async def datasource_analyze(ctx, url_or_file, connector):
265
+ """Analyze a URL or a file before creating a new data source"""
266
+ client = ctx.obj["client"]
267
+
268
+ _connector = None
269
+ if connector:
270
+ load_connector_config(ctx, connector, False, check_uninstalled=False)
271
+ if connector not in ctx.obj:
272
+ raise CLIDatasourceException(FeedbackManager.error_connector_not_configured(connector=connector))
273
+ else:
274
+ _connector = ctx.obj[connector]
275
+
276
+ def _table(title, columns, data):
277
+ row_format = "{:<25}" * len(columns)
278
+ click.echo(FeedbackManager.info_datasource_title(title=title))
279
+ click.echo(FeedbackManager.info_datasource_row(row=row_format.format(*columns)))
280
+ for t in data:
281
+ click.echo(FeedbackManager.info_datasource_row(row=row_format.format(*[str(element) for element in t])))
282
+
283
+ analysis, _ = await _analyze(
284
+ url_or_file, client, format=get_format_from_filename_or_url(url_or_file), connector=_connector
285
+ )
286
+
287
+ columns = ("name", "type", "nullable")
288
+ if "columns" in analysis["analysis"]:
289
+ _table(
290
+ "columns",
291
+ columns,
292
+ [
293
+ (t["name"], t["recommended_type"], "false" if t["present_pct"] == 1 else "true")
294
+ for t in analysis["analysis"]["columns"]
295
+ ],
296
+ )
297
+
298
+ click.echo(FeedbackManager.info_datasource_title(title="SQL Schema"))
299
+ click.echo(analysis["analysis"]["schema"])
300
+
301
+ values = []
302
+
303
+ if "dialect" in analysis:
304
+ for x in analysis["dialect"].items():
305
+ if x[1] == " ":
306
+ values.append((x[0], '" "'))
307
+ elif type(x[1]) == str and ("\n" in x[1] or "\r" in x[1]): # noqa: E721
308
+ values.append((x[0], x[1].replace("\n", "\\n").replace("\r", "\\r")))
309
+ else:
310
+ values.append(x)
311
+
312
+ _table("dialect", ("name", "value"), values)
313
+
314
+
315
+ @datasource.command(name="rm")
316
+ @click.argument("datasource_name")
317
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
318
+ @click.pass_context
319
+ @coro
320
+ async def datasource_delete(ctx: Context, datasource_name: str, yes: bool):
321
+ """Delete a data source"""
322
+ client: TinyB = ctx.ensure_object(dict)["client"]
323
+ try:
324
+ datasource = await client.get_datasource(datasource_name)
325
+ except AuthNoTokenException:
326
+ raise
327
+ except DoesNotExistException:
328
+ raise CLIDatasourceException(FeedbackManager.error_datasource_does_not_exist(datasource=datasource_name))
329
+ except Exception as e:
330
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
331
+ connector = datasource.get("service", False)
332
+
333
+ if connector:
334
+ click.echo(FeedbackManager.warning_datasource_is_connected(datasource=datasource_name, connector=connector))
335
+
336
+ try:
337
+ response = await client.datasource_delete(datasource_name, dry_run=True)
338
+ dependencies_information = f'The Data Source is used in => Pipes="{response["dependent_pipes"]}", nodes="{response["dependent_pipes"]}"'
339
+ dependencies_information = (
340
+ dependencies_information if response["dependent_pipes"] else "The Data Source is not used in any Pipe"
341
+ )
342
+ warning_message = f"\nDo you want to delete {datasource_name}? Once deleted, it can't be recovered."
343
+ except CanNotBeDeletedException as e:
344
+ if "downstream" not in str(e):
345
+ dependencies_information = str(e)
346
+ warning_message = f"\nDo you want to unlink and delete {datasource_name}? This action can't be undone."
347
+ else:
348
+ raise CLIDatasourceException(
349
+ FeedbackManager.error_datasource_can_not_be_deleted(datasource=datasource_name, error=e)
350
+ )
351
+
352
+ semver: str = ctx.ensure_object(dict)["config"]["semver"]
353
+ await warn_if_in_live(semver)
354
+
355
+ if yes or click.confirm(
356
+ FeedbackManager.warning_confirm_delete_datasource(
357
+ warning_message=warning_message, dependencies_information=dependencies_information
358
+ )
359
+ ):
360
+ try:
361
+ await client.datasource_delete(datasource_name, force=True)
362
+ except DoesNotExistException:
363
+ raise CLIDatasourceException(FeedbackManager.error_datasource_does_not_exist(datasource=datasource_name))
364
+ except CanNotBeDeletedException as e:
365
+ raise CLIDatasourceException(
366
+ FeedbackManager.error_datasource_can_not_be_deleted(datasource=datasource_name, error=e)
367
+ )
368
+ except Exception as e:
369
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
370
+
371
+ click.echo(FeedbackManager.success_delete_datasource(datasource=datasource_name))
372
+
373
+
374
+ @datasource.command(name="truncate")
375
+ @click.argument("datasource_name", required=True)
376
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
377
+ @click.option(
378
+ "--cascade", is_flag=True, default=False, help="Truncate dependent DS attached in cascade to the given DS"
379
+ )
380
+ @click.pass_context
381
+ @coro
382
+ async def datasource_truncate(ctx, datasource_name, yes, cascade):
383
+ """Truncate a data source"""
384
+
385
+ semver: str = ctx.ensure_object(dict)["config"]["semver"]
386
+ await warn_if_in_live(semver)
387
+
388
+ client = ctx.obj["client"]
389
+ if yes or click.confirm(FeedbackManager.warning_confirm_truncate_datasource(datasource=datasource_name)):
390
+ try:
391
+ await client.datasource_truncate(datasource_name)
392
+ except AuthNoTokenException:
393
+ raise
394
+ except DoesNotExistException:
395
+ raise CLIDatasourceException(FeedbackManager.error_datasource_does_not_exist(datasource=datasource_name))
396
+ except Exception as e:
397
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
398
+
399
+ click.echo(FeedbackManager.success_truncate_datasource(datasource=datasource_name))
400
+
401
+ if cascade:
402
+ try:
403
+ ds_cascade_dependencies = await client.datasource_dependencies(
404
+ no_deps=False,
405
+ match=None,
406
+ pipe=None,
407
+ datasource=datasource_name,
408
+ check_for_partial_replace=True,
409
+ recursive=False,
410
+ )
411
+ except Exception as e:
412
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
413
+
414
+ cascade_dependent_ds = list(ds_cascade_dependencies.get("dependencies", {}).keys()) + list(
415
+ ds_cascade_dependencies.get("incompatible_datasources", {}).keys()
416
+ )
417
+ for cascade_ds in cascade_dependent_ds:
418
+ if yes or click.confirm(FeedbackManager.warning_confirm_truncate_datasource(datasource=cascade_ds)):
419
+ try:
420
+ await client.datasource_truncate(cascade_ds)
421
+ except DoesNotExistException:
422
+ raise CLIDatasourceException(
423
+ FeedbackManager.error_datasource_does_not_exist(datasource=datasource_name)
424
+ )
425
+ except Exception as e:
426
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
427
+ click.echo(FeedbackManager.success_truncate_datasource(datasource=cascade_ds))
428
+
429
+
430
+ @datasource.command(name="delete")
431
+ @click.argument("datasource_name")
432
+ @click.option("--sql-condition", default=None, help="SQL WHERE condition to remove rows", hidden=True, required=True)
433
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
434
+ @click.option("--wait", is_flag=True, default=False, help="Wait for delete job to finish, disabled by default")
435
+ @click.option("--dry-run", is_flag=True, default=False, help="Run the command without deleting anything")
436
+ @click.pass_context
437
+ @coro
438
+ async def datasource_delete_rows(ctx, datasource_name, sql_condition, yes, wait, dry_run):
439
+ """
440
+ Delete rows from a datasource
441
+
442
+ - Delete rows with SQL condition: `tb datasource delete [datasource_name] --sql-condition "country='ES'"`
443
+
444
+ - Delete rows with SQL condition and wait for the job to finish: `tb datasource delete [datasource_name] --sql-condition "country='ES'" --wait`
445
+ """
446
+
447
+ semver: str = ctx.ensure_object(dict)["config"]["semver"]
448
+ await warn_if_in_live(semver)
449
+
450
+ client: TinyB = ctx.ensure_object(dict)["client"]
451
+ if (
452
+ dry_run
453
+ or yes
454
+ or click.confirm(
455
+ FeedbackManager.warning_confirm_delete_rows_datasource(
456
+ datasource=datasource_name, delete_condition=sql_condition
457
+ )
458
+ )
459
+ ):
460
+ try:
461
+ res = await client.datasource_delete_rows(datasource_name, sql_condition, dry_run)
462
+ if dry_run:
463
+ click.echo(
464
+ FeedbackManager.success_dry_run_delete_rows_datasource(
465
+ rows=res["rows_to_be_deleted"], datasource=datasource_name, delete_condition=sql_condition
466
+ )
467
+ )
468
+ return
469
+ job_id = res["job_id"]
470
+ job_url = res["job_url"]
471
+ click.echo(FeedbackManager.info_datasource_delete_rows_job_url(url=job_url))
472
+ if wait:
473
+ progress_symbols = ["-", "\\", "|", "/"]
474
+ progress_str = "Waiting for the job to finish"
475
+ # TODO: Use click.echo instead of print and see if the behavior is the same
476
+ print(f"\n{progress_str}", end="") # noqa: T201
477
+
478
+ def progress_line(n):
479
+ print(f"\r{progress_str} {progress_symbols[n % len(progress_symbols)]}", end="") # noqa: T201
480
+
481
+ i = 0
482
+ while True:
483
+ try:
484
+ res = await client._req(f"v0/jobs/{job_id}")
485
+ except Exception:
486
+ raise CLIDatasourceException(FeedbackManager.error_job_status(url=job_url))
487
+ if res["status"] == "done":
488
+ print("\n") # noqa: T201
489
+ click.echo(
490
+ FeedbackManager.success_delete_rows_datasource(
491
+ datasource=datasource_name, delete_condition=sql_condition
492
+ )
493
+ )
494
+ break
495
+ elif res["status"] == "error":
496
+ print("\n") # noqa: T201
497
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=res["error"]))
498
+ await asyncio.sleep(1)
499
+ i += 1
500
+ progress_line(i)
501
+
502
+ except AuthNoTokenException:
503
+ raise
504
+ except DoesNotExistException:
505
+ raise CLIDatasourceException(FeedbackManager.error_datasource_does_not_exist(datasource=datasource_name))
506
+ except Exception as e:
507
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
508
+
509
+
510
+ @datasource.command(
511
+ name="generate",
512
+ short_help="Generates a .datasource file based on a sample CSV, NDJSON or Parquet file from local disk or url",
513
+ )
514
+ @click.argument("filenames", nargs=-1, default=None)
515
+ @click.option("--force", is_flag=True, default=False, help="Override existing files")
516
+ @click.option(
517
+ "--connector",
518
+ type=click.Choice(["bigquery", "snowflake"], case_sensitive=True),
519
+ help="Use from one of the selected connectors. In this case pass a table name as a parameter instead of a file name",
520
+ hidden=True,
521
+ )
522
+ @click.pass_context
523
+ @coro
524
+ async def generate_datasource(ctx: Context, connector: str, filenames, force: bool):
525
+ """Generate a data source file based on a sample CSV file from local disk or url"""
526
+ client: TinyB = ctx.ensure_object(dict)["client"]
527
+
528
+ _connector: Optional["Connector"] = None
529
+ if connector:
530
+ load_connector_config(ctx, connector, False, check_uninstalled=False)
531
+ if connector not in ctx.ensure_object(dict):
532
+ raise CLIDatasourceException(FeedbackManager.error_connector_not_configured(connector=connector))
533
+ else:
534
+ _connector = ctx.ensure_object(dict)[connector]
535
+
536
+ for filename in filenames:
537
+ await _generate_datafile(
538
+ filename, client, force=force, format=get_format_from_filename_or_url(filename), connector=_connector
539
+ )
540
+
541
+
542
+ @datasource.command(name="connect")
543
+ @click.argument("connection")
544
+ @click.argument("datasource_name")
545
+ @click.option("--kafka-topic", "topic", help="For Kafka connections: topic", shell_complete=autocomplete_topics)
546
+ @click.option("--topic", "topic", hidden=True)
547
+ @click.option("--kafka-group", "group", help="For Kafka connections: group ID")
548
+ @click.option("--group", "group", hidden=True)
549
+ @click.option(
550
+ "--kafka-auto-offset-reset",
551
+ "auto_offset_reset",
552
+ default=None,
553
+ type=click.Choice(["latest", "earliest"], case_sensitive=False),
554
+ help='Kafka auto.offset.reset config. Valid values are: ["latest", "earliest"]',
555
+ )
556
+ @click.option("--auto-offset-reset", "auto_offset_reset", hidden=True)
557
+ @click.pass_context
558
+ @coro
559
+ # Example usage: tb datasource connect 776824da-ac64-4de4-b8b8-b909f69d5ed5 new_ds --topic a --group b --auto-offset-reset latest
560
+ async def datasource_connect(ctx, connection, datasource_name, topic, group, auto_offset_reset):
561
+ """Create a new datasource from an existing connection"""
562
+
563
+ validate_datasource_name(datasource_name)
564
+
565
+ client: TinyB = ctx.obj["client"]
566
+
567
+ connector = await client.get_connector(connection, key="name") or await client.get_connector(connection, key="id")
568
+ if not connector:
569
+ raise CLIDatasourceException(FeedbackManager.error_connection_does_not_exists(connection=connection))
570
+
571
+ service: str = connector.get("service", "")
572
+ if service == "kafka":
573
+ topic and validate_kafka_topic(topic)
574
+ group and validate_kafka_group(group)
575
+ auto_offset_reset and validate_kafka_auto_offset_reset(auto_offset_reset)
576
+
577
+ if not topic:
578
+ try:
579
+ topics = await client.kafka_list_topics(connection)
580
+ click.echo("We've discovered the following topics:")
581
+ for t in topics:
582
+ click.echo(f" {t}")
583
+ except Exception as e:
584
+ logging.debug(f"Error listing topics: {e}")
585
+ topic = click.prompt("Kafka topic")
586
+ validate_kafka_topic(topic)
587
+ if not group:
588
+ group = click.prompt("Kafka group")
589
+ validate_kafka_group(group)
590
+ if not auto_offset_reset:
591
+ click.echo("Kafka doesn't seem to have prior commits on this topic and group ID")
592
+ click.echo("Setting auto.offset.reset is required. Valid values:")
593
+ click.echo(" latest Skip earlier messages and ingest only new messages")
594
+ click.echo(" earliest Start ingestion from the first message")
595
+ auto_offset_reset = click.prompt("Kafka auto.offset.reset config")
596
+ validate_kafka_auto_offset_reset(auto_offset_reset)
597
+ if not click.confirm("Proceed?"):
598
+ return
599
+ resp = await client.datasource_kafka_connect(connection, datasource_name, topic, group, auto_offset_reset)
600
+ datasource_id = resp["datasource"]["id"]
601
+ click.echo(FeedbackManager.success_datasource_kafka_connected(id=datasource_id))
602
+ else:
603
+ raise CLIDatasourceException(FeedbackManager.error_unknown_connection_service(service=service))
604
+
605
+
606
+ @datasource.command(name="share")
607
+ @click.argument("datasource_name")
608
+ @click.argument("workspace_name_or_id")
609
+ @click.option("--user_token", default=None, help="When passed, we won't prompt asking for it")
610
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
611
+ @click.pass_context
612
+ @coro
613
+ async def datasource_share(ctx: Context, datasource_name: str, workspace_name_or_id: str, user_token: str, yes: bool):
614
+ """Share a datasource"""
615
+
616
+ config = CLIConfig.get_project_config()
617
+ client = config.get_client()
618
+ host = config.get_host() or CLIConfig.DEFAULTS["host"]
619
+ ui_host = get_display_host(host)
620
+
621
+ _datasource: Dict[str, Any] = {}
622
+ try:
623
+ _datasource = await client.get_datasource(datasource_name)
624
+ except AuthNoTokenException:
625
+ raise
626
+ except DoesNotExistException:
627
+ raise CLIDatasourceException(FeedbackManager.error_datasource_does_not_exist(datasource=datasource_name))
628
+ except Exception as e:
629
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=str(e)))
630
+
631
+ workspaces: List[Dict[str, Any]] = (await client.user_workspaces()).get("workspaces", [])
632
+ destination_workspace = next(
633
+ (
634
+ workspace
635
+ for workspace in workspaces
636
+ if workspace["name"] == workspace_name_or_id or workspace["id"] == workspace_name_or_id
637
+ ),
638
+ None,
639
+ )
640
+ current_workspace = next((workspace for workspace in workspaces if workspace["id"] == config["id"]), None)
641
+
642
+ if not destination_workspace:
643
+ raise CLIDatasourceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id))
644
+
645
+ if not current_workspace:
646
+ raise CLIDatasourceException(FeedbackManager.error_not_authenticated())
647
+
648
+ if not user_token:
649
+ user_token = ask_for_user_token("share a Data Source", ui_host)
650
+ await check_user_token(ctx, user_token)
651
+
652
+ client.token = user_token
653
+
654
+ if yes or click.confirm(
655
+ FeedbackManager.warning_datasource_share(
656
+ datasource=datasource_name,
657
+ source_workspace=current_workspace.get("name"),
658
+ destination_workspace=destination_workspace["name"],
659
+ )
660
+ ):
661
+ try:
662
+ await client.datasource_share(
663
+ datasource_id=_datasource.get("id", ""),
664
+ current_workspace_id=current_workspace.get("id", ""),
665
+ destination_workspace_id=destination_workspace.get("id", ""),
666
+ )
667
+ click.echo(
668
+ FeedbackManager.success_datasource_shared(
669
+ datasource=datasource_name, workspace=destination_workspace["name"]
670
+ )
671
+ )
672
+ except Exception as e:
673
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=str(e)))
674
+
675
+
676
+ @datasource.command(name="unshare")
677
+ @click.argument("datasource_name")
678
+ @click.argument("workspace_name_or_id")
679
+ @click.option("--user_token", default=None, help="When passed, we won't prompt asking for it")
680
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
681
+ @click.pass_context
682
+ @coro
683
+ async def datasource_unshare(ctx: Context, datasource_name: str, workspace_name_or_id: str, user_token: str, yes: bool):
684
+ """Unshare a datasource"""
685
+
686
+ config = CLIConfig.get_project_config()
687
+ client = config.get_client()
688
+ host = config.get_host() or CLIConfig.DEFAULTS["host"]
689
+ ui_host = get_display_host(host)
690
+
691
+ _datasource: Dict[str, Any] = {}
692
+ try:
693
+ _datasource = await client.get_datasource(datasource_name)
694
+ except AuthNoTokenException:
695
+ raise
696
+ except DoesNotExistException:
697
+ raise CLIDatasourceException(FeedbackManager.error_datasource_does_not_exist(datasource=datasource_name))
698
+ except Exception as e:
699
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=str(e)))
700
+
701
+ workspaces: List[Dict[str, Any]] = (await client.user_workspaces()).get("workspaces", [])
702
+ destination_workspace = next(
703
+ (
704
+ workspace
705
+ for workspace in workspaces
706
+ if workspace["name"] == workspace_name_or_id or workspace["id"] == workspace_name_or_id
707
+ ),
708
+ None,
709
+ )
710
+ current_workspace = next((workspace for workspace in workspaces if workspace["id"] == config["id"]), None)
711
+
712
+ if not destination_workspace:
713
+ raise CLIDatasourceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id))
714
+
715
+ if not current_workspace:
716
+ raise CLIDatasourceException(FeedbackManager.error_not_authenticated())
717
+
718
+ if not user_token:
719
+ user_token = ask_for_user_token("unshare a Data Source", ui_host)
720
+ await check_user_token(ctx, user_token)
721
+
722
+ client.token = user_token
723
+
724
+ if yes or click.confirm(
725
+ FeedbackManager.warning_datasource_unshare(
726
+ datasource=datasource_name,
727
+ source_workspace=current_workspace.get("name"),
728
+ destination_workspace=destination_workspace["name"],
729
+ )
730
+ ):
731
+ try:
732
+ await client.datasource_unshare(
733
+ datasource_id=_datasource.get("id", ""),
734
+ current_workspace_id=current_workspace.get("id", ""),
735
+ destination_workspace_id=destination_workspace.get("id", ""),
736
+ )
737
+ click.echo(
738
+ FeedbackManager.success_datasource_unshared(
739
+ datasource=datasource_name, workspace=destination_workspace["name"]
740
+ )
741
+ )
742
+ except Exception as e:
743
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=str(e)))
744
+
745
+
746
+ @datasource.command(name="sync")
747
+ @click.argument("datasource_name")
748
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
749
+ @click.pass_context
750
+ @coro
751
+ async def datasource_sync(ctx, datasource_name: str, yes: bool):
752
+ """Sync from connector defined in .datasource file"""
753
+
754
+ try:
755
+ await sync_data(ctx, datasource_name, yes)
756
+ except AuthNoTokenException:
757
+ raise
758
+ except Exception as e:
759
+ raise CLIDatasourceException(FeedbackManager.error_syncing_datasource(datasource=datasource_name, error=str(e)))
760
+
761
+
762
+ @datasource.command(name="exchange", hidden=True)
763
+ @click.argument("datasource_a", required=True)
764
+ @click.argument("datasource_b", required=True)
765
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
766
+ @click.pass_context
767
+ @coro
768
+ async def datasource_exchange(ctx, datasource_a: str, datasource_b: str, yes: bool):
769
+ """Exchange two data sources"""
770
+
771
+ client = ctx.obj["client"]
772
+
773
+ try:
774
+ if yes or click.confirm(FeedbackManager.warning_exchange(datasource_a=datasource_a, datasource_b=datasource_b)):
775
+ await client.datasource_exchange(datasource_a, datasource_b)
776
+ except Exception as e:
777
+ raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
778
+
779
+ click.echo(FeedbackManager.success_exchange_datasources(datasource_a=datasource_a, datasource_b=datasource_b))
780
+
781
+
782
+ @datasource.command(name="copy")
783
+ @click.argument("datasource_name")
784
+ @click.option(
785
+ "--sql",
786
+ default=None,
787
+ help="Freeform SQL query to select what is copied from Main into the Branch Data Source",
788
+ required=False,
789
+ )
790
+ @click.option(
791
+ "--sql-from-main",
792
+ is_flag=True,
793
+ default=False,
794
+ help="SQL query selecting * from the same Data Source in Main",
795
+ required=False,
796
+ )
797
+ @click.option("--wait", is_flag=True, default=False, help="Wait for copy job to finish, disabled by default")
798
+ @click.pass_context
799
+ @coro
800
+ async def datasource_copy_from_main(
801
+ ctx: Context, datasource_name: str, sql: str, sql_from_main: bool, wait: bool
802
+ ) -> None:
803
+ """Copy data source from Main."""
804
+
805
+ client: TinyB = ctx.ensure_object(dict)["client"]
806
+
807
+ if sql and sql_from_main:
808
+ click.echo(FeedbackManager.error_exception(error="Use --sql or --sql-from-main but not both"))
809
+ return
810
+
811
+ if not sql and not sql_from_main:
812
+ click.echo(FeedbackManager.error_exception(error="Use --sql or --sql-from-main"))
813
+ return
814
+
815
+ response = await client.datasource_query_copy(
816
+ datasource_name, sql if sql else f"SELECT * FROM main.{datasource_name}"
817
+ )
818
+ if "job" not in response:
819
+ raise Exception(response)
820
+ job_id = response["job"]["job_id"]
821
+ job_url = response["job"]["job_url"]
822
+ if sql:
823
+ click.echo(FeedbackManager.info_copy_with_sql_job_url(sql=sql, datasource_name=datasource_name, url=job_url))
824
+ else:
825
+ click.echo(FeedbackManager.info_copy_from_main_job_url(datasource_name=datasource_name, url=job_url))
826
+ if wait:
827
+ base_msg = "Copy from Main Workspace" if sql_from_main else f"Copy from {sql}"
828
+ await wait_job(client, job_id, job_url, f"{base_msg} to {datasource_name}")
829
+
830
+
831
+ @datasource.group(name="scheduling")
832
+ @click.pass_context
833
+ def datasource_scheduling(ctx: Context) -> None:
834
+ """Data Source scheduling commands."""
835
+
836
+
837
+ @datasource_scheduling.command(name="state")
838
+ @click.argument("datasource_name")
839
+ @click.pass_context
840
+ @coro
841
+ async def datasource_scheduling_state(ctx: Context, datasource_name: str) -> None:
842
+ """Get the scheduling state of a Data Source."""
843
+ client: TinyB = ctx.obj["client"]
844
+ try:
845
+ await client.get_datasource(datasource_name) # Check if datasource exists
846
+ state = await client.datasource_scheduling_state(datasource_name)
847
+ click.echo(FeedbackManager.info_datasource_scheduling_state(datasource=datasource_name, state=state))
848
+
849
+ except AuthNoTokenException:
850
+ raise
851
+ except Exception as e:
852
+ raise CLIDatasourceException(
853
+ FeedbackManager.error_datasource_scheduling_state(datasource=datasource_name, error=e)
854
+ )
855
+
856
+
857
+ @datasource_scheduling.command(name="pause")
858
+ @click.argument("datasource_name")
859
+ @click.pass_context
860
+ @coro
861
+ async def datasource_scheduling_pause(ctx: Context, datasource_name: str) -> None:
862
+ """Pause the scheduling of a Data Source."""
863
+
864
+ click.echo(FeedbackManager.info_datasource_scheduling_pause())
865
+ client: TinyB = ctx.ensure_object(dict)["client"]
866
+
867
+ try:
868
+ await client.get_datasource(datasource_name) # Check if datasource exists
869
+ await client.datasource_scheduling_pause(datasource_name)
870
+ click.echo(FeedbackManager.success_datasource_scheduling_paused(datasource=datasource_name))
871
+
872
+ except AuthNoTokenException:
873
+ raise
874
+ except Exception as e:
875
+ raise CLIDatasourceException(
876
+ FeedbackManager.error_pausing_datasource_scheduling(datasource=datasource_name, error=e)
877
+ )
878
+
879
+
880
+ @datasource_scheduling.command(name="resume")
881
+ @click.argument("datasource_name")
882
+ @click.pass_context
883
+ @coro
884
+ async def datasource_scheduling_resume(ctx: Context, datasource_name: str) -> None:
885
+ """Resume the scheduling of a Data Source."""
886
+
887
+ click.echo(FeedbackManager.info_datasource_scheduling_resume())
888
+ client: TinyB = ctx.ensure_object(dict)["client"]
889
+
890
+ try:
891
+ await client.get_datasource(datasource_name) # Check if datasource exists
892
+ await client.datasource_scheduling_resume(datasource_name)
893
+ click.echo(FeedbackManager.success_datasource_scheduling_resumed(datasource=datasource_name))
894
+
895
+ except AuthNoTokenException:
896
+ raise
897
+ except Exception as e:
898
+ raise CLIDatasourceException(
899
+ FeedbackManager.error_resuming_datasource_scheduling(datasource=datasource_name, error=e)
900
+ )