tinybird-cli 5.7.0__tar.gz → 5.8.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 (48) hide show
  1. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/PKG-INFO +9 -1
  2. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/__cli__.py +2 -2
  3. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/client.py +74 -4
  4. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/datafile.py +199 -26
  5. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/feedback_manager.py +29 -0
  6. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/sql_template.py +12 -3
  7. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/sql_toolset.py +1 -1
  8. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli.py +2 -0
  9. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/cli.py +0 -84
  10. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/datasource.py +62 -0
  11. tinybird-cli-5.8.0/tinybird/tb_cli_modules/fmt.py +90 -0
  12. tinybird-cli-5.8.0/tinybird/tb_cli_modules/tag.py +116 -0
  13. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird_cli.egg-info/PKG-INFO +9 -1
  14. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird_cli.egg-info/SOURCES.txt +2 -0
  15. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/setup.cfg +0 -0
  16. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/ch_utils/constants.py +0 -0
  17. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/ch_utils/engine.py +0 -0
  18. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/check_pypi.py +0 -0
  19. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/config.py +0 -0
  20. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/connectors.py +0 -0
  21. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/context.py +0 -0
  22. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/datatypes.py +0 -0
  23. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/git_settings.py +0 -0
  24. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/sql.py +0 -0
  25. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/sql_template_fmt.py +0 -0
  26. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/syncasync.py +0 -0
  27. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/auth.py +0 -0
  28. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/branch.py +0 -0
  29. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/cicd.py +0 -0
  30. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/common.py +0 -0
  31. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/config.py +0 -0
  32. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/connection.py +0 -0
  33. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/exceptions.py +0 -0
  34. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/job.py +0 -0
  35. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/pipe.py +0 -0
  36. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/regions.py +0 -0
  37. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/telemetry.py +0 -0
  38. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/test.py +0 -0
  39. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/tinyunit/tinyunit.py +0 -0
  40. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/tinyunit/tinyunit_lib.py +0 -0
  41. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/token.py +0 -0
  42. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/workspace.py +0 -0
  43. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tb_cli_modules/workspace_members.py +0 -0
  44. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird/tornado_template.py +0 -0
  45. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird_cli.egg-info/dependency_links.txt +0 -0
  46. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird_cli.egg-info/entry_points.txt +0 -0
  47. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird_cli.egg-info/requires.txt +0 -0
  48. {tinybird-cli-5.7.0 → tinybird-cli-5.8.0}/tinybird_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tinybird-cli
3
- Version: 5.7.0
3
+ Version: 5.8.0
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/cli/introduction.html
6
6
  Author: Tinybird
@@ -18,6 +18,14 @@ The Tinybird command-line tool allows you to use all the Tinybird functionality
18
18
  Changelog
19
19
  ----------
20
20
 
21
+ 5.8.0
22
+ **********
23
+
24
+ - `Added` `tb datasource scheduling` commands to manage the scheduling of a Data Source
25
+ - `Added` `tb tag` commands to manage tags from the CLI.
26
+ - `Added` support to `TAGS` in `tb fmt`.
27
+ - `Added` support to `TAGS` in `tb pull` and `tb push`. Allows tagging resources for filtering in the UI.
28
+
21
29
  5.7.0
22
30
  **********
23
31
 
@@ -4,5 +4,5 @@ __description__ = 'Tinybird Command Line Tool'
4
4
  __url__ = 'https://www.tinybird.co/docs/cli/introduction.html'
5
5
  __author__ = 'Tinybird'
6
6
  __author_email__ = 'support@tinybird.co'
7
- __version__ = '5.7.0'
8
- __revision__ = '2608aa4'
7
+ __version__ = '5.8.0'
8
+ __revision__ = 'cee7088'
@@ -103,12 +103,20 @@ class TinyB(object):
103
103
  self.semver = semver
104
104
 
105
105
  async def _req(
106
- self, endpoint: str, data=None, files=None, method: str = "GET", retries: int = LIMIT_RETRIES, **kwargs
106
+ self,
107
+ endpoint: str,
108
+ data=None,
109
+ files=None,
110
+ method: str = "GET",
111
+ retries: int = LIMIT_RETRIES,
112
+ use_token: Optional[str] = None,
113
+ **kwargs,
107
114
  ): # noqa: C901
108
115
  url = f"{self.host.strip('/')}/{endpoint.strip('/')}"
109
116
 
110
- if self.token:
111
- url += ("&" if "?" in endpoint else "?") + "token=" + self.token
117
+ token_to_use = use_token if use_token else self.token
118
+ if token_to_use:
119
+ url += ("&" if "?" in endpoint else "?") + "token=" + token_to_use
112
120
  if self.version:
113
121
  url += ("&" if "?" in url else "?") + "cli_version=" + quote(self.version)
114
122
  if self.semver:
@@ -162,7 +170,7 @@ class TinyB(object):
162
170
 
163
171
  if response.status_code == 403:
164
172
  error = parse_error_response(response)
165
- if not self.token:
173
+ if not token_to_use:
166
174
  raise AuthNoTokenException(f"Forbidden: {error}")
167
175
  raise AuthException(f"Forbidden: {error}")
168
176
  if response.status_code == 204 or response.status_code == 205:
@@ -466,6 +474,24 @@ class TinyB(object):
466
474
  async def datasource_sync(self, datasource_id: str):
467
475
  return await self._req(f"/v0/datasources/{datasource_id}/scheduling/runs", method="POST", data="")
468
476
 
477
+ async def datasource_scheduling_state(self, datasource_id: str):
478
+ response = await self._req(f"/v0/datasources/{datasource_id}/scheduling/state", method="GET")
479
+ return response["state"]
480
+
481
+ async def datasource_scheduling_pause(self, datasource_id: str):
482
+ return await self._req(
483
+ f"/v0/datasources/{datasource_id}/scheduling/state",
484
+ method="PUT",
485
+ data='{"state": "paused"}',
486
+ )
487
+
488
+ async def datasource_scheduling_resume(self, datasource_id: str):
489
+ return await self._req(
490
+ f"/v0/datasources/{datasource_id}/scheduling/state",
491
+ method="PUT",
492
+ data='{"state": "running"}',
493
+ )
494
+
469
495
  async def datasource_exchange(self, datasource_a: str, datasource_b: str):
470
496
  payload = {"datasource_a": datasource_a, "datasource_b": datasource_b}
471
497
  return await self._req("/v0/datasources/exchange", method="POST", data=payload)
@@ -1210,3 +1236,47 @@ class TinyB(object):
1210
1236
 
1211
1237
  async def check_auth_login(self) -> Dict[str, Any]:
1212
1238
  return await self._req("/v0/auth")
1239
+
1240
+ async def get_all_tags(self, token: Optional[str] = None) -> Dict[str, Any]:
1241
+ return await self._req("/v0/tags", use_token=token)
1242
+
1243
+ async def create_tag_with_resource(
1244
+ self, name: str, resource_id: str, resource_name: str, resource_type: str, token: Optional[str] = None
1245
+ ):
1246
+ return await self._req(
1247
+ "/v0/tags",
1248
+ method="POST",
1249
+ headers={"Content-Type": "application/json"},
1250
+ data=json.dumps(
1251
+ {
1252
+ "name": name,
1253
+ "resources": [{"id": resource_id, "name": resource_name, "type": resource_type}],
1254
+ }
1255
+ ),
1256
+ use_token=token,
1257
+ )
1258
+
1259
+ async def create_tag(self, name: str, token: Optional[str] = None):
1260
+ return await self._req(
1261
+ "/v0/tags",
1262
+ method="POST",
1263
+ headers={"Content-Type": "application/json"},
1264
+ data=json.dumps({"name": name}),
1265
+ use_token=token,
1266
+ )
1267
+
1268
+ async def update_tag(self, name: str, resources: List[Dict[str, Any]], token: Optional[str] = None):
1269
+ await self._req(
1270
+ f"/v0/tags/{name}",
1271
+ method="PUT",
1272
+ headers={"Content-Type": "application/json"},
1273
+ data=json.dumps(
1274
+ {
1275
+ "resources": resources,
1276
+ }
1277
+ ),
1278
+ use_token=token,
1279
+ )
1280
+
1281
+ async def delete_tag(self, name: str, token: Optional[str] = None):
1282
+ await self._req(f"/v0/tags/{name}", method="DELETE", use_token=token)
@@ -760,6 +760,7 @@ class Datafile:
760
760
  self.includes: Dict[str, Any] = {}
761
761
  self.shared_with: List[str] = []
762
762
  self.warnings: List[str] = []
763
+ self.filtering_tags: Optional[List[str]] = None
763
764
 
764
765
  def validate(self) -> None:
765
766
  for x in self.nodes:
@@ -903,6 +904,33 @@ def eval_var(s: str, skip: bool = False) -> str:
903
904
  return Template(s).safe_substitute(os.environ)
904
905
 
905
906
 
907
+ def parse_tags(tags: str) -> Tuple[str, List[str]]:
908
+ """
909
+ Parses a string of tags into:
910
+ - kv_tags: a string of key-value tags: the previous tags we have for operational purposes. It
911
+ has the format key=value&key2=value2 (with_staging=true&with_last_date=true)
912
+ - filtering_tags: a list of tags that are used for filtering.
913
+
914
+ Example: "with_staging=true&with_last_date=true,billing,stats" ->
915
+ kv_tags = {"with_staging": "true", "with_last_date": "true"}
916
+ filtering_tags = ["billing", "stats"]
917
+ """
918
+ kv_tags = []
919
+ filtering_tags = []
920
+
921
+ entries = tags.split(",")
922
+ for entry in entries:
923
+ trimmed_entry = entry.strip()
924
+ if "=" in trimmed_entry:
925
+ kv_tags.append(trimmed_entry)
926
+ else:
927
+ filtering_tags.append(trimmed_entry)
928
+
929
+ all_kv_tags = "&".join(kv_tags)
930
+
931
+ return all_kv_tags, filtering_tags
932
+
933
+
906
934
  def parse(
907
935
  s: str,
908
936
  default_node: Optional[str] = None,
@@ -1100,6 +1128,21 @@ def parse(
1100
1128
 
1101
1129
  return _f
1102
1130
 
1131
+ def tags(*args: str, **kwargs: Any) -> None:
1132
+ raw_tags = _unquote((" ".join(args)).strip())
1133
+ operational_tags, filtering_tags = parse_tags(raw_tags)
1134
+
1135
+ # Pipe nodes or Data Sources
1136
+ if parser_state.current_node and operational_tags:
1137
+ operational_tags_args = (operational_tags,)
1138
+ assign_node_var("tags")(*operational_tags_args, **kwargs)
1139
+
1140
+ if filtering_tags:
1141
+ if doc.filtering_tags is None:
1142
+ doc.filtering_tags = filtering_tags
1143
+ else:
1144
+ doc.filtering_tags += filtering_tags
1145
+
1103
1146
  cmds = {
1104
1147
  "from": assign("from"),
1105
1148
  "source": sources,
@@ -1120,7 +1163,7 @@ def parse(
1120
1163
  "description": description,
1121
1164
  "type": assign_node_var("type"),
1122
1165
  "datasource": assign_node_var("datasource"),
1123
- "tags": assign_node_var("tags"),
1166
+ "tags": tags,
1124
1167
  "target_datasource": assign_node_var("target_datasource"),
1125
1168
  "copy_schedule": assign_node_var(CopyParameters.COPY_SCHEDULE),
1126
1169
  "copy_mode": assign_node_var("mode"),
@@ -1530,6 +1573,7 @@ async def process_file(
1530
1573
  "deps": deps,
1531
1574
  "tokens": doc.tokens,
1532
1575
  "shared_with": doc.shared_with,
1576
+ "filtering_tags": doc.filtering_tags,
1533
1577
  }
1534
1578
  )
1535
1579
 
@@ -1638,6 +1682,7 @@ async def process_file(
1638
1682
  "tokens": doc.tokens,
1639
1683
  "description": description,
1640
1684
  "warnings": doc.warnings,
1685
+ "filtering_tags": doc.filtering_tags,
1641
1686
  }
1642
1687
  ]
1643
1688
  elif DataFileExtensions.TOKEN in filename:
@@ -2646,6 +2691,121 @@ def show_materialized_view_warnings(warnings):
2646
2691
  )
2647
2692
 
2648
2693
 
2694
+ async def update_tags(resource_id: str, resource_name: str, resource_type: str, tags: List[str], tb_client: TinyB):
2695
+ def get_tags_for_resource(all_tags: dict, resource_id: str, resource_name: str) -> List[str]:
2696
+ tag_names = []
2697
+
2698
+ for tag in all_tags.get("tags", []):
2699
+ for resource in tag.get("resources", []):
2700
+ if resource.get("id") == resource_id or resource.get("name") == resource_name:
2701
+ tag_names.append(tag.get("name"))
2702
+ break # No need to check other resources in this tag
2703
+
2704
+ return tag_names
2705
+
2706
+ def get_tag(all_tags: dict, tag_name: str) -> Optional[dict]:
2707
+ for tag in all_tags.get("tags", []):
2708
+ if tag.get("name") == tag_name:
2709
+ return tag
2710
+ return None
2711
+
2712
+ def compare_tags(current_tags: List[str], new_tags: List[str]) -> Tuple[List[str], List[str]]:
2713
+ tags_to_add = list(set(new_tags) - set(current_tags))
2714
+ tags_to_remove = list(set(current_tags) - set(new_tags))
2715
+ return tags_to_add, tags_to_remove
2716
+
2717
+ token_from_main = await get_token_from_main_branch(tb_client)
2718
+
2719
+ try:
2720
+ all_tags = await tb_client.get_all_tags(token=token_from_main)
2721
+ except Exception as e:
2722
+ raise Exception(FeedbackManager.error_getting_tags(error=str(e)))
2723
+
2724
+ # Get all tags of that resource
2725
+ current_tags = get_tags_for_resource(all_tags, resource_id, resource_name)
2726
+
2727
+ # Get the tags to add and remove
2728
+ tags_to_add, tags_to_remove = compare_tags(current_tags, tags)
2729
+
2730
+ # Tags to add
2731
+ for tag_name in tags_to_add:
2732
+ tag = get_tag(all_tags, tag_name)
2733
+
2734
+ if not tag:
2735
+ # Create new tag
2736
+ try:
2737
+ await tb_client.create_tag_with_resource(
2738
+ name=tag_name,
2739
+ resource_id=resource_id,
2740
+ resource_name=resource_name,
2741
+ resource_type=resource_type,
2742
+ token=token_from_main,
2743
+ )
2744
+ except Exception as e:
2745
+ raise Exception(FeedbackManager.error_creating_tag(error=str(e)))
2746
+ else:
2747
+ # Update tag with new resource
2748
+ resources = tag.get("resources", [])
2749
+ resources.append({"id": resource_id, "name": resource_name, "type": resource_type})
2750
+ try:
2751
+ await tb_client.update_tag(tag.get("name", tag_name), resources, token=token_from_main)
2752
+ except Exception as e:
2753
+ raise Exception(FeedbackManager.error_updating_tag(error=str(e)))
2754
+
2755
+ # Tags to delete
2756
+ for tag_name in tags_to_remove:
2757
+ tag = get_tag(all_tags, tag_name)
2758
+
2759
+ if tag:
2760
+ resources = tag.get("resources", [])
2761
+ resources = [resource for resource in resources if resource.get("name") != resource_name]
2762
+ try:
2763
+ await tb_client.update_tag(tag.get("name", tag_name), resources, token=token_from_main)
2764
+ except Exception as e:
2765
+ raise Exception(FeedbackManager.error_updating_tag(error=str(e)))
2766
+
2767
+
2768
+ async def update_tags_in_resource(rs: Dict[str, Any], resource_type: str, client: TinyB):
2769
+ filtering_tags = rs.get("filtering_tags", [])
2770
+
2771
+ if not filtering_tags:
2772
+ return
2773
+
2774
+ resource_id = ""
2775
+ resource_name = ""
2776
+
2777
+ if resource_type == "datasource":
2778
+ ds_name = rs["params"]["name"]
2779
+ try:
2780
+ persisted_ds = await client.get_datasource(ds_name)
2781
+ resource_id = persisted_ds.get("id", "")
2782
+ resource_name = persisted_ds.get("name", "")
2783
+ except DoesNotExistException:
2784
+ click.echo(
2785
+ FeedbackManager.error_tag_generic("Could not get the latest Data Source info for updating its tags.")
2786
+ )
2787
+ elif resource_type == "pipe":
2788
+ pipe_name = rs["name"]
2789
+ try:
2790
+ persisted_pipe = await client.pipe(pipe_name)
2791
+ resource_id = persisted_pipe.get("id", "")
2792
+ resource_name = persisted_pipe.get("name", "")
2793
+ except DoesNotExistException:
2794
+ click.echo(FeedbackManager.error_tag_generic("Could not get the latest Pipe info for updating its tags."))
2795
+
2796
+ if resource_id and resource_name:
2797
+ try:
2798
+ await update_tags(
2799
+ resource_id=resource_id,
2800
+ resource_name=resource_name,
2801
+ resource_type=resource_type,
2802
+ tags=filtering_tags,
2803
+ tb_client=client,
2804
+ )
2805
+ except Exception as e:
2806
+ click.echo(FeedbackManager.error_tag_generic(error=str(e)))
2807
+
2808
+
2649
2809
  async def get_token_from_main_branch(branch_tb_client: TinyB) -> Optional[str]:
2650
2810
  token_from_main_branch = None
2651
2811
  current_workspace = await branch_tb_client.workspace_info()
@@ -3289,32 +3449,33 @@ async def new_ds(
3289
3449
 
3290
3450
  if alter_response and make_changes:
3291
3451
  # alter operation finished
3292
- return
3293
- # removed replacing by default. When a datasource is removed data is
3294
- # removed and all the references needs to be updated
3295
- if (
3296
- os.getenv("TB_I_KNOW_WHAT_I_AM_DOING")
3297
- and click.prompt(FeedbackManager.info_ask_for_datasource_confirmation()) == ds_name
3298
- ): # TODO move to CLI
3299
- try:
3300
- await client.datasource_delete(ds_name)
3301
- click.echo(FeedbackManager.success_delete_datasource(datasource=ds_name))
3302
- except Exception:
3303
- raise click.ClickException(FeedbackManager.error_removing_datasource(datasource=ds_name))
3304
- return
3452
+ pass
3305
3453
  else:
3306
- if alter_error_message:
3307
- raise click.ClickException(
3308
- FeedbackManager.error_datasource_already_exists_and_alter_failed(
3309
- datasource=ds_name, alter_error_message=alter_error_message
3310
- )
3311
- )
3312
- if promote_error_message:
3313
- raise click.ClickException(
3314
- FeedbackManager.error_promoting_datasource(datasource=ds_name, error=promote_error_message)
3315
- )
3454
+ # removed replacing by default. When a datasource is removed data is
3455
+ # removed and all the references needs to be updated
3456
+ if (
3457
+ os.getenv("TB_I_KNOW_WHAT_I_AM_DOING")
3458
+ and click.prompt(FeedbackManager.info_ask_for_datasource_confirmation()) == ds_name
3459
+ ): # TODO move to CLI
3460
+ try:
3461
+ await client.datasource_delete(ds_name)
3462
+ click.echo(FeedbackManager.success_delete_datasource(datasource=ds_name))
3463
+ except Exception:
3464
+ raise click.ClickException(FeedbackManager.error_removing_datasource(datasource=ds_name))
3465
+ return
3316
3466
  else:
3317
- click.echo(FeedbackManager.warning_datasource_already_exists(datasource=ds_name))
3467
+ if alter_error_message:
3468
+ raise click.ClickException(
3469
+ FeedbackManager.error_datasource_already_exists_and_alter_failed(
3470
+ datasource=ds_name, alter_error_message=alter_error_message
3471
+ )
3472
+ )
3473
+ if promote_error_message:
3474
+ raise click.ClickException(
3475
+ FeedbackManager.error_promoting_datasource(datasource=ds_name, error=promote_error_message)
3476
+ )
3477
+ else:
3478
+ click.echo(FeedbackManager.warning_datasource_already_exists(datasource=ds_name))
3318
3479
 
3319
3480
 
3320
3481
  async def new_token(token: Dict[str, Any], client: TinyB, force: bool = False):
@@ -3400,6 +3561,7 @@ async def exec_file(
3400
3561
  fork_downstream=fork_downstream,
3401
3562
  fork=fork,
3402
3563
  )
3564
+ await update_tags_in_resource(r, "pipe", tb_client)
3403
3565
  elif r["resource"] == "datasources":
3404
3566
  await new_ds(
3405
3567
  r,
@@ -3412,6 +3574,7 @@ async def exec_file(
3412
3574
  fork=fork,
3413
3575
  git_release=git_release,
3414
3576
  )
3577
+ await update_tags_in_resource(r, "datasource", tb_client)
3415
3578
  elif r["resource"] == "tokens":
3416
3579
  await new_token(r, tb_client, force)
3417
3580
  else:
@@ -4659,19 +4822,20 @@ async def format_datasource(
4659
4822
  if for_deploy_diff:
4660
4823
  format_description(file_parts, doc)
4661
4824
  format_tokens(file_parts, doc)
4825
+ format_tags(file_parts, doc)
4662
4826
  format_schema(file_parts, doc.nodes[0])
4663
4827
  format_indices(file_parts, doc.nodes[0])
4664
4828
  await format_engine(file_parts, doc.nodes[0], only_ttl=True if not for_deploy_diff else False, client=client)
4665
4829
  if for_deploy_diff:
4666
4830
  format_import_settings(file_parts, doc.nodes[0])
4667
4831
  format_shared_with(file_parts, doc)
4668
-
4669
4832
  else:
4670
4833
  format_sources(file_parts, doc)
4671
4834
  format_maintainer(file_parts, doc)
4672
4835
  format_version(file_parts, doc)
4673
4836
  format_description(file_parts, doc)
4674
4837
  format_tokens(file_parts, doc)
4838
+ format_tags(file_parts, doc)
4675
4839
  format_schema(file_parts, doc.nodes[0])
4676
4840
  format_indices(file_parts, doc.nodes[0])
4677
4841
  await format_engine(file_parts, doc.nodes[0])
@@ -4747,6 +4911,14 @@ def format_shared_with(file_parts: List[str], doc: Datafile) -> List[str]:
4747
4911
  return file_parts
4748
4912
 
4749
4913
 
4914
+ def format_tags(file_parts: List[str], doc: Datafile) -> List[str]:
4915
+ if doc.filtering_tags:
4916
+ file_parts.append(f'TAGS {", ".join(doc.filtering_tags)}')
4917
+ file_parts.append(DATAFILE_NEW_LINE)
4918
+ file_parts.append(DATAFILE_NEW_LINE)
4919
+ return file_parts
4920
+
4921
+
4750
4922
  async def format_engine(
4751
4923
  file_parts: List[str], node: Dict[str, Any], only_ttl: bool = False, client: Optional[TinyB] = None
4752
4924
  ) -> List[str]:
@@ -4883,6 +5055,7 @@ async def format_pipe(
4883
5055
  format_version(file_parts, doc)
4884
5056
  format_description(file_parts, doc)
4885
5057
  format_tokens(file_parts, doc)
5058
+ format_tags(file_parts, doc)
4886
5059
  if doc.includes and not unroll_includes:
4887
5060
  for k in doc.includes:
4888
5061
  # We filter only the include files as we currently have 2 items for each include
@@ -157,6 +157,15 @@ class FeedbackManager:
157
157
  error_creating_copy_job = error_message("Failed creating copy job: {error}")
158
158
  error_pausing_copy_pipe = error_message("Failed pausing copy pipe: {error}")
159
159
  error_resuming_copy_pipe = error_message("Failed resuming copy pipe: {error}")
160
+ error_pausing_datasource_scheduling = error_message(
161
+ "Failed pausing scheduling for Data Source '{datasource}': {error}"
162
+ )
163
+ error_resuming_datasource_scheduling = error_message(
164
+ "Failed resuming scheduling for Data Source '{datasource}': {error}"
165
+ )
166
+ error_datasource_scheduling_state = error_message(
167
+ "Failed requesting scheduling state for Data Source '{datasource}': {error}"
168
+ )
160
169
  error_creating_pipe = error_message("Failed creating pipe {error}")
161
170
  error_creating_sink_job = error_message("Failed creating sink job: {error}")
162
171
  error_running_on_demand_sink_job = error_message("Failed running on-demand sink job: {error}")
@@ -392,6 +401,11 @@ class FeedbackManager:
392
401
  "Several materialized nodes in pipe {pipe}. Use --node param."
393
402
  )
394
403
  error_unlinking_pipe_not_linked = error_message("** {pipe} is not linked (MV, Copy, or Sink).")
404
+ error_getting_tags = error_message("Error getting tags: {error}")
405
+ error_creating_tag = error_message("Error creating new tag: {error}")
406
+ error_updating_tag = error_message("Error updating tag: {error}")
407
+ error_tag_generic = error_message("There was an issue updating tags. {error}")
408
+ error_tag_not_found = error_message("Tag {tag_name} not found.")
395
409
 
396
410
  info_incl_relative_path = info_message("** Relative path {path} does not exist, skipping.")
397
411
  info_ignoring_incl_file = info_message(
@@ -606,6 +620,12 @@ Ready? """
606
620
  warning_pipe_restricted_params = warning_message(
607
621
  "** The parameter names {words} and '{last_word}' are reserved words. Please, choose another name or the pipe will not work as expected."
608
622
  )
623
+ warning_tag_remove_no_resources = prompt_message(
624
+ "Tag {tag_name} is not used by any resource. Do you want to remove it?"
625
+ )
626
+ warning_tag_remove = prompt_message(
627
+ "Tag {tag_name} is used by {resources_len} resources. Do you want to remove it?"
628
+ )
609
629
 
610
630
  info_fixtures_branch = info_message("** Data Fixtures are only pushed to Branches")
611
631
  info_materialize_push_datasource_exists = warning_message("** Data Source {name} already exists")
@@ -670,6 +690,9 @@ Ready? """
670
690
  info_datasource_title = print_message("** {title}", bcolors.BOLD)
671
691
  info_datasource_row = info_message("{row}")
672
692
  info_datasource_delete_rows_job_url = info_message("** Delete rows job url {url}")
693
+ info_datasource_scheduling_state = info_message("** Scheduling state for Data Source '{datasource}': {state}")
694
+ info_datasource_scheduling_pause = info_message("** Pausing scheduling...")
695
+ info_datasource_scheduling_resume = info_message("** Resuming scheduling...")
673
696
  info_pipes = info_message("** Pipes:")
674
697
  info_pipe_name = info_message("** - {pipe}")
675
698
  info_using_node = print_message("** Using last node {node} as endpoint")
@@ -797,6 +820,8 @@ Ready? """
797
820
  info_release_rollback = info_message(
798
821
  "** The following resources IDs are present in the {semver} Release and will be restored:"
799
822
  )
823
+ info_tag_list = info_message("** Tags:")
824
+ info_tag_resources = info_message("** Resources tagged by {tag_name}:")
800
825
  warning_no_release = warning_message(
801
826
  "** Warning: Workspace does not have Releases, run `tb init --git` to activate them."
802
827
  )
@@ -920,6 +945,8 @@ Ready? """
920
945
  success_datasource_unshared = success_message(
921
946
  "** The Data Source {datasource} has been correctly unshared from {workspace}"
922
947
  )
948
+ success_datasource_scheduling_resumed = success_message("""** Scheduling resumed for Data Source '{datasource}'""")
949
+ success_datasource_scheduling_paused = success_message("""** Scheduling paused for Data Source '{datasource}'""")
923
950
  success_connection_created = success_message("** Connection {id} created successfully!")
924
951
 
925
952
  # TODO: Update the message when the .env feature is implemented
@@ -981,5 +1008,7 @@ Ready? """
981
1008
  success_delete_token = success_message("** Token '{token}' removed successfully")
982
1009
  success_refresh_token = success_message("** Token '{token}' refreshed successfully")
983
1010
  success_copy_token = success_message("** Token '{token}' copied to clipboard")
1011
+ success_tag_created = success_message("** Tag '{tag_name}' created!")
1012
+ success_tag_removed = success_message("** Tag '{tag_name}' removed!")
984
1013
 
985
1014
  debug_running_file = print_message("** Running {file}", bcolors.CGREY)
@@ -386,14 +386,14 @@ def array_type(types): # noqa: C901
386
386
  for i, t in enumerate(list_values):
387
387
  if _type in testers:
388
388
  if testers[_type](str(t)):
389
- values.append(expression_wrapper(types[_type](t), f"{x}[{i}]"))
389
+ values.append(expression_wrapper(types[_type](t), str(t)))
390
390
  else:
391
391
  raise SQLTemplateException(
392
392
  f"Error validating {x}[{i}]({t}) to type {_type}",
393
393
  documentation="/cli/advanced-templates.html",
394
394
  )
395
395
  else:
396
- values.append(expression_wrapper(types.get(_type, lambda x: x)(t), f"{x}[{i}]"))
396
+ values.append(expression_wrapper(types.get(_type, lambda x: x)(t), str(t)))
397
397
  return Expression(f"[{','.join(map(str, values))}]")
398
398
  except AttributeError as e:
399
399
  logging.warning(f"AttributeError on Array: {e}")
@@ -1314,8 +1314,9 @@ def expression_wrapper(x, name, escape_arrays: bool = False):
1314
1314
  elif isinstance(x, Comment):
1315
1315
  return "-- {x} \n"
1316
1316
  if x is None:
1317
+ truncated_name = name[:20] + "..." if len(name) > 20 else name
1317
1318
  raise SQLTemplateException(
1318
- f'expression "{name}" evaluated to null', documentation="/cli/advanced-templates.html"
1319
+ f'expression "{truncated_name}" evaluated to null', documentation="/cli/advanced-templates.html"
1319
1320
  )
1320
1321
  if isinstance(x, list) and escape_arrays:
1321
1322
  logging.warning(f"expression_wrapper -> list :{x}:")
@@ -1992,6 +1993,14 @@ def render_sql_template(
1992
1993
  Traceback (most recent call last):
1993
1994
  ...
1994
1995
  tinybird.sql_template.SQLTemplateException: Template Syntax Error: expression "test" evaluated to null
1996
+ >>> render_sql_template("SELECT {{testisasuperlongthingandwedontwanttoreturnthefullthing}}", {'token':'testing'})
1997
+ Traceback (most recent call last):
1998
+ ...
1999
+ tinybird.sql_template.SQLTemplateException: Template Syntax Error: expression "testisasuperlongthin..." evaluated to null
2000
+ >>> render_sql_template("SELECT {{ Array(embedding, 'Float32') }}", {'token':'testing', 'embedding': '1,2,3,4, null'})
2001
+ Traceback (most recent call last):
2002
+ ...
2003
+ tinybird.sql_template.SQLTemplateException: Template Syntax Error: Error validating 1,2,3,4, null[4]( null) to type Float32
1995
2004
  >>> render_sql_template('{% if test %}SELECT 1{% else %} select 2 {% end %}')
1996
2005
  (' select 2 ', {}, [])
1997
2006
  >>> render_sql_template('{% if Int32(test, 1) %}SELECT 1{% else %} select 2 {% end %}')
@@ -293,7 +293,7 @@ def replace_tables(
293
293
  and is_invalid_resource(r, database, default_database, _replaced_with, valid_tables)
294
294
  ):
295
295
  logging.info(
296
- "Resource not found in replace_tables: %s",
296
+ "Resource not found in replace_tables in sql_toolset: %s",
297
297
  {
298
298
  "r": r,
299
299
  "default_database": default_database,
@@ -10,8 +10,10 @@ import tinybird.tb_cli_modules.cli
10
10
  import tinybird.tb_cli_modules.common
11
11
  import tinybird.tb_cli_modules.connection
12
12
  import tinybird.tb_cli_modules.datasource
13
+ import tinybird.tb_cli_modules.fmt
13
14
  import tinybird.tb_cli_modules.job
14
15
  import tinybird.tb_cli_modules.pipe
16
+ import tinybird.tb_cli_modules.tag
15
17
  import tinybird.tb_cli_modules.test
16
18
  import tinybird.tb_cli_modules.token
17
19
  import tinybird.tb_cli_modules.workspace
@@ -3,7 +3,6 @@
3
3
  # - If it makes sense and only when strictly necessary, you can create utility functions in this file.
4
4
  # - But please, **do not** interleave utility functions and command definitions.
5
5
 
6
- import difflib
7
6
  import json
8
7
  import logging
9
8
  import os
@@ -36,21 +35,16 @@ from tinybird.datafile import (
36
35
  Datafile,
37
36
  ParseException,
38
37
  build_graph,
39
- color_diff,
40
38
  create_release,
41
39
  diff_command,
42
40
  folder_pull,
43
41
  folder_push,
44
- format_datasource,
45
- format_pipe,
46
42
  get_project_filenames,
47
43
  get_resource_versions,
48
44
  has_internal_datafiles,
49
- is_file_a_datasource,
50
45
  parse_datasource,
51
46
  parse_pipe,
52
47
  parse_token,
53
- peek,
54
48
  wait_job,
55
49
  )
56
50
  from tinybird.feedback_manager import FeedbackManager
@@ -753,84 +747,6 @@ async def dependencies(
753
747
  raise CLIException(FeedbackManager.error_partial_replace_cant_be_executed(datasource=datasource))
754
748
 
755
749
 
756
- @cli.command()
757
- @click.argument("filenames", type=click.Path(exists=True), nargs=-1, required=True)
758
- @click.option(
759
- "--line-length",
760
- is_flag=False,
761
- default=100,
762
- help="A number indicating the maximum characters per line in the node SQL, lines will be splitted based on the SQL syntax and the number of characters passed as a parameter",
763
- )
764
- @click.option("--dry-run", is_flag=True, default=False, help="Don't ask to override the local file")
765
- @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation to overwrite the local file")
766
- @click.option(
767
- "--diff",
768
- is_flag=True,
769
- default=False,
770
- help="Formats local file, prints the diff and exits 1 if different, 0 if equal",
771
- )
772
- @click.pass_context
773
- @coro
774
- async def fmt(
775
- ctx: Context, filenames: List[str], line_length: int, dry_run: bool, yes: bool, diff: bool
776
- ) -> Optional[str]:
777
- """
778
- Formats a .datasource, .pipe or .incl file
779
-
780
- This command removes comments starting with # from the file, use DESCRIPTION instead.
781
-
782
- The format command tries to parse the datafile so syntax errors might rise.
783
-
784
- .incl files must contain a NODE definition
785
- """
786
-
787
- result = ""
788
- failed = []
789
- for filename in filenames:
790
- if not diff:
791
- click.echo(filename)
792
- extensions = Path(filename).suffixes
793
- if is_file_a_datasource(filename):
794
- result = await format_datasource(filename, skip_eval=True)
795
- elif (".pipe" in extensions) or (".incl" in extensions):
796
- result = await format_pipe(filename, line_length, skip_eval=True)
797
- else:
798
- click.echo("Unsupported file type. Supported files types are: .pipe, .incl and .datasource")
799
- return None
800
-
801
- if diff:
802
- result = result.rstrip("\n")
803
- lines_fmt = [f"{line}\n" for line in result.split("\n")]
804
- with open(filename, "r") as file:
805
- lines_file = file.readlines()
806
- diff_result = difflib.unified_diff(
807
- lines_file, lines_fmt, fromfile=f"{Path(filename).name} local", tofile="fmt datafile"
808
- )
809
- diff_result = color_diff(diff_result)
810
- not_empty, diff_lines = peek(diff_result)
811
- if not_empty:
812
- sys.stdout.writelines(diff_lines)
813
- failed.append(filename)
814
- click.echo("")
815
- else:
816
- click.echo(result)
817
- if dry_run:
818
- return None
819
-
820
- if yes or click.confirm(FeedbackManager.prompt_override_local_file(name=filename)):
821
- with open(f"{filename}", "w") as file:
822
- file.write(result)
823
-
824
- click.echo(FeedbackManager.success_generated_local_file(file=filename))
825
-
826
- if len(failed):
827
- click.echo(FeedbackManager.error_failed_to_format_files(number=len(failed)))
828
- for f in failed:
829
- click.echo(f"tb fmt {f} --yes")
830
- sys.exit(1)
831
- return result
832
-
833
-
834
750
  @cli.command(
835
751
  name="diff",
836
752
  short_help="Diffs local datafiles to the corresponding remote files in the workspace. For the case of .datasource files it just diffs VERSION and SCHEMA, since ENGINE, KAFKA or other metadata is considered immutable.",
@@ -805,3 +805,65 @@ async def datasource_copy_from_main(
805
805
  if wait:
806
806
  base_msg = "Copy from Main Workspace" if sql_from_main else f"Copy from {sql}"
807
807
  await wait_job(client, job_id, job_url, f"{base_msg} to {datasource_name}")
808
+
809
+
810
+ @datasource.group(name="scheduling")
811
+ @click.pass_context
812
+ def datasource_scheduling(ctx: Context) -> None:
813
+ """Data Source scheduling commands."""
814
+
815
+
816
+ @datasource_scheduling.command(name="state")
817
+ @click.argument("datasource_name")
818
+ @click.pass_context
819
+ @coro
820
+ async def datasource_scheduling_state(ctx: Context, datasource_name: str) -> None:
821
+ """Get the scheduling state of a Data Source."""
822
+ client: TinyB = ctx.obj["client"]
823
+ try:
824
+ state = await client.datasource_scheduling_state(datasource_name)
825
+ click.echo(FeedbackManager.info_datasource_scheduling_state(datasource=datasource_name, state=state))
826
+ except Exception as e:
827
+ raise CLIDatasourceException(
828
+ FeedbackManager.error_datasource_scheduling_state(datasource=datasource_name, error=e)
829
+ )
830
+
831
+
832
+ @datasource_scheduling.command(name="pause")
833
+ @click.argument("datasource_name")
834
+ @click.pass_context
835
+ @coro
836
+ async def datasource_scheduling_pause(ctx: Context, datasource_name: str) -> None:
837
+ """Pause the scheduling of a Data Source."""
838
+
839
+ click.echo(FeedbackManager.info_datasource_scheduling_pause())
840
+ client: TinyB = ctx.ensure_object(dict)["client"]
841
+
842
+ try:
843
+ await client.datasource_scheduling_pause(datasource_name)
844
+ click.echo(FeedbackManager.success_datasource_scheduling_paused(datasource=datasource_name))
845
+
846
+ except Exception as e:
847
+ raise CLIDatasourceException(
848
+ FeedbackManager.error_pausing_datasource_scheduling(datasource=datasource_name, error=e)
849
+ )
850
+
851
+
852
+ @datasource_scheduling.command(name="resume")
853
+ @click.argument("datasource_name")
854
+ @click.pass_context
855
+ @coro
856
+ async def datasource_scheduling_resume(ctx: Context, datasource_name: str) -> None:
857
+ """Resume the scheduling of a Data Source."""
858
+
859
+ click.echo(FeedbackManager.info_datasource_scheduling_resume())
860
+ client: TinyB = ctx.ensure_object(dict)["client"]
861
+
862
+ try:
863
+ await client.datasource_scheduling_resume(datasource_name)
864
+ click.echo(FeedbackManager.success_datasource_scheduling_resumed(datasource=datasource_name))
865
+
866
+ except Exception as e:
867
+ raise CLIDatasourceException(
868
+ FeedbackManager.error_resuming_datasource_scheduling(datasource=datasource_name, error=e)
869
+ )
@@ -0,0 +1,90 @@
1
+ import difflib
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+ import click
7
+ from click import Context
8
+
9
+ from tinybird.datafile import color_diff, format_datasource, format_pipe, is_file_a_datasource, peek
10
+ from tinybird.feedback_manager import FeedbackManager
11
+ from tinybird.tb_cli_modules.cli import cli
12
+ from tinybird.tb_cli_modules.common import coro
13
+
14
+
15
+ @cli.command()
16
+ @click.argument("filenames", type=click.Path(exists=True), nargs=-1, required=True)
17
+ @click.option(
18
+ "--line-length",
19
+ is_flag=False,
20
+ default=100,
21
+ help="A number indicating the maximum characters per line in the node SQL, lines will be splitted based on the SQL syntax and the number of characters passed as a parameter",
22
+ )
23
+ @click.option("--dry-run", is_flag=True, default=False, help="Don't ask to override the local file")
24
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation to overwrite the local file")
25
+ @click.option(
26
+ "--diff",
27
+ is_flag=True,
28
+ default=False,
29
+ help="Formats local file, prints the diff and exits 1 if different, 0 if equal",
30
+ )
31
+ @click.pass_context
32
+ @coro
33
+ async def fmt(
34
+ ctx: Context, filenames: List[str], line_length: int, dry_run: bool, yes: bool, diff: bool
35
+ ) -> Optional[str]:
36
+ """
37
+ Formats a .datasource, .pipe or .incl file
38
+
39
+ This command removes comments starting with # from the file, use DESCRIPTION instead.
40
+
41
+ The format command tries to parse the datafile so syntax errors might rise.
42
+
43
+ .incl files must contain a NODE definition
44
+ """
45
+
46
+ result = ""
47
+ failed = []
48
+ for filename in filenames:
49
+ if not diff:
50
+ click.echo(filename)
51
+ extensions = Path(filename).suffixes
52
+ if is_file_a_datasource(filename):
53
+ result = await format_datasource(filename, skip_eval=True)
54
+ elif (".pipe" in extensions) or (".incl" in extensions):
55
+ result = await format_pipe(filename, line_length, skip_eval=True)
56
+ else:
57
+ click.echo("Unsupported file type. Supported files types are: .pipe, .incl and .datasource")
58
+ return None
59
+
60
+ if diff:
61
+ result = result.rstrip("\n")
62
+ lines_fmt = [f"{line}\n" for line in result.split("\n")]
63
+ with open(filename, "r") as file:
64
+ lines_file = file.readlines()
65
+ diff_result = difflib.unified_diff(
66
+ lines_file, lines_fmt, fromfile=f"{Path(filename).name} local", tofile="fmt datafile"
67
+ )
68
+ diff_result = color_diff(diff_result)
69
+ not_empty, diff_lines = peek(diff_result)
70
+ if not_empty:
71
+ sys.stdout.writelines(diff_lines)
72
+ failed.append(filename)
73
+ click.echo("")
74
+ else:
75
+ click.echo(result)
76
+ if dry_run:
77
+ return None
78
+
79
+ if yes or click.confirm(FeedbackManager.prompt_override_local_file(name=filename)):
80
+ with open(f"{filename}", "w") as file:
81
+ file.write(result)
82
+
83
+ click.echo(FeedbackManager.success_generated_local_file(file=filename))
84
+
85
+ if len(failed):
86
+ click.echo(FeedbackManager.error_failed_to_format_files(number=len(failed)))
87
+ for f in failed:
88
+ click.echo(f"tb fmt {f} --yes")
89
+ sys.exit(1)
90
+ return result
@@ -0,0 +1,116 @@
1
+ from typing import Optional
2
+
3
+ import click
4
+ from click import Context
5
+
6
+ from tinybird.feedback_manager import FeedbackManager
7
+ from tinybird.tb_cli_modules.cli import cli
8
+ from tinybird.tb_cli_modules.common import (
9
+ coro,
10
+ echo_safe_humanfriendly_tables_format_smart_table,
11
+ get_current_main_workspace,
12
+ )
13
+
14
+
15
+ @cli.group()
16
+ @click.pass_context
17
+ def tag(ctx: Context) -> None:
18
+ """Tag commands"""
19
+
20
+
21
+ @tag.command(name="ls")
22
+ @click.argument("tag_name", required=False)
23
+ @click.pass_context
24
+ @coro
25
+ async def tag_ls(ctx: Context, tag_name: Optional[str]) -> None:
26
+ """List all the tags of the current Workspace or the resources associated to a specific tag."""
27
+
28
+ client = ctx.ensure_object(dict)["client"]
29
+ config = ctx.ensure_object(dict)["config"]
30
+ main_workspace = await get_current_main_workspace(client, config)
31
+ token = main_workspace.get("token") if main_workspace else None
32
+
33
+ response = await client.get_all_tags(token=token)
34
+
35
+ if tag_name:
36
+ the_tag = [tag for tag in response["tags"] if tag["name"] == tag_name]
37
+
38
+ columns = ["name", "id", "type"]
39
+ table = []
40
+
41
+ if len(the_tag) > 0:
42
+ for resource in the_tag[0]["resources"]:
43
+ table.append([resource["name"], resource["id"], resource["type"]])
44
+
45
+ click.echo(FeedbackManager.info_tag_resources(tag_name=tag_name))
46
+ echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
47
+ return
48
+
49
+ columns = ["tag", "resources"]
50
+ table = []
51
+
52
+ for tag in response["tags"]:
53
+ unique_resources = []
54
+ for resource in tag["resources"]:
55
+ if resource.get("name", "") not in unique_resources:
56
+ unique_resources.append(resource) # Reducing by name in case there are duplicates.
57
+ table.append([tag["name"], len(unique_resources)])
58
+
59
+ click.echo(FeedbackManager.info_tag_list())
60
+ echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
61
+
62
+
63
+ @tag.command(name="create")
64
+ @click.argument("tag_name")
65
+ @click.pass_context
66
+ @coro
67
+ async def tag_create(ctx: Context, tag_name: str) -> None:
68
+ """Create a tag in the current Workspace."""
69
+
70
+ client = ctx.ensure_object(dict)["client"]
71
+ config = ctx.ensure_object(dict)["config"]
72
+ main_workspace = await get_current_main_workspace(client, config)
73
+ token = main_workspace.get("token") if main_workspace else None
74
+
75
+ await client.create_tag(name=tag_name, token=token)
76
+
77
+ click.echo(FeedbackManager.success_tag_created(tag_name=tag_name))
78
+
79
+
80
+ @tag.command(name="rm")
81
+ @click.argument("tag_name")
82
+ @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation to delete the tag.")
83
+ @click.pass_context
84
+ @coro
85
+ async def tag_rm(ctx: Context, tag_name: str, yes: bool) -> None:
86
+ """Remove a tag from the current Workspace."""
87
+
88
+ client = ctx.ensure_object(dict)["client"]
89
+ config = ctx.ensure_object(dict)["config"]
90
+ main_workspace = await get_current_main_workspace(client, config)
91
+ token = main_workspace.get("token") if main_workspace else None
92
+
93
+ remove_tag = True
94
+
95
+ if not yes:
96
+ all_tags = await client.get_all_tags(token=token)
97
+ the_tag = [tag for tag in all_tags["tags"] if tag["name"] == tag_name]
98
+ if len(the_tag) > 0:
99
+ unique_resources = []
100
+ for resource in the_tag[0]["resources"]:
101
+ if resource.get("name", "") not in unique_resources:
102
+ unique_resources.append(resource) # Reducing by name in case there are duplicates.
103
+
104
+ if len(unique_resources) > 0:
105
+ remove_tag = click.confirm(
106
+ FeedbackManager.warning_tag_remove(tag_name=tag_name, resources_len=len(unique_resources))
107
+ )
108
+ else:
109
+ remove_tag = click.confirm(FeedbackManager.warning_tag_remove_no_resources(tag_name=tag_name))
110
+ else:
111
+ remove_tag = False
112
+ click.echo(FeedbackManager.error_tag_not_found(tag_name=tag_name))
113
+
114
+ if remove_tag:
115
+ await client.delete_tag(tag_name, token=token)
116
+ click.echo(FeedbackManager.success_tag_removed(tag_name=tag_name))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tinybird-cli
3
- Version: 5.7.0
3
+ Version: 5.8.0
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/cli/introduction.html
6
6
  Author: Tinybird
@@ -18,6 +18,14 @@ The Tinybird command-line tool allows you to use all the Tinybird functionality
18
18
  Changelog
19
19
  ----------
20
20
 
21
+ 5.8.0
22
+ **********
23
+
24
+ - `Added` `tb datasource scheduling` commands to manage the scheduling of a Data Source
25
+ - `Added` `tb tag` commands to manage tags from the CLI.
26
+ - `Added` support to `TAGS` in `tb fmt`.
27
+ - `Added` support to `TAGS` in `tb pull` and `tb push`. Allows tagging resources for filtering in the UI.
28
+
21
29
  5.7.0
22
30
  **********
23
31
 
@@ -26,9 +26,11 @@ tinybird/tb_cli_modules/config.py
26
26
  tinybird/tb_cli_modules/connection.py
27
27
  tinybird/tb_cli_modules/datasource.py
28
28
  tinybird/tb_cli_modules/exceptions.py
29
+ tinybird/tb_cli_modules/fmt.py
29
30
  tinybird/tb_cli_modules/job.py
30
31
  tinybird/tb_cli_modules/pipe.py
31
32
  tinybird/tb_cli_modules/regions.py
33
+ tinybird/tb_cli_modules/tag.py
32
34
  tinybird/tb_cli_modules/telemetry.py
33
35
  tinybird/tb_cli_modules/test.py
34
36
  tinybird/tb_cli_modules/token.py
File without changes