tinybird 0.0.1.dev72__py3-none-any.whl → 0.0.1.dev74__py3-none-any.whl

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

Potentially problematic release.


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

tinybird/client.py CHANGED
@@ -669,10 +669,12 @@ class TinyB:
669
669
  async def pipe_unlink_materialized(self, pipe_name: str, node_id: str):
670
670
  return await self._req(f"/v0/pipes/{pipe_name}/nodes/{node_id}/materialization", method="DELETE")
671
671
 
672
- async def query(self, sql: str, pipeline: Optional[str] = None):
672
+ async def query(self, sql: str, pipeline: Optional[str] = None, playground: Optional[str] = None):
673
673
  params = {}
674
674
  if pipeline:
675
675
  params = {"pipeline": pipeline}
676
+ if playground:
677
+ params = {"playground": playground}
676
678
  params.update({"release_replacements": "true"})
677
679
 
678
680
  if len(sql) > TinyB.MAX_GET_LENGTH:
tinybird/prompts.py CHANGED
@@ -634,20 +634,42 @@ materialized_pipe_instructions = """
634
634
  <materialized_pipe_instructions>
635
635
  - Do not create materialized pipes by default, unless the user asks for it.
636
636
  - In a .pipe file you can define how to materialize each row ingested in the earliest Data Source in the Pipe query to a materialized Data Source. Materialization happens at ingest.
637
- - DATASOURCE: Required when TYPE is MATERIALIZED. Sets the destination Data Source for materialized nodes.
637
+ - DATASOURCE: Required when TYPE is MATERIALIZED. Sets the target Data Source for materialized nodes.
638
638
  - TYPE MATERIALIZED is the type of the pipe and it is mandatory for materialized pipes.
639
- - The content of the .pipe file must follow this format:
640
- DESCRIPTION Materialized Pipe to aggregate sales per hour in the sales_by_hour Data Source
641
-
639
+ - The content of the .pipe file must follow the materialized_pipe_content format.
640
+ - Use State modifier for the aggregated columns in the pipe.
641
+ - Keep the SQL query simple and avoid using complex queries with joins, subqueries, etc.
642
+ </materialized_pipe_instructions>
643
+ <materialized_pipe_content>
642
644
  NODE daily_sales
643
645
  SQL >
644
- SELECT toStartOfDay(starting_date) day, country, sum(sales) as total_sales
646
+ SELECT toStartOfDay(starting_date) day, country, sumState(sales) as total_sales
645
647
  FROM teams
646
648
  GROUP BY day, country
647
649
 
648
650
  TYPE MATERIALIZED
649
651
  DATASOURCE sales_by_hour
650
- </materialized_pipe_instructions>
652
+ </materialized_pipe_content>
653
+ <target_datasource_instructions>
654
+ - The target datasource of a materialized pipe must have an AggregatingMergeTree engine.
655
+ - Use AggregateFunction for the aggregated columns in the pipe.
656
+ - Pipes using a materialized data source must use the Merge modifier in the SQL query for the aggregated columns. Example: sumMerge(total_sales)
657
+ - Put all dimensions in the ENGINE_SORTING_KEY, sorted from least to most cardinality.
658
+ </target_datasource_instructions>
659
+ <target_datasource_content>
660
+ SCHEMA >
661
+ `total_sales` AggregateFunction(sum, Float64),
662
+ `sales_count` AggregateFunction(count, UInt64),
663
+ `column_name_2` AggregateFunction(avg, Float64),
664
+ `dimension_1` String,
665
+ `dimension_2` String,
666
+ ...
667
+ `date` DateTime
668
+
669
+ ENGINE "AggregatingMergeTree"
670
+ ENGINE_PARTITION_KEY "toYYYYMM(date)"
671
+ ENGINE_SORTING_KEY "date, dimension_1, dimension_2, ..."
672
+ </target_datasource_content>
651
673
  """
652
674
 
653
675
 
@@ -821,6 +843,10 @@ Follow these instructions when creating or updating .pipe files:
821
843
  Follow these instructions when creating or updating .yaml files for tests:
822
844
  {test_instructions}
823
845
  </test_file_instructions>
846
+ <deployment_instruction>
847
+ Follow these instructions when evolving a datasource schema:
848
+ {deployment_instructions}
849
+ </deployment_instruction>
824
850
  """.format(
825
851
  base_command=base_command,
826
852
  datasource_instructions=datasource_instructions,
@@ -831,6 +857,7 @@ Follow these instructions when creating or updating .yaml files for tests:
831
857
  copy_pipe_instructions=copy_pipe_instructions,
832
858
  materialized_pipe_instructions=materialized_pipe_instructions,
833
859
  test_instructions=test_instructions,
860
+ deployment_instructions=deployment_instructions,
834
861
  )
835
862
 
836
863
 
@@ -879,3 +906,24 @@ test_instructions = """
879
906
 
880
907
  </test_file_format>
881
908
  """
909
+
910
+ deployment_instructions = """
911
+ - When you make schema changes that are incompatible with the old schema, you must use a forward query in your data source. Forward queries are necessary when introducing breaking changes. Otherwise, your deployment will fail due to a schema mismatch.
912
+ - Forward queries translate the old schema to a new one that you define in the .datasource file. This helps you evolve your schema while continuing to ingest data.
913
+ Follow these steps to evolve your schema using a forward query:
914
+ - Edit the .datasource file to add a forward query.
915
+ - Run tb deploy --check to validate the deployment before creating it.
916
+ - Deploy and promote your changes in Tinybird Cloud using {base_command} --cloud deploy.
917
+ <forward_query_example>
918
+ SCHEMA >
919
+ `timestamp` DateTime `json:$.timestamp`,
920
+ `session_id` UUID `json:$.session_id`,
921
+ `action` String `json:$.action`,
922
+ `version` String `json:$.version`,
923
+ `payload` String `json:$.payload`
924
+
925
+ FORWARD_QUERY >
926
+ select timestamp, toUUID(session_id) as session_id, action, version, payload
927
+ </forward_query_example>
928
+ </deployment_instruction>
929
+ """
tinybird/tb/__cli__.py CHANGED
@@ -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__ = '0.0.1.dev72'
8
- __revision__ = 'f2d1d8b'
7
+ __version__ = '0.0.1.dev74'
8
+ __revision__ = '68ada75'
tinybird/tb/cli.py CHANGED
@@ -20,6 +20,7 @@ import tinybird.tb.modules.login
20
20
  import tinybird.tb.modules.materialization
21
21
  import tinybird.tb.modules.mock
22
22
  import tinybird.tb.modules.pipe
23
+ import tinybird.tb.modules.playground
23
24
  import tinybird.tb.modules.tag
24
25
  import tinybird.tb.modules.test
25
26
  import tinybird.tb.modules.token
@@ -7,6 +7,7 @@ import time
7
7
  from functools import partial
8
8
  from pathlib import Path
9
9
  from typing import Callable, Optional
10
+ from urllib.parse import urlencode
10
11
 
11
12
  import click
12
13
  import requests
@@ -41,7 +42,7 @@ def build(ctx: click.Context, watch: bool) -> None:
41
42
  )
42
43
 
43
44
 
44
- @cli.command("dev", help="Build the project server side and watch for changes")
45
+ @cli.command("dev", help="Build the project server side and watch for changes.")
45
46
  @click.pass_context
46
47
  def dev(ctx: click.Context) -> None:
47
48
  project: Project = ctx.ensure_object(dict)["project"]
@@ -191,6 +192,7 @@ def show_data(tb_client: TinyB, filename: str, diff: Optional[str] = None):
191
192
  table_name = diff
192
193
  resource_path = Path(filename)
193
194
  resource_name = resource_path.stem
195
+ resource_content = resource_path.read_text()
194
196
 
195
197
  pipeline = resource_name if filename.endswith(".pipe") else None
196
198
 
@@ -201,6 +203,17 @@ def show_data(tb_client: TinyB, filename: str, diff: Optional[str] = None):
201
203
 
202
204
  res = asyncio.run(tb_client.query(sql, pipeline=pipeline))
203
205
  print_table_formatted(res, table_name)
206
+ if Project.is_endpoint(resource_content):
207
+ example_params = {
208
+ "format": "json",
209
+ "pipe": resource_name,
210
+ "q": "",
211
+ "token": tb_client.token,
212
+ }
213
+ endpoint_url = asyncio.run(tb_client._req(f"/examples/query.http?{urlencode(example_params)}"))
214
+ if endpoint_url:
215
+ endpoint_url = endpoint_url.replace("http://localhost:8001", tb_client.host)
216
+ click.echo(FeedbackManager.gray(message="\nTest endpoint at ") + FeedbackManager.info(message=endpoint_url))
204
217
 
205
218
 
206
219
  def process(
@@ -240,7 +253,7 @@ def process(
240
253
  def run_watch(
241
254
  project: Project, tb_client: TinyB, process: Callable[[bool, Optional[str], Optional[str]], None]
242
255
  ) -> None:
243
- shell = Shell(project=project, tb_client=tb_client)
256
+ shell = Shell(project=project, tb_client=tb_client, playground=False)
244
257
  click.echo(FeedbackManager.gray(message="\nWatching for changes..."))
245
258
  watcher_thread = threading.Thread(
246
259
  target=watch_project,
@@ -52,11 +52,11 @@ VERSION = f"{__cli__.__version__} (rev {__cli__.__revision__})"
52
52
  default=False,
53
53
  help="Prints internal representation, can be combined with any command to get more information.",
54
54
  )
55
- @click.option("--token", help="Use auth token, defaults to TB_TOKEN envvar, then to the .tinyb file")
55
+ @click.option("--token", help="Use auth token, defaults to TB_TOKEN envvar, then to the .tinyb file.")
56
56
  @click.option("--host", help="Use custom host, defaults to TB_HOST envvar, then to https://api.tinybird.co")
57
- @click.option("--show-tokens", is_flag=True, default=False, help="Enable the output of tokens")
58
- @click.option("--cloud/--local", is_flag=True, default=False, help="Run against cloud or local")
59
- @click.option("--build", is_flag=True, default=False, help="Run against build mode")
57
+ @click.option("--show-tokens", is_flag=True, default=False, help="Enable the output of tokens.")
58
+ @click.option("--cloud/--local", is_flag=True, default=False, help="Run against cloud or local.")
59
+ @click.option("--build", is_flag=True, default=False, help="Run against build mode.")
60
60
  @click.option("--staging", is_flag=True, default=False, help="Run against a staging deployment.")
61
61
  @click.version_option(version=VERSION)
62
62
  @click.pass_context
@@ -189,7 +189,7 @@ async def dependencies(
189
189
 
190
190
  @cli.command(
191
191
  name="diff",
192
- short_help="Diff 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.",
192
+ short_help="Diff local datafiles to the corresponding remote files in the workspace. Only diffs VERSION and SCHEMA for .datasource files.",
193
193
  )
194
194
  @click.argument("filename", type=click.Path(exists=True), nargs=-1, required=False)
195
195
  @click.option(
@@ -394,7 +394,7 @@ async def create_ctx_client(ctx: Context, config: Dict[str, Any], cloud: bool, b
394
394
  if command in commands_without_ctx_client:
395
395
  return None
396
396
 
397
- commands_always_cloud = ["pull"]
397
+ commands_always_cloud = ["pull", "playground"]
398
398
  commands_always_build = ["build", "test", "dev"]
399
399
  commands_always_local = ["create"]
400
400
  if (
@@ -244,8 +244,7 @@ class CatchAuthExceptions(AliasedGroup):
244
244
  formatter.write_text(
245
245
  """
246
246
  Tinybird collects anonymous usage data and errors to improve the command
247
- line experience. To opt-out, set TB_CLI_TELEMETRY_OPTOUT environment
248
- variable to '1' or 'true'."""
247
+ line experience. To opt-out, set TB_CLI_TELEMETRY_OPTOUT to '1' or 'true'."""
249
248
  )
250
249
  formatter.write_paragraph()
251
250
 
@@ -77,7 +77,7 @@ class CLIConfig:
77
77
  _global: Optional["CLIConfig"] = None
78
78
  _projects: Dict[str, "CLIConfig"] = {}
79
79
 
80
- def __init__(self, path: Optional[str], parent: Optional["CLIConfig"] = None) -> None:
80
+ def __init__(self, path: Optional[str] = None, parent: Optional["CLIConfig"] = None) -> None:
81
81
  self._path = path
82
82
  self._parent = parent
83
83
  self._values: Dict[str, ConfigValue] = {}
@@ -21,7 +21,7 @@ from tinybird.tb.modules.feedback_manager import FeedbackManager
21
21
  @cli.group()
22
22
  @click.pass_context
23
23
  def copy(ctx):
24
- """Copy pipe commands"""
24
+ """Copy pipe commands."""
25
25
 
26
26
 
27
27
  @copy.command(name="ls")
@@ -9,12 +9,11 @@ import requests
9
9
  from croniter import croniter
10
10
 
11
11
  from tinybird.client import DoesNotExistException, TinyB
12
- from tinybird.tb.modules.common import getenv_bool, requests_delete, requests_get, wait_job
12
+ from tinybird.tb.modules.common import requests_delete, requests_get, wait_job
13
+ from tinybird.tb.modules.config import CLIConfig
13
14
  from tinybird.tb.modules.datafile.common import ON_DEMAND, CopyModes, CopyParameters, PipeNodeTypes, PipeTypes
14
- from tinybird.tb.modules.datafile.pipe_checker import PipeCheckerRunner
15
15
  from tinybird.tb.modules.exceptions import CLIPipeException
16
16
  from tinybird.tb.modules.feedback_manager import FeedbackManager
17
- from tinybird.tb.modules.table import format_pretty_table
18
17
 
19
18
 
20
19
  async def new_pipe(
@@ -47,7 +46,8 @@ async def new_pipe(
47
46
  ):
48
47
  # TODO use tb_client instead of calling the urls directly.
49
48
  host = tb_client.host
50
- token = tb_client.token
49
+ config = CLIConfig().get_project_config()
50
+ token = config.get_user_token()
51
51
 
52
52
  headers = {"Authorization": f"Bearer {token}"}
53
53
 
@@ -55,11 +55,16 @@ async def new_pipe(
55
55
  cli_params["cli_version"] = tb_client.version
56
56
  cli_params["description"] = p.get("description", "")
57
57
  cli_params["ignore_sql_errors"] = "true" if ignore_sql_errors else "false"
58
+ cli_params["workspace_id"] = config.get("id", None)
58
59
 
59
- r: requests.Response = await requests_get(f"{host}/v0/pipes/{p['name']}?{urlencode(cli_params)}", headers=headers)
60
-
61
- current_pipe = r.json() if r.status_code == 200 else None
62
- pipe_exists = current_pipe is not None
60
+ r: requests.Response = await requests_get(f"{host}/v0/playgrounds?{urlencode(cli_params)}", headers=headers)
61
+ current_pipe = None
62
+ pipe_exists = False
63
+ playgrounds_response = r.json() if r.status_code == 200 else None
64
+ if playgrounds_response:
65
+ playgrounds = playgrounds_response["playgrounds"]
66
+ current_pipe = next((play for play in playgrounds if play["name"] == p["name"] + "__tb__playground"), None)
67
+ pipe_exists = current_pipe is not None
63
68
 
64
69
  is_materialized = any([node.get("params", {}).get("type", None) == "materialized" for node in p["nodes"]])
65
70
  copy_node = next((node for node in p["nodes"] if node.get("params", {}).get("type", None) == "copy"), None)
@@ -75,27 +80,7 @@ async def new_pipe(
75
80
  # TODO: this should create a different node and rename it to the final one on success
76
81
  if check and not populate:
77
82
  if not is_materialized and not copy_node and not sink_node and not stream_node:
78
- await check_pipe(
79
- p,
80
- host,
81
- token,
82
- populate,
83
- tb_client,
84
- only_response_times=only_response_times,
85
- limit=tests_to_run,
86
- relative_change=tests_relative_change,
87
- sample_by_params=tests_to_sample_by_params,
88
- matches=tests_filter_by,
89
- failfast=tests_failfast,
90
- validate_processed_bytes=tests_validate_processed_bytes,
91
- ignore_order=tests_ignore_order,
92
- token_for_requests_to_check=(
93
- await get_token_from_main_branch(tb_client)
94
- if not tests_check_requests_from_branch
95
- else None
96
- ),
97
- current_pipe=current_pipe,
98
- )
83
+ pass
99
84
  else:
100
85
  if is_materialized:
101
86
  await check_materialized(
@@ -167,9 +152,27 @@ async def new_pipe(
167
152
  post_headers.update(headers)
168
153
 
169
154
  try:
170
- data = await tb_client._req(
171
- f"/v0/pipes?{urlencode(params)}", method="POST", headers=post_headers, data=json.dumps(body)
172
- )
155
+ user_client = deepcopy(tb_client)
156
+ config = CLIConfig().get_project_config()
157
+ user_client.token = config.get_user_token()
158
+ params["workspace_id"] = config.get("id", None)
159
+ body["name"] = p["name"] + "__tb__playground"
160
+
161
+ if pipe_exists and current_pipe:
162
+ data = await user_client._req(
163
+ f"/v0/playgrounds/{current_pipe['id']}?{urlencode(params)}",
164
+ method="PUT",
165
+ headers=post_headers,
166
+ data=json.dumps(body),
167
+ )
168
+
169
+ else:
170
+ data = await user_client._req(
171
+ f"/v0/playgrounds?{urlencode(params)}",
172
+ method="POST",
173
+ headers=post_headers,
174
+ data=json.dumps(body),
175
+ )
173
176
  except Exception as e:
174
177
  raise click.ClickException(FeedbackManager.error_pushing_pipe(pipe=p["name"], error=str(e)))
175
178
 
@@ -296,160 +299,6 @@ async def get_token_from_main_branch(branch_tb_client: TinyB) -> Optional[str]:
296
299
  return token_from_main_branch
297
300
 
298
301
 
299
- async def check_pipe(
300
- pipe,
301
- host: str,
302
- token: str,
303
- populate: bool,
304
- cl: TinyB,
305
- limit: int = 0,
306
- relative_change: float = 0.01,
307
- sample_by_params: int = 0,
308
- only_response_times=False,
309
- matches: Optional[List[str]] = None,
310
- failfast: bool = False,
311
- validate_processed_bytes: bool = False,
312
- ignore_order: bool = False,
313
- token_for_requests_to_check: Optional[str] = None,
314
- current_pipe: Optional[Dict[str, Any]] = None,
315
- ):
316
- checker_pipe = deepcopy(pipe)
317
- checker_pipe["name"] = f"{checker_pipe['name']}__checker"
318
-
319
- if current_pipe:
320
- pipe_type = current_pipe["type"]
321
- if pipe_type == PipeTypes.COPY:
322
- await cl.pipe_remove_copy(current_pipe["id"], current_pipe["copy_node"])
323
- if pipe_type == PipeTypes.DATA_SINK:
324
- await cl.pipe_remove_sink(current_pipe["id"], current_pipe["sink_node"])
325
- if pipe_type == PipeTypes.STREAM:
326
- await cl.pipe_remove_stream(current_pipe["id"], current_pipe["stream_node"])
327
-
328
- # In case of doing --force for a materialized view, checker is being created as standard pipe
329
- for node in checker_pipe["nodes"]:
330
- node["params"]["type"] = PipeNodeTypes.STANDARD
331
-
332
- if populate:
333
- raise click.ClickException(FeedbackManager.error_check_pipes_populate())
334
-
335
- runner = PipeCheckerRunner(pipe["name"], host)
336
- headers = (
337
- {"Authorization": f"Bearer {token_for_requests_to_check}"}
338
- if token_for_requests_to_check
339
- else {"Authorization": f"Bearer {token}"}
340
- )
341
-
342
- sql_for_coverage, sql_latest_requests = runner.get_sqls_for_requests_to_check(
343
- matches or [], sample_by_params, limit
344
- )
345
-
346
- params = {"q": sql_for_coverage if limit == 0 and sample_by_params > 0 else sql_latest_requests}
347
- r: requests.Response = await requests_get(
348
- f"{host}/v0/sql?{urlencode(params)}", headers=headers, verify=not getenv_bool("TB_DISABLE_SSL_CHECKS", False)
349
- )
350
-
351
- # If we get a timeout, fallback to just the last requests
352
-
353
- if not r or r.status_code == 408:
354
- params = {"q": sql_latest_requests}
355
- r = await requests_get(
356
- f"{host}/v0/sql?{urlencode(params)}",
357
- headers=headers,
358
- verify=not getenv_bool("TB_DISABLE_SSL_CHECKS", False),
359
- )
360
-
361
- if not r or r.status_code != 200:
362
- raise click.ClickException(FeedbackManager.error_check_pipes_api(pipe=pipe["name"]))
363
-
364
- pipe_requests_to_check: List[Dict[str, Any]] = []
365
- for row in r.json().get("data", []):
366
- for i in range(len(row["endpoint_url"])):
367
- pipe_requests_to_check += [
368
- {
369
- "endpoint_url": f"{host}{row['endpoint_url'][i]}",
370
- "pipe_request_params": row["pipe_request_params"][i],
371
- "http_method": row["http_method"],
372
- }
373
- ]
374
-
375
- if not pipe_requests_to_check:
376
- return
377
-
378
- await new_pipe(checker_pipe, cl, force=True, check=False, populate=populate)
379
-
380
- runner_response = runner.run_pipe_checker(
381
- pipe_requests_to_check,
382
- checker_pipe["name"],
383
- token,
384
- only_response_times,
385
- ignore_order,
386
- validate_processed_bytes,
387
- relative_change,
388
- failfast,
389
- )
390
-
391
- try:
392
- if runner_response.metrics_summary and runner_response.metrics_timing:
393
- column_names_tests = ["Test Run", "Test Passed", "Test Failed", "% Test Passed", "% Test Failed"]
394
- click.echo("\n==== Test Metrics ====\n")
395
- click.echo(
396
- format_pretty_table(
397
- [
398
- [
399
- runner_response.metrics_summary["run"],
400
- runner_response.metrics_summary["passed"],
401
- runner_response.metrics_summary["failed"],
402
- runner_response.metrics_summary["percentage_passed"],
403
- runner_response.metrics_summary["percentage_failed"],
404
- ]
405
- ],
406
- column_names=column_names_tests,
407
- )
408
- )
409
-
410
- column_names_timing = ["Timing Metric (s)", "Current", "New"]
411
- click.echo("\n==== Response Time Metrics ====\n")
412
- click.echo(
413
- format_pretty_table(
414
- [
415
- [metric, runner_response.metrics_timing[metric][0], runner_response.metrics_timing[metric][1]]
416
- for metric in [
417
- "min response time",
418
- "max response time",
419
- "mean response time",
420
- "median response time",
421
- "p90 response time",
422
- "min read bytes",
423
- "max read bytes",
424
- "mean read bytes",
425
- "median read bytes",
426
- "p90 read bytes",
427
- ]
428
- ],
429
- column_names=column_names_timing,
430
- )
431
- )
432
- except Exception:
433
- pass
434
-
435
- if not runner_response.was_successfull:
436
- for failure in runner_response.failed:
437
- try:
438
- click.echo("==== Test FAILED ====\n")
439
- click.echo(failure["name"])
440
- click.echo(FeedbackManager.error_check_pipe(error=failure["error"]))
441
- click.echo("=====================\n\n\n")
442
- except Exception:
443
- pass
444
- raise RuntimeError("Invalid results, you can bypass checks by running push with the --no-check flag")
445
-
446
- # Only delete if no errors, so we can check results after failure
447
- headers = {"Authorization": f"Bearer {token}"}
448
- r = await requests_delete(f"{host}/v0/pipes/{checker_pipe['name']}", headers=headers)
449
- if r.status_code != 204:
450
- click.echo(FeedbackManager.warning_check_pipe(content=r.content))
451
-
452
-
453
302
  async def check_materialized(pipe, host, token, cl, override_datasource=False, current_pipe=None):
454
303
  checker_pipe = deepcopy(pipe)
455
304
  checker_pipe["name"] = f"{checker_pipe['name']}__checker"
@@ -1151,6 +1151,21 @@ def parse(
1151
1151
 
1152
1152
  return raise_deprecation_error
1153
1153
 
1154
+ def not_supported_yet(extra_message: str = "") -> Callable[..., Any]:
1155
+ def inner(func: Callable[..., Any]) -> Callable[..., Any]:
1156
+ @functools.wraps(func)
1157
+ def raise_not_supported_yet_error(*args: Any, **kwargs: Any) -> Any:
1158
+ extra_message_bit = f". {extra_message}" if extra_message else ""
1159
+ raise DatafileSyntaxError(
1160
+ f"{kwargs['cmd']} is not supported yet{extra_message_bit}",
1161
+ lineno=kwargs["lineno"],
1162
+ pos=1,
1163
+ )
1164
+
1165
+ return raise_not_supported_yet_error
1166
+
1167
+ return inner
1168
+
1154
1169
  def assign(attr):
1155
1170
  @multiline_not_supported
1156
1171
  def _fn(x, **kwargs):
@@ -1255,6 +1270,7 @@ def parse(
1255
1270
 
1256
1271
  return _f
1257
1272
 
1273
+ @not_supported_yet(extra_message="You can manage tokens with `tb token` in the meantime.")
1258
1274
  @multiline_not_supported
1259
1275
  def add_token(*args: str, **kwargs: Any) -> None: # token_name, permissions):
1260
1276
  # lineno = kwargs["lineno"]
@@ -1268,6 +1284,7 @@ def parse(
1268
1284
  # APPEND for datasources
1269
1285
  doc.tokens.append({"token_name": _unquote(args[0]), "permissions": args[1]})
1270
1286
 
1287
+ @not_supported_yet()
1271
1288
  def include(*args: str, **kwargs: Any) -> None:
1272
1289
  f = _unquote(args[0])
1273
1290
  f = eval_var(f)