tinybird-cli 6.4.1.dev0__tar.gz → 6.5.0__tar.gz

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.
Files changed (47) hide show
  1. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/PKG-INFO +13 -2
  2. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/__cli__.py +2 -2
  3. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/client.py +43 -7
  4. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/config.py +0 -2
  5. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/datafile_common.py +102 -43
  6. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/datatypes.py +2 -2
  7. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/git_settings.py +1 -1
  8. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/sql.py +14 -8
  9. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/sql_template.py +6 -5
  10. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/sql_toolset.py +1 -19
  11. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/auth.py +1 -1
  12. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/branch.py +12 -14
  13. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/cli.py +11 -13
  14. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/common.py +16 -15
  15. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/datasource.py +4 -4
  16. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/job.py +1 -3
  17. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/tag.py +2 -3
  18. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/workspace.py +12 -13
  19. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird_cli.egg-info/PKG-INFO +13 -2
  20. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird_cli.egg-info/requires.txt +1 -1
  21. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/setup.cfg +0 -0
  22. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/ch_utils/constants.py +0 -0
  23. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/ch_utils/engine.py +0 -0
  24. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/check_pypi.py +0 -0
  25. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/context.py +0 -0
  26. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/feedback_manager.py +0 -0
  27. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/sql_template_fmt.py +0 -0
  28. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/syncasync.py +0 -0
  29. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli.py +0 -0
  30. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/cicd.py +0 -0
  31. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/config.py +0 -0
  32. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/connection.py +0 -0
  33. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/exceptions.py +0 -0
  34. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/fmt.py +0 -0
  35. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/pipe.py +0 -0
  36. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/regions.py +0 -0
  37. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/telemetry.py +0 -0
  38. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/test.py +0 -0
  39. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/tinyunit/tinyunit.py +0 -0
  40. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/tinyunit/tinyunit_lib.py +0 -0
  41. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/token.py +0 -0
  42. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tb_cli_modules/workspace_members.py +0 -0
  43. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird/tornado_template.py +0 -0
  44. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird_cli.egg-info/SOURCES.txt +0 -0
  45. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird_cli.egg-info/dependency_links.txt +0 -0
  46. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird_cli.egg-info/entry_points.txt +0 -0
  47. {tinybird_cli-6.4.1.dev0 → tinybird_cli-6.5.0}/tinybird_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: tinybird_cli
3
- Version: 6.4.1.dev0
3
+ Version: 6.5.0
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/cli
6
6
  Author: Tinybird
@@ -12,7 +12,7 @@ Requires-Dist: clickhouse-toolset==0.34.dev0
12
12
  Requires-Dist: click<8.2,>=8.1.8
13
13
  Requires-Dist: colorama==0.4.6
14
14
  Requires-Dist: cryptography~=41.0.0
15
- Requires-Dist: croniter==1.3.15
15
+ Requires-Dist: croniter==6.2.2
16
16
  Requires-Dist: GitPython~=3.1.32
17
17
  Requires-Dist: humanfriendly~=8.2
18
18
  Requires-Dist: pydantic~=2.8.0
@@ -43,6 +43,17 @@ The Tinybird command-line tool allows you to use all the Tinybird functionality
43
43
  Changelog
44
44
  ----------
45
45
 
46
+ 6.5.0
47
+ ***********
48
+
49
+ - `Changed` `tb push` can now perform sharing and unsharing operations on Datasources with a DATASOURCES:CREATE token in workspaces in the same Organization
50
+
51
+ 6.4.1
52
+ ***********
53
+
54
+ - `Improved` feedback messages when suggesting other cli commands.
55
+ - `Fixed` `tb push` rejecting the `%` operator in column DEFAULT/MATERIALIZED expressions, causing failures after `tb pull` on schemas using modulo arithmetic.
56
+
46
57
  6.4.0
47
58
  ***********
48
59
 
@@ -4,5 +4,5 @@ __description__ = 'Tinybird Command Line Tool'
4
4
  __url__ = 'https://www.tinybird.co/docs/cli'
5
5
  __author__ = 'Tinybird'
6
6
  __author_email__ = 'support@tinybird.co'
7
- __version__ = '6.4.1.dev0'
8
- __revision__ = 'c8790f8'
7
+ __version__ = '6.5.0'
8
+ __revision__ = '676e19d'
@@ -340,10 +340,14 @@ class TinyB:
340
340
  for c in connectors
341
341
  ]
342
342
 
343
- async def get_datasource(self, ds_name: str, used_by: bool = False) -> Dict[str, Any]:
343
+ async def get_datasource(
344
+ self, ds_name: str, used_by: bool = False, include_workspace_names: bool = False
345
+ ) -> Dict[str, Any]:
344
346
  params = {
345
347
  "attrs": "used_by" if used_by else "",
346
348
  }
349
+ if include_workspace_names:
350
+ params["include_workspace_names"] = "true"
347
351
  return await self._req(f"/v0/datasources/{ds_name}?{urlencode(params)}")
348
352
 
349
353
  async def alter_datasource(
@@ -489,12 +493,32 @@ class TinyB:
489
493
 
490
494
  return await self._req(f"/v0/dependencies?{urlencode(params)}", timeout=60)
491
495
 
492
- async def datasource_share(self, datasource_id: str, current_workspace_id: str, destination_workspace_id: str):
493
- params = {"origin_workspace_id": current_workspace_id, "destination_workspace_id": destination_workspace_id}
496
+ async def datasource_share(
497
+ self,
498
+ datasource_id: str,
499
+ current_workspace_id: str,
500
+ destination_workspace_id: Optional[str] = None,
501
+ destination_workspace_name: Optional[str] = None,
502
+ ) -> Dict[str, Any]:
503
+ params = {"origin_workspace_id": current_workspace_id}
504
+ if destination_workspace_id:
505
+ params["destination_workspace_id"] = destination_workspace_id
506
+ if destination_workspace_name:
507
+ params["destination_workspace_name"] = destination_workspace_name
494
508
  return await self._req(f"/v0/datasources/{datasource_id}/share", method="POST", data=params)
495
509
 
496
- async def datasource_unshare(self, datasource_id: str, current_workspace_id: str, destination_workspace_id: str):
497
- params = {"origin_workspace_id": current_workspace_id, "destination_workspace_id": destination_workspace_id}
510
+ async def datasource_unshare(
511
+ self,
512
+ datasource_id: str,
513
+ current_workspace_id: str,
514
+ destination_workspace_id: Optional[str] = None,
515
+ destination_workspace_name: Optional[str] = None,
516
+ ) -> Dict[str, Any]:
517
+ params = {"origin_workspace_id": current_workspace_id}
518
+ if destination_workspace_id:
519
+ params["destination_workspace_id"] = destination_workspace_id
520
+ if destination_workspace_name:
521
+ params["destination_workspace_name"] = destination_workspace_name
498
522
  return await self._req(f"/v0/datasources/{datasource_id}/share", method="DELETE", data=params)
499
523
 
500
524
  async def datasource_sync(self, datasource_id: str):
@@ -972,16 +996,28 @@ class TinyB:
972
996
  kafka_sasl_mechanism="PLAIN",
973
997
  kafka_security_protocol="SASL_SSL",
974
998
  kafka_ssl_ca_pem=None,
999
+ kafka_sasl_oauthbearer_method=None,
1000
+ kafka_sasl_oauthbearer_aws_region=None,
1001
+ kafka_sasl_oauthbearer_aws_role_arn=None,
1002
+ kafka_sasl_oauthbearer_aws_external_id=None,
975
1003
  ):
1004
+ is_oauthbearer = kafka_sasl_mechanism == "OAUTHBEARER"
976
1005
  params = {
977
1006
  "service": "kafka",
978
1007
  "kafka_security_protocol": kafka_security_protocol,
979
1008
  "kafka_sasl_mechanism": kafka_sasl_mechanism,
980
1009
  "kafka_bootstrap_servers": kafka_bootstrap_servers,
981
- "kafka_sasl_plain_username": kafka_key,
982
- "kafka_sasl_plain_password": kafka_secret,
983
1010
  "name": kafka_connection_name,
984
1011
  }
1012
+ if is_oauthbearer:
1013
+ params["kafka_sasl_oauthbearer_method"] = kafka_sasl_oauthbearer_method
1014
+ params["kafka_sasl_oauthbearer_aws_region"] = kafka_sasl_oauthbearer_aws_region
1015
+ params["kafka_sasl_oauthbearer_aws_role_arn"] = kafka_sasl_oauthbearer_aws_role_arn
1016
+ if kafka_sasl_oauthbearer_aws_external_id:
1017
+ params["kafka_sasl_oauthbearer_aws_external_id"] = kafka_sasl_oauthbearer_aws_external_id
1018
+ else:
1019
+ params["kafka_sasl_plain_username"] = kafka_key
1020
+ params["kafka_sasl_plain_password"] = kafka_secret
985
1021
 
986
1022
  if kafka_schema_registry_url:
987
1023
  params["kafka_schema_registry_url"] = kafka_schema_registry_url
@@ -37,7 +37,6 @@ LEGACY_HOSTS = {
37
37
  "https://api.wadus2.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus2",
38
38
  "https://api.wadus3.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus3",
39
39
  "https://api.wadus4.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus4",
40
- "https://api.wadus5.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus5",
41
40
  "https://api.wadus1.aws.tinybird.co": "https://app.wadus.tinybird.co/aws/wadus1",
42
41
  "https://api.wadus2.aws.tinybird.co": "https://app.wadus.tinybird.co/aws/wadus2",
43
42
  "https://api.wadus3.aws.tinybird.co": "https://app.wadus.tinybird.co/aws/wadus3",
@@ -59,7 +58,6 @@ LEGACY_HOSTS = {
59
58
  "https://ui.wadus2.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus2",
60
59
  "https://ui.wadus3.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus3",
61
60
  "https://ui.wadus4.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus4",
62
- "https://ui.wadus5.gcp.tinybird.co": "https://app.wadus.tinybird.co/gcp/wadus5",
63
61
  "https://ui.wadus1.aws.tinybird.co": "https://app.wadus.tinybird.co/aws/wadus1",
64
62
  "https://ui.wadus2.aws.tinybird.co": "https://app.wadus.tinybird.co/aws/wadus2",
65
63
  "https://ui.wadus3.aws.tinybird.co": "https://app.wadus.tinybird.co/aws/wadus3",
@@ -425,7 +425,7 @@ class CLIGitRelease:
425
425
 
426
426
  def is_dirty_to_release(self, use_include_dir: bool = True) -> bool:
427
427
  if use_include_dir:
428
- return any([self.repo.is_dirty(path=p) for p in self.paths]) or self.has_untracked_files()
428
+ return any(self.repo.is_dirty(path=p) for p in self.paths) or self.has_untracked_files()
429
429
  else:
430
430
  return self.repo.is_dirty(path=self.path) or self.has_untracked_files()
431
431
 
@@ -594,7 +594,9 @@ class Deployment:
594
594
  ):
595
595
  if self.is_git_release:
596
596
  if not self.current_release:
597
- raise CLIGitReleaseException(FeedbackManager.error_init_release(workspace=self.current_ws["name"]))
597
+ raise CLIGitReleaseException(
598
+ FeedbackManager.error_init_release(workspace=self.current_ws["name"], cli="tb")
599
+ )
598
600
  self.cli_git_release = CLIGitRelease()
599
601
  if not use_main:
600
602
  self.cli_git_release.validate_local_for_release(self.current_release, check_outdated=check_outdated)
@@ -613,7 +615,7 @@ class Deployment:
613
615
  # error until we support it https://gitlab.com/tinybird/analytics/-/issues/9655
614
616
  for d in diffs:
615
617
  if self.cli_git_release.ChangeType(d.change_type) == self.cli_git_release.ChangeType.RENAMED:
616
- raise CLIGitReleaseException(FeedbackManager.error_unsupported_diff())
618
+ raise CLIGitReleaseException(FeedbackManager.error_unsupported_diff(cli="tb"))
617
619
  if not diffs:
618
620
  click.echo(FeedbackManager.info_git_release_no_diffs())
619
621
  changed = self.cli_git_release.get_changes_from_diffs(diffs, filenames)
@@ -701,10 +703,10 @@ class Deployment:
701
703
  release = await self.cli_git_release.update_release(self.tb_client, self.current_ws, commit)
702
704
  click.echo(FeedbackManager.success_git_release(release_commit=release["commit"]))
703
705
  else:
704
- click.echo(FeedbackManager.warning_no_release())
706
+ click.echo(FeedbackManager.warning_no_release(cli="tb"))
705
707
  except Exception as e:
706
708
  if self.only_changes:
707
- click.echo(FeedbackManager.warning_no_release())
709
+ click.echo(FeedbackManager.warning_no_release(cli="tb"))
708
710
  else:
709
711
  raise e
710
712
 
@@ -1233,6 +1235,10 @@ def parse(
1233
1235
  "kafka_key_avro_deserialization": assign_var("kafka_key_avro_deserialization"),
1234
1236
  "kafka_ssl_ca_pem": assign_var("kafka_ssl_ca_pem"),
1235
1237
  "kafka_sasl_mechanism": assign_var("kafka_sasl_mechanism"),
1238
+ "kafka_sasl_oauthbearer_method": assign_var("kafka_sasl_oauthbearer_method"),
1239
+ "kafka_sasl_oauthbearer_aws_region": assign_var("kafka_sasl_oauthbearer_aws_region"),
1240
+ "kafka_sasl_oauthbearer_aws_role_arn": assign_var("kafka_sasl_oauthbearer_aws_role_arn"),
1241
+ "kafka_sasl_oauthbearer_aws_external_id": assign_var("kafka_sasl_oauthbearer_aws_external_id"),
1236
1242
  "import_service": assign_var("import_service"),
1237
1243
  "import_connection_name": assign_var("import_connection_name"),
1238
1244
  "import_schedule": assign_var("import_schedule"),
@@ -1366,6 +1372,7 @@ async def process_file(
1366
1372
 
1367
1373
  if not skip_connectors:
1368
1374
  try:
1375
+ is_oauthbearer = params.get("kafka_sasl_mechanism") == "OAUTHBEARER"
1369
1376
  connector_params = {
1370
1377
  "kafka_bootstrap_servers": params.get("kafka_bootstrap_servers", None),
1371
1378
  "kafka_key": params.get("kafka_key", None),
@@ -1375,6 +1382,12 @@ async def process_file(
1375
1382
  "kafka_schema_registry_url": params.get("kafka_schema_registry_url", None),
1376
1383
  "kafka_ssl_ca_pem": get_ca_pem_content(params.get("kafka_ssl_ca_pem", None), filename),
1377
1384
  "kafka_sasl_mechanism": params.get("kafka_sasl_mechanism", None),
1385
+ "kafka_sasl_oauthbearer_method": params.get("kafka_sasl_oauthbearer_method", None),
1386
+ "kafka_sasl_oauthbearer_aws_region": params.get("kafka_sasl_oauthbearer_aws_region", None),
1387
+ "kafka_sasl_oauthbearer_aws_role_arn": params.get("kafka_sasl_oauthbearer_aws_role_arn", None),
1388
+ "kafka_sasl_oauthbearer_aws_external_id": params.get(
1389
+ "kafka_sasl_oauthbearer_aws_external_id", None
1390
+ ),
1378
1391
  }
1379
1392
 
1380
1393
  connector = await tb_client.get_connection(**connector_params)
@@ -1382,11 +1395,19 @@ async def process_file(
1382
1395
  click.echo(
1383
1396
  FeedbackManager.info_creating_kafka_connection(connection_name=params["kafka_connection_name"])
1384
1397
  )
1385
- required_params = [
1386
- connector_params["kafka_bootstrap_servers"],
1387
- connector_params["kafka_key"],
1388
- connector_params["kafka_secret"],
1389
- ]
1398
+ if is_oauthbearer:
1399
+ required_params = [
1400
+ connector_params["kafka_bootstrap_servers"],
1401
+ connector_params["kafka_sasl_oauthbearer_method"],
1402
+ connector_params["kafka_sasl_oauthbearer_aws_region"],
1403
+ connector_params["kafka_sasl_oauthbearer_aws_role_arn"],
1404
+ ]
1405
+ else:
1406
+ required_params = [
1407
+ connector_params["kafka_bootstrap_servers"],
1408
+ connector_params["kafka_key"],
1409
+ connector_params["kafka_secret"],
1410
+ ]
1390
1411
 
1391
1412
  if not all(required_params):
1392
1413
  raise click.ClickException(FeedbackManager.error_unknown_kafka_connection(datasource=name))
@@ -1547,10 +1568,7 @@ async def process_file(
1547
1568
  period: int = DEFAULT_CRON_PERIOD
1548
1569
 
1549
1570
  if current_ws:
1550
- workspaces = (await tb_client.user_workspaces()).get("workspaces", [])
1551
- workspace_rate_limits: Dict[str, Dict[str, int]] = next(
1552
- (w.get("rate_limits", {}) for w in workspaces if w["id"] == current_ws["id"]), {}
1553
- )
1571
+ workspace_rate_limits: Dict[str, Dict[str, int]] = current_ws.get("rate_limits", {})
1554
1572
  period = workspace_rate_limits.get("api_datasources_create_append_replace", {}).get(
1555
1573
  "period", DEFAULT_CRON_PERIOD
1556
1574
  )
@@ -1630,7 +1648,7 @@ async def process_file(
1630
1648
  deps = []
1631
1649
  nodes: List[Dict[str, Any]] = []
1632
1650
 
1633
- is_copy = any([node for node in doc.nodes if node.get("type", "standard").lower() == PipeNodeTypes.COPY])
1651
+ is_copy = any(node for node in doc.nodes if node.get("type", "standard").lower() == PipeNodeTypes.COPY)
1634
1652
  for node in doc.nodes:
1635
1653
  sql = node["sql"]
1636
1654
  node_type = node.get("type", "standard").lower()
@@ -2910,7 +2928,7 @@ async def new_pipe(
2910
2928
  current_pipe = r.json() if r.status_code == 200 else None
2911
2929
  pipe_exists = current_pipe is not None
2912
2930
 
2913
- is_materialized = any([node.get("params", {}).get("type", None) == "materialized" for node in p["nodes"]])
2931
+ is_materialized = any(node.get("params", {}).get("type", None) == "materialized" for node in p["nodes"])
2914
2932
  copy_node = next((node for node in p["nodes"] if node.get("params", {}).get("type", None) == "copy"), None)
2915
2933
  sink_node = next((node for node in p["nodes"] if node.get("params", {}).get("type", None) == "sink"), None)
2916
2934
  stream_node = next((node for node in p["nodes"] if node.get("params", {}).get("type", None) == "stream"), None)
@@ -3137,7 +3155,7 @@ async def new_pipe(
3137
3155
  async def share_and_unshare_datasource(
3138
3156
  client: TinyB,
3139
3157
  datasource: Dict[str, Any],
3140
- user_token: str,
3158
+ user_token: Optional[str],
3141
3159
  workspaces_current_shared_with: List[str],
3142
3160
  workspaces_to_share: List[str],
3143
3161
  current_ws: Optional[Dict[str, Any]],
@@ -3145,21 +3163,58 @@ async def share_and_unshare_datasource(
3145
3163
  datasource_name = datasource.get("name", "")
3146
3164
  datasource_id = datasource.get("id", "")
3147
3165
  workspaces: List[Dict[str, Any]]
3148
- # We duplicate the client to use the user_token in workspace discovery and sharing operations.
3149
- user_client: TinyB = deepcopy(client)
3150
- user_client.token = user_token
3166
+ user_client: TinyB = deepcopy(client) if user_token else client
3167
+ if user_token:
3168
+ # We duplicate the client to use the user_token in workspace discovery and sharing operations.
3169
+ user_client.token = user_token
3151
3170
 
3152
3171
  # In case we are pushing to a branch, we don't share the datasource
3153
3172
  # FIXME: Have only once way to get the current workspace
3154
3173
  if current_ws:
3155
3174
  workspace = current_ws
3156
- else:
3175
+ elif user_token:
3157
3176
  workspace = await client.user_workspace_branches()
3177
+ else:
3178
+ workspace = await client.workspace_info()
3158
3179
 
3159
3180
  if workspace.get("is_branch", False):
3160
3181
  click.echo(FeedbackManager.info_skipping_sharing_datasources_branch(datasource=datasource["name"]))
3161
3182
  return
3162
3183
 
3184
+ if not user_token:
3185
+ workspaces_current_shared_with = [
3186
+ shared_workspace["name"] for shared_workspace in datasource.get("shared_with_workspaces", [])
3187
+ ]
3188
+ workspace_names_need_to_share = [
3189
+ workspace_name
3190
+ for workspace_name in workspaces_to_share
3191
+ if workspace_name not in workspaces_current_shared_with
3192
+ ]
3193
+ workspace_names_need_to_unshare = [
3194
+ workspace_name
3195
+ for workspace_name in workspaces_current_shared_with
3196
+ if workspace_name not in workspaces_to_share
3197
+ ]
3198
+
3199
+ for workspace_name in workspace_names_need_to_share:
3200
+ await user_client.datasource_share(
3201
+ datasource_id=datasource_id,
3202
+ current_workspace_id=workspace.get("id", ""),
3203
+ destination_workspace_name=workspace_name,
3204
+ )
3205
+ click.echo(FeedbackManager.success_datasource_shared(datasource=datasource_name, workspace=workspace_name))
3206
+
3207
+ for workspace_name in workspace_names_need_to_unshare:
3208
+ await user_client.datasource_unshare(
3209
+ datasource_id=datasource_id,
3210
+ current_workspace_id=workspace.get("id", ""),
3211
+ destination_workspace_name=workspace_name,
3212
+ )
3213
+ click.echo(
3214
+ FeedbackManager.success_datasource_unshared(datasource=datasource_name, workspace=workspace_name)
3215
+ )
3216
+ return
3217
+
3163
3218
  # Use the user token for workspace discovery, as workspace/admin tokens may not list all targets.
3164
3219
  workspaces = (await user_client.user_workspaces()).get("workspaces", [])
3165
3220
  if not workspaces_current_shared_with:
@@ -3243,8 +3298,12 @@ async def new_ds(
3243
3298
  scopes.append(sc)
3244
3299
  await client.alter_tokens(token_name, scopes)
3245
3300
 
3301
+ can_manage_shared_with = bool(user_token or (current_ws and current_ws.get("can_manage_datasources")))
3302
+
3246
3303
  try:
3247
- existing_ds = await client.get_datasource(ds_name)
3304
+ existing_ds = await client.get_datasource(
3305
+ ds_name, include_workspace_names=can_manage_shared_with and not user_token
3306
+ )
3248
3307
  datasource_exists = True
3249
3308
  except DoesNotExistException:
3250
3309
  datasource_exists = False
@@ -3302,7 +3361,7 @@ async def new_ds(
3302
3361
  await manage_tokens()
3303
3362
 
3304
3363
  if ds.get("shared_with"):
3305
- if not user_token:
3364
+ if not can_manage_shared_with:
3306
3365
  click.echo(FeedbackManager.info_skipping_shared_with_entry())
3307
3366
  else:
3308
3367
  await share_and_unshare_datasource(
@@ -3322,7 +3381,7 @@ async def new_ds(
3322
3381
  raise click.ClickException(FeedbackManager.error_datasource_already_exists(datasource=ds_name))
3323
3382
 
3324
3383
  if ds.get("shared_with", []) or existing_ds.get("shared_with", []):
3325
- if not user_token:
3384
+ if not can_manage_shared_with:
3326
3385
  click.echo(FeedbackManager.info_skipping_shared_with_entry())
3327
3386
  else:
3328
3387
  await share_and_unshare_datasource(
@@ -3359,7 +3418,7 @@ async def new_ds(
3359
3418
  existing = existing_ds.get("indexes", [])
3360
3419
  new.sort(key=lambda x: x["name"])
3361
3420
  existing.sort(key=lambda x: x["name"])
3362
- if len(existing) != len(new) or any([(d, d2) for d, d2 in zip(new, existing) if d != d2]):
3421
+ if len(existing) != len(new) or any((d, d2) for d, d2 in zip(new, existing) if d != d2):
3363
3422
  new_indices = ds.get("params", {}).get("indexes") or "0"
3364
3423
  if (
3365
3424
  new_description
@@ -3383,7 +3442,7 @@ async def new_ds(
3383
3442
 
3384
3443
  if alter_response:
3385
3444
  if git_release and not skip_confirmation:
3386
- click.echo(FeedbackManager.info_custom_deployment())
3445
+ click.echo(FeedbackManager.info_custom_deployment(cli="tb"))
3387
3446
  click.echo("***************************************")
3388
3447
  click.echo("***************************************")
3389
3448
  click.echo(FeedbackManager.info_datasource_doesnt_match(datasource=ds_name))
@@ -3559,7 +3618,7 @@ async def new_token(token: Dict[str, Any], client: TinyB, force: bool = False):
3559
3618
 
3560
3619
  if force:
3561
3620
  ADMIN_SCOPES = ["ADMIN", "ADMIN_USER"]
3562
- if any([scope["type"] in ADMIN_SCOPES for scope in existing_token["scopes"]]):
3621
+ if any(scope["type"] in ADMIN_SCOPES for scope in existing_token["scopes"]):
3563
3622
  raise click.ClickException(FeedbackManager.error_token_cannot_be_overriden(token=token["name"]))
3564
3623
 
3565
3624
  await client.token_update(token)
@@ -4002,7 +4061,7 @@ async def build_graph(
4002
4061
  if (
4003
4062
  fork_downstream
4004
4063
  and r.get("resource", "") == "pipes"
4005
- and any(["engine" in x.get("params", {}) for x in r.get("nodes", [])])
4064
+ and any("engine" in x.get("params", {}) for x in r.get("nodes", []))
4006
4065
  ):
4007
4066
  raise click.ClickException(FeedbackManager.error_forkdownstream_pipes_with_engine(pipe=fn))
4008
4067
 
@@ -4034,13 +4093,10 @@ async def build_graph(
4034
4093
 
4035
4094
  # In case the datasource is to be shared and we have mapping, let's replace the name
4036
4095
  if "shared_with" in r and workspace_map:
4037
- mapped_workspaces: List[str] = []
4038
- for shared_with in r["shared_with"]:
4039
- mapped_workspaces.append(
4040
- workspace_map.get(shared_with)
4041
- if workspace_map.get(shared_with, None) is not None
4042
- else shared_with # type: ignore
4043
- )
4096
+ mapped_workspaces: List[str] = [
4097
+ workspace_map.get(shared_with) if workspace_map.get(shared_with, None) is not None else shared_with # type: ignore
4098
+ for shared_with in r["shared_with"]
4099
+ ]
4044
4100
  r["shared_with"] = mapped_workspaces
4045
4101
 
4046
4102
  dep_map[fn] = set(dep_list)
@@ -4241,10 +4297,13 @@ async def folder_push(
4241
4297
  hide_folders: bool = False,
4242
4298
  on_demand_compute: bool = False,
4243
4299
  ):
4244
- workspaces: List[Dict[str, Any]] = (await tb_client.user_workspaces_and_branches()).get("workspaces", [])
4245
- current_ws: Dict[str, Any] = next(
4246
- (workspace for workspace in workspaces if config and workspace.get("id", ".") == config.get("id", "..")), {}
4247
- )
4300
+ if tb_client.semver:
4301
+ workspaces: List[Dict[str, Any]] = (await tb_client.user_workspaces_and_branches()).get("workspaces", [])
4302
+ current_ws: Dict[str, Any] = next(
4303
+ (workspace for workspace in workspaces if config and workspace.get("id", ".") == config.get("id", "..")), {}
4304
+ )
4305
+ else:
4306
+ current_ws = await tb_client.workspace_info()
4248
4307
  is_branch = current_ws.get("is_branch", False)
4249
4308
  has_semver: bool = release_created or False
4250
4309
 
@@ -4270,13 +4329,13 @@ async def folder_push(
4270
4329
  existing_resources: List[str] = [x["name"] for x in datasources] + [x["name"] for x in pipes]
4271
4330
  # replace workspace mapping names
4272
4331
  for old_ws, new_ws in workspace_map.items():
4273
- existing_resources = [re.sub(f"^{old_ws}\.", f"{new_ws}.", x) for x in existing_resources]
4332
+ existing_resources = [re.sub(f"^{old_ws}\\.", f"{new_ws}.", x) for x in existing_resources]
4274
4333
 
4275
4334
  remote_resource_names = [get_remote_resource_name_without_version(x) for x in existing_resources]
4276
4335
 
4277
4336
  # replace workspace mapping names
4278
4337
  for old_ws, new_ws in workspace_map.items():
4279
- remote_resource_names = [re.sub(f"^{old_ws}\.", f"{new_ws}.", x) for x in remote_resource_names]
4338
+ remote_resource_names = [re.sub(f"^{old_ws}\\.", f"{new_ws}.", x) for x in remote_resource_names]
4280
4339
 
4281
4340
  if not filenames:
4282
4341
  filenames = get_project_filenames(folder)
@@ -5555,7 +5614,7 @@ def is_materialized(resource: Optional[Dict[str, Any]]) -> bool:
5555
5614
  return False
5556
5615
 
5557
5616
  is_materialized = any(
5558
- [node.get("params", {}).get("type", None) == "materialized" for node in resource.get("nodes", []) or []]
5617
+ node.get("params", {}).get("type", None) == "materialized" for node in resource.get("nodes", []) or []
5559
5618
  )
5560
5619
  return is_materialized
5561
5620
 
@@ -5634,7 +5693,7 @@ async def create_release(
5634
5693
  def has_internal_datafiles(folder: str) -> bool:
5635
5694
  folder = folder or "."
5636
5695
  filenames = get_project_filenames(folder)
5637
- return any([f for f in filenames if "spans" in str(f) and "vendor" not in str(f)])
5696
+ return any(f for f in filenames if "spans" in str(f) and "vendor" not in str(f))
5638
5697
 
5639
5698
 
5640
5699
  def is_file_a_datasource(filename: str) -> bool:
@@ -117,11 +117,11 @@ def date_test(x: str) -> bool:
117
117
 
118
118
 
119
119
  def datetime64_test(x: str) -> bool:
120
- return any([p.match(x) for p in datetime64_patterns])
120
+ return any(p.match(x) for p in datetime64_patterns)
121
121
 
122
122
 
123
123
  def datetime_test(x: str) -> bool:
124
- return any([p.match(x) for p in datetime_patterns])
124
+ return any(p.match(x) for p in datetime_patterns)
125
125
 
126
126
 
127
127
  def int_8_test(x: str) -> bool:
@@ -71,7 +71,7 @@ VERSION=0.0.0
71
71
  # TB_SKIP_REGRESSION=0
72
72
 
73
73
  # 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.
74
- # OBFUSCATE_REGEX_PATTERN="https://(www\.)?[^/]+||^Follow these instructions =>"
74
+ # OBFUSCATE_REGEX_PATTERN="https://(www\\.)?[^/]+||^Follow these instructions =>"
75
75
  # OBFUSCATE_PATTERN_SEPARATOR=||
76
76
  ##########
77
77
  """
@@ -6,7 +6,7 @@ from dataclasses import dataclass
6
6
  from typing import Any, Dict, Iterable, List, Optional
7
7
 
8
8
  valid_chars_name: str = string.ascii_letters + string.digits + "._`*<>+-'"
9
- valid_chars_fn: str = valid_chars_name + "[](),=!?:/ \n\t\r"
9
+ valid_chars_fn: str = valid_chars_name + "[](),=!?:/% \n\t\r"
10
10
  # Use sets for O(1) membership checks in hot loops
11
11
  _VALID_CHARS_NAME_SET = set(valid_chars_name)
12
12
  _VALID_CHARS_FN_SET = set(valid_chars_fn)
@@ -233,10 +233,21 @@ def try_to_fix_nullable_in_simple_aggregating_function(t: str) -> Optional[str]:
233
233
  if match := _RE_TRY_FIX_NULLABLE_SAF.search(t):
234
234
  fn = match.group(1)
235
235
  inner_type = match.group(2)
236
- result = f"SimpleAggregateFunction({fn}, Nullable({inner_type}))"
236
+ if "Nullable(" not in inner_type:
237
+ result = f"SimpleAggregateFunction({fn}, Nullable({inner_type}))"
237
238
  return result
238
239
 
239
240
 
241
+ def wrap_nullable(col: dict[str, Any]):
242
+ if col["nullable"]:
243
+ if (col_type := try_to_fix_nullable_in_simple_aggregating_function(col["type"])) is None:
244
+ # Skip wrapping if Nullable already present, e.g. LowCardinality(Nullable(String))
245
+ col_type = col["type"] if "Nullable(" in col["type"] else "Nullable(%s)" % col["type"]
246
+ else:
247
+ col_type = col["type"]
248
+ return col_type
249
+
250
+
240
251
  def schema_to_sql_columns(schema: List[Dict[str, Any]], skip_jsonpaths: bool = False) -> List[str]:
241
252
  """return an array with each column in SQL
242
253
  >>> schema_to_sql_columns([{'name': 'temperature', 'type': 'Float32', 'codec': None, 'default_value': None, 'nullable': False, 'normalized_name': 'temperature'}, {'name': 'temperature_delta', 'type': 'Float32', 'codec': 'CODEC(Delta(4), LZ4))', 'default_value': 'MATERIALIZED temperature', 'nullable': False, 'normalized_name': 'temperature_delta'}])
@@ -255,12 +266,7 @@ def schema_to_sql_columns(schema: List[Dict[str, Any]], skip_jsonpaths: bool = F
255
266
  columns: List[str] = []
256
267
  for x in schema:
257
268
  name = x["normalized_name"] if "normalized_name" in x else x["name"]
258
- if x["nullable"]:
259
- if (_type := try_to_fix_nullable_in_simple_aggregating_function(x["type"])) is None:
260
- # Skip wrapping if Nullable already present, e.g. LowCardinality(Nullable(String))
261
- _type = x["type"] if "Nullable(" in x["type"] else "Nullable(%s)" % x["type"]
262
- else:
263
- _type = x["type"]
269
+ _type = wrap_nullable(x)
264
270
  parts = [col_name(name, backquotes=True), _type]
265
271
  if x.get("jsonpath", None) and not skip_jsonpaths:
266
272
  parts.append(f"`json:{x['jsonpath']}`")
@@ -20,6 +20,8 @@ from tinybird.context import (
20
20
  from .datatypes import testers
21
21
  from .tornado_template import VALID_CUSTOM_FUNCTION_NAMES, SecurityException, Template
22
22
 
23
+ VALID_ACTIVATE_FEATURES = frozenset(["analyzer", "parallel_replicas", "optimize_aggregation_in_order"])
24
+
23
25
  TB_SECRET_IN_TEST_MODE = "tb_secret_dont_raise"
24
26
  TB_SECRET_PREFIX = "tb_secret_"
25
27
  CH_PARAM_PREFIX = "param_"
@@ -1463,10 +1465,9 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1463
1465
  return Expression(f"-- cache_ttl {ttl_expression}\n")
1464
1466
 
1465
1467
  def set_activate(feature):
1466
- valid_features = ("analyzer", "parallel_replicas")
1467
- if feature not in valid_features:
1468
+ if feature not in VALID_ACTIVATE_FEATURES:
1468
1469
  raise SQLTemplateException(f"'{feature}' is not a valid 'activate' argument")
1469
- template_execution_results["activate"] = feature
1470
+ template_execution_results.setdefault("activate", set()).add(feature)
1470
1471
  return Expression(f"-- activate {feature}\n")
1471
1472
 
1472
1473
  def set_disable_feature(feature):
@@ -2143,7 +2144,7 @@ def get_var_data(content, node_id=None):
2143
2144
  return [dict(name=k, **v) for k, v in vars.items()]
2144
2145
 
2145
2146
 
2146
- def get_var_names_and_types(t, node_id=None):
2147
+ def get_var_names_and_types(t: Template, node_id: Optional[str] = None) -> List[Dict[str, Any]]:
2147
2148
  """
2148
2149
  >>> get_var_names_and_types(Template("SELECT * FROM filter_value WHERE description = {{Float32(with_value, 0.0)}}"))
2149
2150
  [{'name': 'with_value', 'type': 'Float32', 'default': 0.0}]
@@ -2825,7 +2826,7 @@ def render_sql_template(
2825
2826
  return Comment("error launched")
2826
2827
 
2827
2828
  v: dict = {x["name"]: Placeholder(x["name"], x["line"]) for x in template_variables}
2828
- is_tb_secret = any([s for s in template_variables if s["name"] == "tb_secret" or s["name"] == "tb_var"])
2829
+ is_tb_secret = any(s for s in template_variables if s["name"] == "tb_secret" or s["name"] == "tb_var")
2829
2830
 
2830
2831
  if variables:
2831
2832
  v.update(variables)
@@ -22,7 +22,7 @@ VALID_REMOTE = "VALID_REMOTE"
22
22
 
23
23
  class InvalidFunction(ValueError):
24
24
  def __init__(self, msg: str = "", table_function_name: str = ""):
25
- if any([fn for fn in COPY_ENABLED_TABLE_FUNCTIONS if fn in msg]):
25
+ if any(fn for fn in COPY_ENABLED_TABLE_FUNCTIONS if fn in msg):
26
26
  msg = msg.replace("is restricted", "is restricted to Copy Pipes")
27
27
 
28
28
  if table_function_name:
@@ -75,19 +75,6 @@ def explain_plan(sql: str) -> str:
75
75
  return chquery.explain_ast(sql)
76
76
 
77
77
 
78
- @dataclass(frozen=True)
79
- class ColumnInfo:
80
- name: str
81
- type: str
82
- nullable: bool
83
- default_specifier: str = ""
84
- default_expression: str | None = None
85
- codec: str | None = None
86
- comment: str | None = None
87
- ttl: str | None = None
88
- is_primary_key: bool = False
89
-
90
-
91
78
  @dataclass
92
79
  class MaterializedViewTarget:
93
80
  database: Optional[str]
@@ -118,11 +105,6 @@ def parse_materialized_view_target(create_table_query: str) -> Optional[Material
118
105
  )
119
106
 
120
107
 
121
- def get_columns_from_create_query(sql_schema: str) -> list[ColumnInfo]:
122
- columns = chquery.get_columns_from_create_query(sql_schema)
123
- return [ColumnInfo(**col) for col in columns]
124
-
125
-
126
108
  def has_join(sql: str) -> bool:
127
109
  return any(line.rstrip().startswith("TableJoin") for line in explain_plan(sql).split())
128
110
 
@@ -234,7 +234,7 @@ async def auth_use(region_name_or_host_or_id: str) -> None:
234
234
  config.set_host(host)
235
235
 
236
236
  if not await try_authenticate(config, regions):
237
- msg = FeedbackManager.error_wrong_config_file(config_file=config._path)
237
+ msg = FeedbackManager.error_wrong_config_file(config_file=config._path, cli="tb")
238
238
  raise CLIAuthException(msg)
239
239
 
240
240
  config.persist_to_file()
@@ -58,11 +58,10 @@ async def release_ls() -> None:
58
58
  async def print_releases(config: CLIConfig):
59
59
  response = await config.get_client().releases(config["id"])
60
60
 
61
- table: List[Tuple[str, str, str, str, str]] = []
62
- for release in response["releases"]:
63
- table.append(
64
- (release["created_at"], release["semver"], release["status"], release["commit"], release["rollback"])
65
- )
61
+ table: List[Tuple[str, str, str, str, str]] = [
62
+ (release["created_at"], release["semver"], release["status"], release["commit"], release["rollback"])
63
+ for release in response["releases"]
64
+ ]
66
65
 
67
66
  columns = ["created_at", "semver", "status", "commit", "rollback release"]
68
67
  click.echo(FeedbackManager.info_releases())
@@ -79,7 +78,7 @@ async def print_releases(config: CLIConfig):
79
78
  )
80
79
  @coro
81
80
  async def release_generate(semver: str) -> None:
82
- click.echo(FeedbackManager.warning_deprecated_releases())
81
+ click.echo(FeedbackManager.warning_deprecated_releases(cli="tb"))
83
82
  if os.path.exists(".tinyenv"):
84
83
  async with aiofiles.open(".tinyenv", "r") as env_file:
85
84
  lines = await env_file.readlines()
@@ -147,7 +146,7 @@ set -euxo pipefail
147
146
  )
148
147
  @coro
149
148
  async def release_create(semver: str, commit: Optional[str]) -> None:
150
- click.echo(FeedbackManager.warning_deprecated_releases())
149
+ click.echo(FeedbackManager.warning_deprecated_releases(cli="tb"))
151
150
  config = CLIConfig.get_project_config()
152
151
  _ = await try_update_config_with_remote(config, only_if_needed=True)
153
152
 
@@ -165,7 +164,7 @@ async def release_promote(semver: str) -> None:
165
164
  """
166
165
  The oldest rollback Release will be automatically removed if no usage, otherwise export TB_FORCE_REMOVE_OLDEST_ROLLBACK="1" to force deletion
167
166
  """
168
- click.echo(FeedbackManager.warning_deprecated_releases())
167
+ click.echo(FeedbackManager.warning_deprecated_releases(cli="tb"))
169
168
  config = CLIConfig.get_project_config()
170
169
  _ = await try_update_config_with_remote(config, only_if_needed=True)
171
170
 
@@ -191,7 +190,7 @@ async def release_promote(semver: str) -> None:
191
190
  )
192
191
  @coro
193
192
  async def release_preview(semver: str) -> None:
194
- click.echo(FeedbackManager.warning_deprecated_releases())
193
+ click.echo(FeedbackManager.warning_deprecated_releases(cli="tb"))
195
194
  config = CLIConfig.get_project_config()
196
195
  _ = await try_update_config_with_remote(config, only_if_needed=True)
197
196
 
@@ -208,7 +207,7 @@ async def release_preview(semver: str) -> None:
208
207
  @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
209
208
  @coro
210
209
  async def release_rollback(yes: bool) -> None:
211
- click.echo(FeedbackManager.warning_deprecated_releases())
210
+ click.echo(FeedbackManager.warning_deprecated_releases(cli="tb"))
212
211
  config = CLIConfig.get_project_config()
213
212
  _ = await try_update_config_with_remote(config, only_if_needed=False)
214
213
 
@@ -252,7 +251,7 @@ async def release_rollback(yes: bool) -> None:
252
251
  )
253
252
  @coro
254
253
  async def release_rm(semver: str, oldest_rollback: bool, force: bool, yes: bool, dry_run: bool) -> None:
255
- click.echo(FeedbackManager.warning_deprecated_releases())
254
+ click.echo(FeedbackManager.warning_deprecated_releases(cli="tb"))
256
255
  if (not semver and not oldest_rollback) or (semver and oldest_rollback):
257
256
  raise CLIException(FeedbackManager.error_release_rm_param())
258
257
 
@@ -278,7 +277,6 @@ async def release_rm(semver: str, oldest_rollback: bool, force: bool, yes: bool,
278
277
  @cli.group()
279
278
  def branch() -> None:
280
279
  """Branch commands. Branches are an experimental feature only available in beta. Running branch commands without activation will return an error"""
281
- pass
282
280
 
283
281
 
284
282
  @branch.command(name="ls")
@@ -427,7 +425,7 @@ async def delete_branch(branch_name_or_id: str, yes: bool) -> None:
427
425
  raise CLIBranchException(FeedbackManager.error_exception(error=str(e)))
428
426
 
429
427
  if not workspace_to_delete:
430
- raise CLIBranchException(FeedbackManager.error_branch(branch=branch_name_or_id))
428
+ raise CLIBranchException(FeedbackManager.error_branch(branch=branch_name_or_id, cli="tb"))
431
429
 
432
430
  if yes or click.confirm(FeedbackManager.warning_confirm_delete_branch(branch=workspace_to_delete["name"])):
433
431
  need_to_switch_to_main = workspace_to_delete.get("main") and config["id"] == workspace_to_delete["id"]
@@ -450,7 +448,7 @@ async def delete_branch(branch_name_or_id: str, yes: bool) -> None:
450
448
  if workspace_main:
451
449
  await switch_to_workspace_by_user_workspace_data(config, workspace_main)
452
450
  else:
453
- raise CLIException(FeedbackManager.error_switching_to_main())
451
+ raise CLIException(FeedbackManager.error_switching_to_main(cli="tb"))
454
452
 
455
453
 
456
454
  @branch.command(
@@ -127,7 +127,7 @@ async def cli(
127
127
  click.echo(FeedbackManager.warning_development_cli())
128
128
 
129
129
  if "x.y.z" not in CURRENT_VERSION and latest_version != CURRENT_VERSION:
130
- click.echo(FeedbackManager.warning_update_version(latest_version=latest_version))
130
+ click.echo(FeedbackManager.warning_update_version(latest_version=latest_version, cli="tb"))
131
131
  click.echo(FeedbackManager.warning_current_version(current_version=CURRENT_VERSION))
132
132
 
133
133
  if debug:
@@ -237,7 +237,7 @@ async def init(
237
237
  )
238
238
 
239
239
  if current_ws.get("is_branch"):
240
- raise CLIException(FeedbackManager.error_not_allowed_in_branch())
240
+ raise CLIException(FeedbackManager.error_not_allowed_in_branch(cli="tb"))
241
241
 
242
242
  await folder_init(client, folder, generate_datasources, generate_releases=True, force=force)
243
243
 
@@ -255,7 +255,7 @@ async def init(
255
255
 
256
256
  if sync_git:
257
257
  if not cli_git_release.is_main_branch() and not override_commit:
258
- raise CLIGitReleaseException(FeedbackManager.error_no_git_main_branch())
258
+ raise CLIGitReleaseException(FeedbackManager.error_no_git_main_branch(cli="tb"))
259
259
 
260
260
  if not cli_git_release.is_dottinyb_ignored():
261
261
  raise CLIGitReleaseException(
@@ -316,7 +316,7 @@ async def init(
316
316
 
317
317
  else:
318
318
  click.echo(FeedbackManager.info_no_git_release_yet(workspace=current_ws["name"]))
319
- click.echo(FeedbackManager.info_diff_resources_for_git_init())
319
+ click.echo(FeedbackManager.info_diff_resources_for_git_init(cli="tb"))
320
320
  changed = await diff_command(
321
321
  [], True, client, with_print=False, verbose=False, clean_up=True, progress_bar=True
322
322
  )
@@ -342,7 +342,7 @@ async def init(
342
342
  if cli_git_release.is_dirty_to_init():
343
343
  raise CLIGitReleaseException(
344
344
  FeedbackManager.error_commit_changes_to_init_release(
345
- path=cli_git_release.path, git_output=cli_git_release.status()
345
+ path=cli_git_release.path, git_output=cli_git_release.status(), cli="tb"
346
346
  )
347
347
  )
348
348
  try:
@@ -732,19 +732,19 @@ async def diff(
732
732
  for workspace in response["workspaces"]:
733
733
  if config["id"] == workspace["id"]:
734
734
  if not workspace.get("is_branch"):
735
- raise CLIException(FeedbackManager.error_not_a_branch())
735
+ raise CLIException(FeedbackManager.error_not_a_branch(cli="tb"))
736
736
 
737
737
  origin = workspace["main"]
738
738
  workspace = await get_current_main_workspace(config)
739
739
 
740
740
  if not workspace:
741
- raise CLIException(FeedbackManager.error_workspace(workspace=origin))
741
+ raise CLIException(FeedbackManager.error_workspace(workspace=origin, cli="tb"))
742
742
 
743
743
  ws_client = _get_tb_client(workspace["token"], config["host"])
744
744
  break
745
745
 
746
746
  if not ws_client:
747
- raise CLIException(FeedbackManager.error_workspace(workspace=origin))
747
+ raise CLIException(FeedbackManager.error_workspace(workspace=origin, cli="tb"))
748
748
  changed = await diff_command(
749
749
  list(filename) if filename else None, fmt, ws_client, no_color, with_print=not only_resources_changed
750
750
  )
@@ -867,9 +867,7 @@ async def sql(
867
867
  if format_ == "json":
868
868
  click.echo(json.dumps(res, indent=8))
869
869
  else:
870
- dd = []
871
- for d in res["data"]:
872
- dd.append(d.values())
870
+ dd = [d.values() for d in res["data"]]
873
871
  echo_safe_humanfriendly_tables_format_smart_table(dd, column_names=res["data"][0].keys())
874
872
  else:
875
873
  click.echo(FeedbackManager.info_no_rows())
@@ -1347,7 +1345,7 @@ async def deploy(
1347
1345
  current_semver = release.get("semver")
1348
1346
 
1349
1347
  if not current_semver:
1350
- click.echo(FeedbackManager.error_init_release(workspace=current_ws.get("name")))
1348
+ click.echo(FeedbackManager.error_init_release(workspace=current_ws.get("name"), cli="tb"))
1351
1349
  sys.exit(1)
1352
1350
 
1353
1351
  release_created = False
@@ -1358,7 +1356,7 @@ async def deploy(
1358
1356
  if not semver:
1359
1357
  semver = current_semver
1360
1358
  else:
1361
- click.echo(FeedbackManager.warning_deprecated_releases())
1359
+ click.echo(FeedbackManager.warning_deprecated_releases(cli="tb"))
1362
1360
 
1363
1361
  if semver and current_semver:
1364
1362
  new_version = version.parse(semver.split("-snapshot")[0])
@@ -234,7 +234,7 @@ variable to '1' or 'true'."""
234
234
  try:
235
235
  self.main(*args, **kwargs)
236
236
  except AuthNoTokenException:
237
- error_msg = FeedbackManager.error_notoken()
237
+ error_msg = FeedbackManager.error_notoken(cli="tb")
238
238
  error_event = "auth_error"
239
239
  exit_code = 1
240
240
  except AuthException as ex:
@@ -315,7 +315,6 @@ async def folder_init(
315
315
  except FileExistsError:
316
316
  if not force:
317
317
  click.echo(FeedbackManager.info_path_already_exists(path=x))
318
- pass
319
318
 
320
319
  if generate_datasources:
321
320
  for format in SUPPORTED_FORMATS:
@@ -690,7 +689,7 @@ async def create_workspace_branch(
690
689
  try:
691
690
  workspace = await get_current_workspace(config)
692
691
  if not workspace:
693
- raise CLIWorkspaceException(FeedbackManager.error_workspace())
692
+ raise CLIWorkspaceException(FeedbackManager.error_workspace(cli="tb"))
694
693
 
695
694
  if not branch_name:
696
695
  click.echo(FeedbackManager.info_workspace_branch_create_greeting())
@@ -748,10 +747,12 @@ async def create_workspace_branch(
748
747
  async def print_data_branch_summary(client, job_id, response=None):
749
748
  response = await client.job(job_id) if job_id else response or {"partitions": []}
750
749
  columns = ["Data Source", "Partition", "Status", "Error"]
751
- table = []
750
+ table: list[list] = []
752
751
  for partition in response["partitions"]:
753
- for p in partition["partitions"]:
754
- table.append([partition["datasource"]["name"], p["partition"], p["status"], p.get("error", "")])
752
+ table.extend(
753
+ [partition["datasource"]["name"], p["partition"], p["status"], p.get("error", "")]
754
+ for p in partition["partitions"]
755
+ )
755
756
  echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
756
757
 
757
758
 
@@ -1195,7 +1196,7 @@ def validate_string_connector_param(param, s):
1195
1196
 
1196
1197
  async def validate_connection_name(client, connection_name, service):
1197
1198
  if await client.get_connector(connection_name, service) is not None:
1198
- raise CLIConnectionException(FeedbackManager.error_connection_already_exists(name=connection_name))
1199
+ raise CLIConnectionException(FeedbackManager.error_connection_already_exists(name=connection_name, cli="tb"))
1199
1200
 
1200
1201
 
1201
1202
  def _get_setting_value(connection, setting, sensitive_settings):
@@ -1223,9 +1224,9 @@ async def switch_workspace(config: CLIConfig, workspace_name_or_id: str, only_en
1223
1224
 
1224
1225
  if not workspace:
1225
1226
  if only_environments:
1226
- raise CLIException(FeedbackManager.error_branch(branch=workspace_name_or_id))
1227
+ raise CLIException(FeedbackManager.error_branch(branch=workspace_name_or_id, cli="tb"))
1227
1228
  else:
1228
- raise CLIException(FeedbackManager.error_workspace(workspace=workspace_name_or_id))
1229
+ raise CLIException(FeedbackManager.error_workspace(workspace=workspace_name_or_id, cli="tb"))
1229
1230
 
1230
1231
  config.set_token(workspace["token"])
1231
1232
  config.set_token_for_host(workspace["token"], config.get_host())
@@ -1391,17 +1392,17 @@ async def get_host_from_region(
1391
1392
  try:
1392
1393
  host = regions[index - 1]["api_host"]
1393
1394
  except Exception:
1394
- raise CLIException(FeedbackManager.error_getting_region_by_index())
1395
+ raise CLIException(FeedbackManager.error_getting_region_by_index(cli="tb"))
1395
1396
  except ValueError:
1396
1397
  region_name = region_name_or_host_or_id.lower()
1397
1398
  try:
1398
1399
  region = get_region_from_host(region_name, regions)
1399
1400
  host = region["api_host"] if region else None
1400
1401
  except Exception:
1401
- raise CLIException(FeedbackManager.error_getting_region_by_name_or_url())
1402
+ raise CLIException(FeedbackManager.error_getting_region_by_name_or_url(cli="tb"))
1402
1403
 
1403
1404
  if not host:
1404
- raise CLIException(FeedbackManager.error_getting_region_by_name_or_url())
1405
+ raise CLIException(FeedbackManager.error_getting_region_by_name_or_url(cli="tb"))
1405
1406
 
1406
1407
  return regions, host
1407
1408
 
@@ -1562,7 +1563,7 @@ async def try_authenticate(
1562
1563
  break
1563
1564
 
1564
1565
  if not authenticated:
1565
- raise CLIAuthException(FeedbackManager.error_invalid_token())
1566
+ raise CLIAuthException(FeedbackManager.error_invalid_token(cli="tb"))
1566
1567
 
1567
1568
  config.persist_to_file()
1568
1569
 
@@ -1899,14 +1900,14 @@ async def validate_aws_iamrole_connection_name(
1899
1900
  ) -> str:
1900
1901
  if connection_name and no_validate is False:
1901
1902
  if await client.get_connector(connection_name) is not None:
1902
- raise CLIConnectionException(FeedbackManager.info_connection_already_exists(name=connection_name))
1903
+ raise CLIConnectionException(FeedbackManager.info_connection_already_exists(name=connection_name, cli="tb"))
1903
1904
  else:
1904
1905
  while not connection_name:
1905
1906
  connection_name = click.prompt("Enter the name for this connection", default=None, show_default=False)
1906
1907
  assert isinstance(connection_name, str)
1907
1908
 
1908
1909
  if no_validate is False and await client.get_connector(connection_name) is not None:
1909
- click.echo(FeedbackManager.info_connection_already_exists(name=connection_name))
1910
+ click.echo(FeedbackManager.info_connection_already_exists(name=connection_name, cli="tb"))
1910
1911
  connection_name = None
1911
1912
  assert isinstance(connection_name, str)
1912
1913
  return connection_name
@@ -556,10 +556,10 @@ async def datasource_share(ctx: Context, datasource_name: str, workspace_name_or
556
556
  current_workspace = next((workspace for workspace in workspaces if workspace["id"] == config["id"]), None)
557
557
 
558
558
  if not destination_workspace:
559
- raise CLIDatasourceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id))
559
+ raise CLIDatasourceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id, cli="tb"))
560
560
 
561
561
  if not current_workspace:
562
- raise CLIDatasourceException(FeedbackManager.error_not_authenticated())
562
+ raise CLIDatasourceException(FeedbackManager.error_not_authenticated(cli="tb"))
563
563
 
564
564
  if not user_token:
565
565
  user_token = ask_for_user_token("share a Data Source", ui_host)
@@ -626,10 +626,10 @@ async def datasource_unshare(ctx: Context, datasource_name: str, workspace_name_
626
626
  current_workspace = next((workspace for workspace in workspaces if workspace["id"] == config["id"]), None)
627
627
 
628
628
  if not destination_workspace:
629
- raise CLIDatasourceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id))
629
+ raise CLIDatasourceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id, cli="tb"))
630
630
 
631
631
  if not current_workspace:
632
- raise CLIDatasourceException(FeedbackManager.error_not_authenticated())
632
+ raise CLIDatasourceException(FeedbackManager.error_not_authenticated(cli="tb"))
633
633
 
634
634
  if not user_token:
635
635
  user_token = ask_for_user_token("unshare a Data Source", ui_host)
@@ -36,9 +36,7 @@ async def jobs_ls(ctx: Context, status: str) -> None:
36
36
  jobs = await client.jobs(status=status)
37
37
  columns = ["id", "kind", "status", "created at", "updated at", "job url"]
38
38
  click.echo(FeedbackManager.info_jobs())
39
- table = []
40
- for j in jobs:
41
- table.append([j[c.replace(" ", "_")] for c in columns])
39
+ table = [[j[c.replace(" ", "_")] for c in columns] for j in jobs]
42
40
  echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
43
41
  click.echo("\n")
44
42
 
@@ -28,11 +28,10 @@ async def tag_ls(ctx: Context, tag_name: Optional[str]) -> None:
28
28
  the_tag = [tag for tag in response["tags"] if tag["name"] == tag_name]
29
29
 
30
30
  columns = ["name", "id", "type"]
31
- table = []
31
+ table: list[list] = []
32
32
 
33
33
  if len(the_tag) > 0:
34
- for resource in the_tag[0]["resources"]:
35
- table.append([resource["name"], resource["id"], resource["type"]])
34
+ table.extend([resource["name"], resource["id"], resource["type"]] for resource in the_tag[0]["resources"])
36
35
 
37
36
  click.echo(FeedbackManager.info_tag_resources(tag_name=tag_name))
38
37
  echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
@@ -55,19 +55,18 @@ async def workspace_ls(ctx: Context) -> None:
55
55
  raise CLIWorkspaceException(FeedbackManager.error_unable_to_identify_main_workspace())
56
56
 
57
57
  columns = ["name", "id", "role", "plan", "current"]
58
- table = []
59
58
  click.echo(FeedbackManager.info_workspaces())
60
59
 
61
- for workspace in response["workspaces"]:
62
- table.append(
63
- [
64
- workspace["name"],
65
- workspace["id"],
66
- workspace["role"],
67
- _get_workspace_plan_name(workspace["plan"]),
68
- current_main_workspace["id"] == workspace["id"],
69
- ]
70
- )
60
+ table = [
61
+ [
62
+ workspace["name"],
63
+ workspace["id"],
64
+ workspace["role"],
65
+ _get_workspace_plan_name(workspace["plan"]),
66
+ current_main_workspace["id"] == workspace["id"],
67
+ ]
68
+ for workspace in response["workspaces"]
69
+ ]
71
70
 
72
71
  echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
73
72
 
@@ -114,7 +113,7 @@ async def clear_workspace(ctx: Context, yes: bool, dry_run: bool) -> None:
114
113
  for workspace in response["workspaces"]:
115
114
  if config["id"] == workspace["id"]:
116
115
  if workspace.get("is_branch"):
117
- raise CLIWorkspaceException(FeedbackManager.error_not_allowed_in_branch())
116
+ raise CLIWorkspaceException(FeedbackManager.error_not_allowed_in_branch(cli="tb"))
118
117
  return
119
118
  else:
120
119
  click.echo(FeedbackManager.info_current_workspace())
@@ -297,7 +296,7 @@ async def delete_workspace(
297
296
  )
298
297
 
299
298
  if not workspace_to_delete:
300
- raise CLIWorkspaceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id))
299
+ raise CLIWorkspaceException(FeedbackManager.error_workspace(workspace=workspace_name_or_id, cli="tb"))
301
300
 
302
301
  if yes or click.confirm(
303
302
  FeedbackManager.warning_confirm_delete_workspace(workspace_name=workspace_to_delete.get("name"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: tinybird_cli
3
- Version: 6.4.1.dev0
3
+ Version: 6.5.0
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/cli
6
6
  Author: Tinybird
@@ -12,7 +12,7 @@ Requires-Dist: clickhouse-toolset==0.34.dev0
12
12
  Requires-Dist: click<8.2,>=8.1.8
13
13
  Requires-Dist: colorama==0.4.6
14
14
  Requires-Dist: cryptography~=41.0.0
15
- Requires-Dist: croniter==1.3.15
15
+ Requires-Dist: croniter==6.2.2
16
16
  Requires-Dist: GitPython~=3.1.32
17
17
  Requires-Dist: humanfriendly~=8.2
18
18
  Requires-Dist: pydantic~=2.8.0
@@ -43,6 +43,17 @@ The Tinybird command-line tool allows you to use all the Tinybird functionality
43
43
  Changelog
44
44
  ----------
45
45
 
46
+ 6.5.0
47
+ ***********
48
+
49
+ - `Changed` `tb push` can now perform sharing and unsharing operations on Datasources with a DATASOURCES:CREATE token in workspaces in the same Organization
50
+
51
+ 6.4.1
52
+ ***********
53
+
54
+ - `Improved` feedback messages when suggesting other cli commands.
55
+ - `Fixed` `tb push` rejecting the `%` operator in column DEFAULT/MATERIALIZED expressions, causing failures after `tb pull` on schemas using modulo arithmetic.
56
+
46
57
  6.4.0
47
58
  ***********
48
59
 
@@ -3,7 +3,7 @@ clickhouse-toolset==0.34.dev0
3
3
  click<8.2,>=8.1.8
4
4
  colorama==0.4.6
5
5
  cryptography~=41.0.0
6
- croniter==1.3.15
6
+ croniter==6.2.2
7
7
  GitPython~=3.1.32
8
8
  humanfriendly~=8.2
9
9
  pydantic~=2.8.0