tinybird 4.6.22.dev0__py3-none-any.whl → 4.6.24.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tinybird/sql_template.py CHANGED
@@ -1,5 +1,4 @@
1
1
  import ast
2
- import builtins
3
2
  import linecache
4
3
  import logging
5
4
  import re
@@ -1336,8 +1335,10 @@ sqlescape_for_string_expression = sqlescape_generator(
1336
1335
  )
1337
1336
 
1338
1337
 
1339
- def escape_single_quote_str(s):
1340
- return "'" + s.replace("'", "''") + "'"
1338
+ def escape_single_quote_str(s: str) -> str:
1339
+ # ClickHouse string literals treat backslash as an escape char, so it must be doubled too:
1340
+ # otherwise a trailing backslash escapes the closing quote and the following input becomes SQL
1341
+ return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
1341
1342
 
1342
1343
 
1343
1344
  def expression_wrapper(x, name, escape_arrays: bool = False):
@@ -1385,16 +1386,16 @@ _namespace = {
1385
1386
  "close": None,
1386
1387
  "print": None,
1387
1388
  "input": None,
1388
- # Keep customer parameters named `type`/`dir` usable without restoring the unsafe builtins.
1389
- "type": None,
1390
- "dir": None,
1391
1389
  }
1392
1390
 
1393
1391
 
1394
- reserved_vars = {"_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *list(vars(builtins))}
1392
+ # Built from the builtins templates can actually resolve: names missing from SAFE_BUILTINS (`type`, `dir`,
1393
+ # `property`, `object`, ...) can only be template parameters, so they must be discovered as such to get a
1394
+ # value (None or a test-mode placeholder) instead of raising NameError.
1395
+ reserved_vars = {"_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *SAFE_BUILTINS}
1395
1396
  for p in DEFAULT_PARAM_NAMES: # we handle these in an specific manner
1396
1397
  reserved_vars.discard(p) # `format` is part of builtins
1397
- # Allow 'id' to be used as a template parameter - https://gitlab.com/tinybird/analytics/-/issues/19119
1398
+ # Allow 'id' as a template parameter name despite being a builtin - https://gitlab.com/tinybird/analytics/-/issues/19119
1398
1399
  reserved_vars.discard("id")
1399
1400
  error_vars = ["error", "custom_error"]
1400
1401
 
@@ -1471,8 +1472,7 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1471
1472
  return Expression(f"-- disable {feature}\n")
1472
1473
 
1473
1474
  namespace.update(_namespace)
1474
- # This is to fix the issue https://gitlab.com/tinybird/analytics/-/issues/19119
1475
- # We need to do the override here because if we modify the _namespace, we would filter out parameters from the users
1475
+ # Default it here, not in _namespace, so it stays visible to variable extraction.
1476
1476
  namespace.update({"id": None})
1477
1477
  namespace.update(kwargs)
1478
1478
  namespace.update(
@@ -1822,7 +1822,7 @@ def get_var_names(t: Template):
1822
1822
  >>> [v['name'] for v in get_var_names(Template("{% for name in secret_names %}{{tb_secret(name)}}{% end %}"))]
1823
1823
  ['secret_names', 'name', 'tb_secret', 'name']
1824
1824
 
1825
- Type casting with date function in elif chain (type is in reserved_vars):
1825
+ Type casting with date function in elif chain:
1826
1826
  >>> t = Template("{% if kind == 'int' %}{{Int32(val, 0)}}{% elif kind == 'date' %}{{DateTime(val)}}{% elif kind == 'diff' %}{{date_diff_in_days(val, now)}}{% end %}")
1827
1827
  >>> [v['name'] for v in get_var_names(t)]
1828
1828
  ['kind', 'Int32', 'val', 'kind', 'DateTime', 'val', 'kind', 'date_diff_in_days', 'val', 'now']
@@ -2374,7 +2374,24 @@ def wrap_vars(t, escape_arrays: bool = False):
2374
2374
  return var
2375
2375
 
2376
2376
 
2377
- def get_used_tables_in_template(sql):
2377
+ def _constant_table_name(node: ast.expr) -> Optional[str]:
2378
+ """Value of a constant table() argument: a literal or a concatenation of string literals."""
2379
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
2380
+ return str(node.value)
2381
+ return _string_literal(node)
2382
+
2383
+
2384
+ def _string_literal(node: ast.expr) -> Optional[str]:
2385
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
2386
+ return node.value
2387
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
2388
+ left, right = _string_literal(node.left), _string_literal(node.right)
2389
+ if left is not None and right is not None:
2390
+ return left + right
2391
+ return None
2392
+
2393
+
2394
+ def get_used_tables_in_template(sql: str) -> List[str]:
2378
2395
  """
2379
2396
  >>> get_used_tables_in_template("select * from {{table('test')}}")
2380
2397
  ['test']
@@ -2388,30 +2405,42 @@ def get_used_tables_in_template(sql):
2388
2405
  []
2389
2406
  >>> get_used_tables_in_template("select * from {{table('my.test')}}, {{table('another.one')}}")
2390
2407
  ['my.test', 'another.one']
2408
+
2409
+ Only constant table names can be known without rendering, so anything else is skipped
2410
+ >>> get_used_tables_in_template("select * from {{table(tbl)}}, {{TABLE('b')}}, {{table('my.' + 'c')}}")
2411
+ ['b', 'my.c']
2391
2412
  """
2413
+ return list(_get_used_tables_in_template_cached(sql))
2414
+
2415
+
2416
+ # Building the Template dominates the cost and the result only depends on the SQL, which deployments and
2417
+ # /v0/sql pipelines scan repeatedly
2418
+ @lru_cache(maxsize=2**10)
2419
+ def _get_used_tables_in_template_cached(sql: str) -> Tuple[str, ...]:
2392
2420
  try:
2393
2421
  t = Template(sql)
2394
2422
 
2395
2423
  def _n(chunks, tables):
2396
2424
  for x in chunks:
2397
2425
  if type(x).__name__ == "_Expression":
2398
- c = compile(x.expression, "<string>", "exec", dont_inherit=True)
2399
- v = [x.lower() for x in c.co_names if x not in _namespace and x not in reserved_vars]
2400
- if "table" in v:
2401
-
2402
- def _t(*args, **kwargs):
2403
- return str(args[0])
2404
-
2405
- n = {"table": _t, "TABLE": _t}
2406
- e = "_tt_tmp = %s" % x.expression
2407
- exec_in(e, n)
2408
- tables += [n["_tt_tmp"]]
2426
+ # Read the names from the AST instead of executing the expression: this runs outside
2427
+ # generate(), without the restricted builtins, on every pipe, query and deployment.
2428
+ for node in ast.walk(ast.parse(x.expression)):
2429
+ if (
2430
+ isinstance(node, ast.Call)
2431
+ and isinstance(node.func, ast.Name)
2432
+ and node.func.id in ("table", "TABLE")
2433
+ and node.args
2434
+ ):
2435
+ name = _constant_table_name(node.args[0])
2436
+ if name is not None:
2437
+ tables.append(name)
2409
2438
  elif type(x).__name__ == "_ControlBlock":
2410
2439
  _n(x.body.chunks, tables)
2411
2440
 
2412
- tables = []
2441
+ tables: List[str] = []
2413
2442
  _n(t.file.body.chunks, tables)
2414
- return tables
2443
+ return tuple(tables)
2415
2444
  except SecurityException as e:
2416
2445
  raise SQLTemplateException(e)
2417
2446
 
@@ -214,8 +214,8 @@ class TinybirdDialect(ClickHouse):
214
214
  ],
215
215
  }
216
216
 
217
- for section in override_rules:
218
- for rule in override_rules[section]:
217
+ for section, rules in override_rules.items():
218
+ for rule in rules:
219
219
  for rr in self.RULES[section]:
220
220
  if rr.name == rule.name:
221
221
  self.RULES[section].remove(rr)
tinybird/syncasync.py CHANGED
@@ -260,49 +260,6 @@ def _iscoroutinefunction_or_partial(func: Any) -> bool:
260
260
  return asyncio.iscoroutinefunction(func)
261
261
 
262
262
 
263
- class ThreadSensitiveContext:
264
- """Async context manager to manage context for thread sensitive mode
265
- This context manager controls which thread pool executor is used when in
266
- thread sensitive mode. By default, a single thread pool executor is shared
267
- within a process.
268
- In Python 3.7+, the ThreadSensitiveContext() context manager may be used to
269
- specify a thread pool per context.
270
- In Python 3.6, usage of this context manager has no effect.
271
- This context manager is re-entrant, so only the outer-most call to
272
- ThreadSensitiveContext will set the context.
273
- """
274
-
275
- def __init__(self):
276
- self.token = None
277
-
278
- if contextvars:
279
-
280
- async def __aenter__(self):
281
- try:
282
- SyncToAsync.thread_sensitive_context.get()
283
- except LookupError:
284
- self.token = SyncToAsync.thread_sensitive_context.set(self)
285
-
286
- return self
287
-
288
- async def __aexit__(self, exc, value, tb):
289
- if not self.token:
290
- return
291
-
292
- executor = SyncToAsync.context_to_thread_executor.pop(self, None)
293
- if executor:
294
- executor.shutdown()
295
- SyncToAsync.thread_sensitive_context.reset(self.token)
296
-
297
- else:
298
-
299
- async def __aenter__(self):
300
- return self
301
-
302
- async def __aexit__(self, exc, value, tb):
303
- pass
304
-
305
-
306
263
  class AsyncToSync:
307
264
  """
308
265
  Utility class which turns an awaitable that only works on the thread with
tinybird/tb/__cli__.py CHANGED
@@ -4,5 +4,5 @@ __description__ = 'Tinybird Command Line Tool'
4
4
  __url__ = 'https://www.tinybird.co/docs/forward/commands'
5
5
  __author__ = 'Tinybird'
6
6
  __author_email__ = 'support@tinybird.co'
7
- __version__ = '4.6.22.dev0'
8
- __revision__ = '3a75e0ef'
7
+ __version__ = '4.6.24.dev0'
8
+ __revision__ = '5269fe22'
tinybird/tb/client.py CHANGED
@@ -58,14 +58,6 @@ class TimeoutException(Exception):
58
58
  pass
59
59
 
60
60
 
61
- class ReachRetryLimit(Exception):
62
- pass
63
-
64
-
65
- class ConnectorNothingToLoad(Exception):
66
- pass
67
-
68
-
69
61
  class JobException(Exception):
70
62
  pass
71
63
 
@@ -1277,11 +1277,11 @@ class ConnectionReplacements:
1277
1277
  """Maps prompt parameters to API parameters."""
1278
1278
 
1279
1279
  api_params = {}
1280
- for key in params.keys():
1280
+ for key, value in params.items():
1281
1281
  try:
1282
- api_params[ConnectionReplacements._PARAMS_REPLACEMENTS[service][key]] = params[key]
1282
+ api_params[ConnectionReplacements._PARAMS_REPLACEMENTS[service][key]] = value
1283
1283
  except KeyError:
1284
- api_params[key] = params[key]
1284
+ api_params[key] = value
1285
1285
 
1286
1286
  api_params["service"] = service
1287
1287
  return api_params
@@ -215,14 +215,12 @@ def create_project_structure(folder: str):
215
215
  "connections →": "Connect to and ingest data from popular sources: Kafka, S3 or GCS.",
216
216
  }
217
217
 
218
- for x in PROJECT_PATHS_DESCRIPTIONS.keys():
218
+ for x, description in PROJECT_PATHS_DESCRIPTIONS.items():
219
219
  try:
220
220
  path = x.split("→")[0].strip()
221
221
  f = folder_path / path
222
222
  f.mkdir()
223
- click.echo(
224
- FeedbackManager.info(message=f"./{x} ") + FeedbackManager.gray(message=PROJECT_PATHS_DESCRIPTIONS[x])
225
- )
223
+ click.echo(FeedbackManager.info(message=f"./{x} ") + FeedbackManager.gray(message=description))
226
224
  except FileExistsError:
227
225
  pass
228
226
 
@@ -1,8 +1,11 @@
1
+ from datetime import datetime
1
2
  from pathlib import Path
2
3
  from typing import Any, Optional
3
4
 
4
5
  import click
5
6
 
7
+ from tinybird.datafile.common import PipeNodeTypes
8
+ from tinybird.datafile.parse_pipe import parse_pipe
6
9
  from tinybird.tb.client import AuthNoTokenException, TinyB
7
10
  from tinybird.tb.modules.datafile.format_datasource import format_datasource
8
11
  from tinybird.tb.modules.datafile.format_pipe import format_pipe
@@ -10,6 +13,52 @@ from tinybird.tb.modules.feedback_manager import FeedbackManager
10
13
 
11
14
  PULL_EXISTING_FILE_MAX_DEPTH = 5
12
15
 
16
+ DEMOTABLE_NODE_TYPES = {
17
+ PipeNodeTypes.DATA_SINK: ("sink_pipes", "Sink"),
18
+ PipeNodeTypes.COPY: ("copy_pipes", "Copy"),
19
+ }
20
+
21
+
22
+ def _parse_created_at(value: Any) -> datetime:
23
+ try:
24
+ return datetime.fromisoformat(str(value))
25
+ except ValueError:
26
+ return datetime.min
27
+
28
+
29
+ def pipes_over_node_type_limit(pipes: list[dict[str, Any]], node_type: str, limit: Optional[int]) -> set[str]:
30
+ if limit is None:
31
+ return set()
32
+
33
+ matching_pipes = [p for p in pipes if p.get("type") == node_type]
34
+ if len(matching_pipes) <= limit:
35
+ return set()
36
+
37
+ matching_pipes.sort(key=lambda p: _parse_created_at(p.get("created_at")))
38
+ return {p["name"] for p in matching_pipes[limit:]}
39
+
40
+
41
+ def pipes_over_plan_limits(pipes: list[dict[str, Any]], limits: dict[str, Any]) -> dict[str, str]:
42
+ demoted: dict[str, str] = {}
43
+ for node_type, (limits_key, _label) in DEMOTABLE_NODE_TYPES.items():
44
+ limit = limits.get(limits_key, {}).get("max")
45
+ for name in pipes_over_node_type_limit(pipes, node_type, limit):
46
+ demoted[name] = node_type
47
+ return demoted
48
+
49
+
50
+ def demote_pipe_content(name: str, content: str, node_type: str) -> str:
51
+ datafile = parse_pipe(name, content=content, replace_includes=False).datafile
52
+ demoted = False
53
+ for node in datafile.nodes:
54
+ if (node.get("type") or "").lower() == node_type:
55
+ node.pop("type", None)
56
+ demoted = True
57
+
58
+ if not demoted:
59
+ return content
60
+ return format_pipe(name, datafile=datafile)
61
+
13
62
 
14
63
  def folder_pull(
15
64
  client: TinyB,
@@ -61,6 +110,7 @@ def folder_pull(
61
110
  get_resource_function: str,
62
111
  progress_bar: bool = False,
63
112
  fmt: bool = False,
113
+ demoted_pipe_types: Optional[dict[str, str]] = None,
64
114
  ) -> list[Path]:
65
115
  def write_resource(k: dict[str, Any]) -> Optional[Path]:
66
116
  name = f"{k['name']}.{extension}"
@@ -73,8 +123,19 @@ def folder_pull(
73
123
  else:
74
124
  resource = getattr(client, get_resource_function)(k["name"])
75
125
  resource_to_write = resource
76
-
77
- if fmt:
126
+ resource_type = k.get("type")
127
+
128
+ demoted_node_type = demoted_pipe_types.get(k["name"]) if demoted_pipe_types else None
129
+ if demoted_node_type:
130
+ resource_to_write = demote_pipe_content(name, resource, demoted_node_type)
131
+ resource_type = None
132
+ label = DEMOTABLE_NODE_TYPES[demoted_node_type][1]
133
+ warnings.append(
134
+ f"{label} pipe '{k['name']}' exceeds the {demoted_node_type} pipe limit of your current "
135
+ f"plan and was pulled as a plain pipe without its {demoted_node_type} configuration. "
136
+ "Upgrade your plan and reconfigure it to restore it."
137
+ )
138
+ elif fmt:
78
139
  if extension == "datasource":
79
140
  resource_to_write = format_datasource(name, content=resource)
80
141
  elif extension == "pipe":
@@ -85,7 +146,7 @@ def folder_pull(
85
146
  dest_folder = Path(folder) / "vendor" / k["name"].split(".", 1)[0]
86
147
  name = f"{k['name'].split('.', 1)[1]}.{extension}"
87
148
 
88
- file_folder = get_file_folder(extension, k.get("type"))
149
+ file_folder = get_file_folder(extension, resource_type)
89
150
  f = Path(dest_folder) / file_folder if file_folder is not None else Path(dest_folder)
90
151
 
91
152
  existing_file = find_existing_resource_file(Path(dest_folder), name)
@@ -128,6 +189,14 @@ def folder_pull(
128
189
  pipes = client.pipes()
129
190
  connections = client.connections()
130
191
 
192
+ demoted_pipe_types: dict[str, str] = {}
193
+ if not only_vendored:
194
+ try:
195
+ limits = client.workspace_info().get("limits", {})
196
+ except Exception:
197
+ limits = {}
198
+ demoted_pipe_types = pipes_over_plan_limits(pipes, limits)
199
+
131
200
  if only_vendored:
132
201
  written_files = write_files(
133
202
  resources=[ds for ds in datasources if "shared_from" in ds],
@@ -154,6 +223,7 @@ def folder_pull(
154
223
  get_resource_function="pipe_file",
155
224
  progress_bar=progress_bar,
156
225
  fmt=fmt,
226
+ demoted_pipe_types=demoted_pipe_types,
157
227
  )
158
228
  )
159
229
  written_files.extend(
@@ -677,7 +677,7 @@ def datasource_start(ctx: Context, datasource_name: str) -> None:
677
677
 
678
678
  @datasource.command(name="delete")
679
679
  @click.argument("datasource_name")
680
- @click.option("--sql-condition", default=None, help="SQL WHERE condition to remove rows", hidden=True, required=True)
680
+ @click.option("--sql-condition", help="SQL WHERE condition to remove rows", hidden=True, required=True)
681
681
  @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
682
682
  @click.option(
683
683
  "--wait/--no-wait",
@@ -335,13 +335,12 @@ def discard_deployment(host: Optional[str], headers: dict, wait: bool, request_f
335
335
  click.echo(FeedbackManager.error(message="Only one deployment found. Cannot discard the only deployment."))
336
336
  return
337
337
 
338
- current_deployment, deployment_to_discard = deployments[0], deployments[1]
338
+ live_deployment = next((deployment for deployment in deployments if deployment.get("live")), deployments[0])
339
+ deployment_to_discard = next(deployment for deployment in deployments if deployment is not live_deployment)
339
340
 
340
- # NOTE(eclbg): we never get here. We wrote this code when we though we'd enable promoting back and forth between
341
- # staging and live, but the current CLI commands don't allow getting in that state
342
- if current_deployment.get("status") != "data_ready":
341
+ if live_deployment.get("status") != "data_ready":
343
342
  click.echo(FeedbackManager.error(message="Previous deployment is not ready"))
344
- deploy_errors = current_deployment.get("errors", [])
343
+ deploy_errors = live_deployment.get("errors", [])
345
344
  for deploy_error in deploy_errors:
346
345
  click.echo(FeedbackManager.error(message=f"* {deploy_error}"))
347
346
  return
@@ -359,7 +358,6 @@ def discard_deployment(host: Optional[str], headers: dict, wait: bool, request_f
359
358
  click.echo(FeedbackManager.error(message=result.get("error")))
360
359
  sys_exit("deployment_error", result.get("error", "Unknown error"))
361
360
 
362
- deployment_to_discard = deployments[1]
363
361
  click.echo(FeedbackManager.success(message="Discard process successfully started"))
364
362
 
365
363
  if wait:
@@ -92,20 +92,6 @@ class CLITokenException(CLIException):
92
92
  super().__init__(message, "token_error", **kw_telemetry_event_data)
93
93
 
94
94
 
95
- class CLIBuildException(CLIException):
96
- """Exceptions generated by the build commands"""
97
-
98
- def __init__(self, message: str, **kw_telemetry_event_data: Any) -> None:
99
- super().__init__(message, "build_error", **kw_telemetry_event_data)
100
-
101
-
102
- class CLIDeploymentException(CLIException):
103
- """Exceptions generated by the deployment commands"""
104
-
105
- def __init__(self, message: str, **kw_telemetry_event_data: Any) -> None:
106
- super().__init__(message, "deployment_error", **kw_telemetry_event_data)
107
-
108
-
109
95
  class CLITestException(CLIException):
110
96
  """Exceptions generated by the test commands"""
111
97
 
@@ -127,13 +113,6 @@ class CLIMockException(CLIException):
127
113
  super().__init__(message, "mock_error", **kw_telemetry_event_data)
128
114
 
129
115
 
130
- class CLIAgentException(CLIException):
131
- """Exceptions generated by the agent commands"""
132
-
133
- def __init__(self, message: str, **kw_telemetry_event_data: Any) -> None:
134
- super().__init__(message, "agent_error", **kw_telemetry_event_data)
135
-
136
-
137
116
  class CLILoginException(CLIException):
138
117
  """Exceptions generated by the login commands"""
139
118
 
@@ -1304,11 +1304,11 @@ class ConnectionReplacements:
1304
1304
  """Maps prompt parameters to API parameters."""
1305
1305
 
1306
1306
  api_params = {}
1307
- for key in params.keys():
1307
+ for key, value in params.items():
1308
1308
  try:
1309
- api_params[ConnectionReplacements._PARAMS_REPLACEMENTS[service][key]] = params[key]
1309
+ api_params[ConnectionReplacements._PARAMS_REPLACEMENTS[service][key]] = value
1310
1310
  except KeyError:
1311
- api_params[key] = params[key]
1311
+ api_params[key] = value
1312
1312
 
1313
1313
  api_params["service"] = service
1314
1314
  return api_params
@@ -200,7 +200,6 @@ import builtins
200
200
  import datetime
201
201
  import linecache
202
202
  import os.path
203
- import posixpath
204
203
  import re
205
204
  import threading
206
205
  from io import StringIO
@@ -465,28 +464,6 @@ class Loader(BaseLoader):
465
464
  return Template(f.read(), name=name, loader=self)
466
465
 
467
466
 
468
- class DictLoader(BaseLoader):
469
- """A template loader that loads from a dictionary."""
470
-
471
- def __init__(self, dict, **kwargs):
472
- super().__init__(**kwargs)
473
- self.dict = dict
474
-
475
- def resolve_path(self, name, parent_path=None):
476
- if (
477
- parent_path
478
- and not parent_path.startswith("<")
479
- and not parent_path.startswith("/")
480
- and not name.startswith("/")
481
- ):
482
- file_dir = posixpath.dirname(parent_path)
483
- name = posixpath.normpath(posixpath.join(file_dir, name))
484
- return name
485
-
486
- def _create_template(self, name):
487
- return Template(self.dict[name], name=name, loader=self)
488
-
489
-
490
467
  class _Node:
491
468
  def each_child(self):
492
469
  return ()
@@ -554,43 +531,6 @@ class _ExtendsBlock(_Node):
554
531
  self.name = name
555
532
 
556
533
 
557
- class _IncludeBlock(_Node):
558
- def __init__(self, name, reader, line):
559
- self.name = name
560
- self.template_name = reader.name
561
- self.line = line
562
-
563
- def find_named_blocks(self, loader, named_blocks):
564
- included = loader.load(self.name, self.template_name)
565
- included.file.find_named_blocks(loader, named_blocks)
566
-
567
- def generate(self, writer):
568
- included = writer.loader.load(self.name, self.template_name)
569
- with writer.include(included, self.line):
570
- included.file.body.generate(writer)
571
-
572
-
573
- class _ApplyBlock(_Node):
574
- def __init__(self, method, line, body=None):
575
- self.method = method
576
- self.line = line
577
- self.body = body
578
-
579
- def each_child(self):
580
- return (self.body,)
581
-
582
- def generate(self, writer):
583
- method_name = "_tt_apply%d" % writer.apply_counter
584
- writer.apply_counter += 1
585
- writer.write_line("def %s():" % method_name, self.line)
586
- with writer.indent():
587
- writer.write_line("_tt_buffer = []", self.line)
588
- writer.write_line("_tt_append = _tt_buffer.append", self.line)
589
- self.body.generate(writer)
590
- writer.write_line("return _tt_utf8('').join(_tt_buffer)", self.line)
591
- writer.write_line("_tt_append(_tt_utf8(%s(%s())))" % (self.method, method_name), self.line)
592
-
593
-
594
534
  class _ControlBlock(_Node):
595
535
  def __init__(self, statement, line, body=None):
596
536
  if not disable_template_security_validation.get(False):
@@ -654,11 +594,6 @@ class _Expression(_Node):
654
594
  writer.write_line("_tt_append(_tt_tmp)", self.line)
655
595
 
656
596
 
657
- class _Module(_Expression):
658
- def __init__(self, expression, line):
659
- super().__init__("_tt_modules." + expression, line, raw=True)
660
-
661
-
662
597
  class _Text(_Node):
663
598
  def __init__(self, value, line, whitespace):
664
599
  self.value = value
@@ -1125,6 +1060,10 @@ def check_valid_code(code):
1125
1060
  Traceback (most recent call last):
1126
1061
  ...
1127
1062
  tinybird.tornado_template.SecurityException: Invalid expression type: Attribute(value=Name(id='len', ctx=Load()), attr='__self__', ctx=Load())
1063
+ >>> check_valid_code('''String(__builtins__)''')
1064
+ Traceback (most recent call last):
1065
+ ...
1066
+ tinybird.tornado_template.SecurityException: Invalid name: __builtins__
1128
1067
  """
1129
1068
  body = ast.parse(code).body
1130
1069
  for expr in body:
@@ -1148,9 +1087,11 @@ def check_valid_expr(expr):
1148
1087
  raise SecurityException(f"Invalid function: {ast.dump(expr)}")
1149
1088
  for subexpr in expr.args:
1150
1089
  check_valid_expr(subexpr)
1151
- # Keyword values are evaluated before the call, so they need the same checks as positional args
1090
+ # Keyword values never went through the allowlist and customers call functions it doesn't list
1091
+ # there, so they only get the checks that block reaching interpreter internals
1152
1092
  for keyword in expr.keywords:
1153
- check_valid_expr(keyword.value)
1093
+ for node in ast.walk(keyword.value):
1094
+ _check_node_is_safe(node)
1154
1095
  elif isinstance(expr, ast.Constant):
1155
1096
  if isinstance(expr.value, int):
1156
1097
  return
@@ -1162,7 +1103,9 @@ def check_valid_expr(expr):
1162
1103
  return
1163
1104
  raise SecurityException(f"Invalid Constant: {ast.dump(expr)}")
1164
1105
  elif isinstance(expr, ast.Name):
1165
- return
1106
+ # Same rule as control blocks: dunder names such as __builtins__ only reach interpreter internals
1107
+ if _is_dunder(expr.id):
1108
+ raise SecurityException(f"Invalid name: {expr.id}")
1166
1109
  elif isinstance(expr, ast.UnaryOp):
1167
1110
  if not isinstance(expr.op, ast.USub) and not isinstance(expr.op, ast.Not):
1168
1111
  raise SecurityException(f"Invalid UnaryOp: {ast.dump(expr.op)}")
@@ -1182,6 +1125,8 @@ def check_valid_expr(expr):
1182
1125
  check_valid_expr(expr.slice.lower)
1183
1126
  if expr.slice.upper is not None:
1184
1127
  check_valid_expr(expr.slice.upper)
1128
+ if expr.slice.step is not None:
1129
+ check_valid_expr(expr.slice.step)
1185
1130
  elif isinstance(expr.slice, (ast.Constant, ast.Subscript)):
1186
1131
  check_valid_expr(expr.slice)
1187
1132
  else:
@@ -1199,6 +1144,9 @@ def check_valid_expr(expr):
1199
1144
  check_valid_expr(x)
1200
1145
  elif isinstance(expr, ast.FormattedValue):
1201
1146
  check_valid_expr(expr.value)
1147
+ # f"{x:{expr}}" nests expressions in the format spec, which JoinedStr.values doesn't include
1148
+ if expr.format_spec is not None:
1149
+ check_valid_expr(expr.format_spec)
1202
1150
  elif isinstance(expr, ast.IfExp):
1203
1151
  check_valid_expr(expr.test)
1204
1152
  check_valid_expr(expr.body)
@@ -1208,7 +1156,9 @@ def check_valid_expr(expr):
1208
1156
 
1209
1157
 
1210
1158
  # Names that allow evaluating code, reflection or reading attributes by string. They are not part of
1211
- # SAFE_BUILTINS either; rejecting them while parsing gives a clear error instead of a NameError.
1159
+ # SAFE_BUILTINS, so they can only resolve to template parameters (customers use `type`, `dir`, `property`
1160
+ # or `object` as parameter names). Reading them is allowed; calling them is rejected while parsing to give
1161
+ # a clear error instead of a NameError or "'str' object is not callable".
1212
1162
  UNSAFE_NAMES = frozenset(
1213
1163
  {
1214
1164
  "eval",
@@ -1235,11 +1185,6 @@ UNSAFE_NAMES = frozenset(
1235
1185
  }
1236
1186
  )
1237
1187
 
1238
- # Common customer parameter names that collide with unsafe builtins (e.g. a `dir` sort-direction
1239
- # parameter, or a `type` filter). Reads are allowed so `defined(dir)` keeps working; only calling the
1240
- # builtin itself (`dir(...)`) is rejected.
1241
- NAMES_ALLOWED_AS_PARAMETERS = frozenset({"type", "dir"})
1242
-
1243
1188
  # Attributes that reach interpreter internals without a leading underscore (frames and code objects of
1244
1189
  # generators, coroutines and tracebacks) or that read arbitrary attributes through a format string
1245
1190
  # ("{0.__class__}".format(x)). Any attribute starting with "_" is rejected too. Same idea as jinja2.sandbox.
@@ -1298,29 +1243,28 @@ def _is_dunder(name: str) -> bool:
1298
1243
  return len(name) > 4 and name.startswith("__") and name.endswith("__")
1299
1244
 
1300
1245
 
1246
+ def _check_node_is_safe(node: ast.AST) -> None:
1247
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in UNSAFE_NAMES:
1248
+ raise SecurityException(f"Invalid function name: {node.func.id}")
1249
+ if isinstance(node, ast.Attribute):
1250
+ if node.attr.startswith("_") or node.attr in UNSAFE_ATTRIBUTE_NAMES:
1251
+ raise SecurityException(f"Invalid attribute: {node.attr}")
1252
+ elif isinstance(node, ast.Name):
1253
+ if _is_dunder(node.id):
1254
+ raise SecurityException(f"Invalid name: {node.id}")
1255
+ elif isinstance(node, ast.ExceptHandler):
1256
+ if node.name and _is_dunder(node.name):
1257
+ raise SecurityException(f"Invalid name: {node.name}")
1258
+ elif isinstance(node, (ast.Yield, ast.YieldFrom, ast.Await)):
1259
+ raise SecurityException(f"Invalid expression type: {ast.dump(node)}")
1260
+
1261
+
1301
1262
  def _check_valid_statement_tree(tree: ast.Module, expected_statements: int, statement: str) -> None:
1302
1263
  statements = 0
1303
1264
  for node in ast.walk(tree):
1304
1265
  if isinstance(node, ast.stmt):
1305
1266
  statements += 1
1306
- # Customer pipes use `type`/`dir` as parameters; allow reads but forbid calls to the unsafe builtin.
1307
- elif (
1308
- isinstance(node, ast.Call)
1309
- and isinstance(node.func, ast.Name)
1310
- and node.func.id in NAMES_ALLOWED_AS_PARAMETERS
1311
- ):
1312
- raise SecurityException(f"Invalid function name: {node.func.id}")
1313
- elif isinstance(node, ast.Attribute):
1314
- if node.attr.startswith("_") or node.attr in UNSAFE_ATTRIBUTE_NAMES:
1315
- raise SecurityException(f"Invalid attribute: {node.attr}")
1316
- elif isinstance(node, ast.Name):
1317
- if _is_dunder(node.id) or (node.id in UNSAFE_NAMES and node.id not in NAMES_ALLOWED_AS_PARAMETERS):
1318
- raise SecurityException(f"Invalid name: {node.id}")
1319
- elif isinstance(node, ast.ExceptHandler):
1320
- if node.name and _is_dunder(node.name):
1321
- raise SecurityException(f"Invalid name: {node.name}")
1322
- elif isinstance(node, (ast.Yield, ast.YieldFrom, ast.Await)):
1323
- raise SecurityException(f"Invalid expression type: {ast.dump(node)}")
1267
+ _check_node_is_safe(node)
1324
1268
  if statements != expected_statements:
1325
1269
  raise SecurityException(f"Invalid control block: {statement}")
1326
1270
 
@@ -1342,7 +1286,8 @@ def check_valid_control_statement(statement: str) -> None:
1342
1286
  >>> check_valid_control_statement("elif eval('1')")
1343
1287
  Traceback (most recent call last):
1344
1288
  ...
1345
- tinybird.tornado_template.SecurityException: Invalid name: eval
1289
+ tinybird.tornado_template.SecurityException: Invalid function name: eval
1290
+ >>> check_valid_control_statement("if defined(property) and object != 'x'")
1346
1291
  >>> check_valid_control_statement("for x in (i for i in [1]).gi_frame.f_builtins")
1347
1292
  Traceback (most recent call last):
1348
1293
  ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: tinybird
3
- Version: 4.6.22.dev0
3
+ Version: 4.6.24.dev0
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/forward/commands
6
6
  Author: Tinybird
@@ -52,6 +52,16 @@ The Tinybird command-line tool allows you to use all the Tinybird functionality
52
52
  Changelog
53
53
  ----------
54
54
 
55
+ 4.6.22
56
+ ********
57
+
58
+ - `Added` ``tb pull`` demotes sink and copy pipes that exceed the workspace's sink/copy pipe plan limit to plain pipes instead of failing, stripping their ``TYPE``/``EXPORT_*``/``TARGET_DATASOURCE``/``COPY_SCHEDULE`` settings and warning that the plan must be upgraded to restore them.
59
+
60
+ 4.6.21
61
+ ********
62
+
63
+ - `Fixed` ``tb deployment discard`` now discards the deployment that is not live, instead of assuming the live deployment is listed first. Previously, if removing the old deployment failed after a promotion, the command refused to discard it.
64
+
55
65
  4.6.20
56
66
  ********
57
67
 
@@ -5,11 +5,11 @@ tinybird/git_settings.py,sha256=ETrcrvnhi3I9W-cGOwILXTBBNVFm5lYbeDdgTFaxT48,3898
5
5
  tinybird/prompts.py,sha256=FNYpphDYlrXpm5AQM3yfQPbj4gd_TVnwoaoeswtx0OE,32699
6
6
  tinybird/service_datasources.py,sha256=kNTlnSV6dngANVVDhFQsY77mlD5jKExy_YRC4Cw5rsQ,57555
7
7
  tinybird/sql.py,sha256=V807-z7tlZWMowr2ezYhsxjFWm-rWejaQ1mlr0ZXtPU,47173
8
- tinybird/sql_template.py,sha256=mQy_b9iLg8Tfoabl3lhUMyc3_XgTVk5TAeynvbjS6aw,132009
9
- tinybird/sql_template_fmt.py,sha256=UZbCNmFNdSBTC1joEu-1C11vBbyWgG_sCbYJFqSKNwo,10528
8
+ tinybird/sql_template.py,sha256=T9dcuB6vdYnw9BvpptIqo4MsWXbzn3O_uS6PoH0xmAM,133695
9
+ tinybird/sql_template_fmt.py,sha256=cOhhkA9h_M8II-06Qx5pt3E7IXUgzvrzfWGMtWdtkUM,10525
10
10
  tinybird/sql_toolset.py,sha256=gB6ZkQww8pU-7Zu2H1l7GHttPHkgqGhYBt6MfuhduDc,29926
11
- tinybird/syncasync.py,sha256=VV2yn9TKc-aEr0hzQQWIwUcklWZG8AHv7fj6V5LnQSU,27718
12
- tinybird/tornado_template.py,sha256=uPfjB-Q558g9C0sDkeYdrYzkCODxWm2ihqsTmADNtnw,50708
11
+ tinybird/syncasync.py,sha256=fFQ5GPPnUlTJ-J3VTw9AZCNXtp5IUiP9Q22sZXcixU4,26352
12
+ tinybird/tornado_template.py,sha256=mTQMTywuqTfk9xX737mT5Fv_o-PNHJxpgTa4LF-edCE,49079
13
13
  tinybird/ch_utils/constants.py,sha256=8q5SH-0uBpYsBzmPpYsyhy-wcqvLgK1hmPr_NsKKoi8,4349
14
14
  tinybird/ch_utils/engine.py,sha256=XxBpukaupup8QEKWB7CPZu8c3r1NQuliA2ao85oWw8Y,37883
15
15
  tinybird/datafile/common.py,sha256=QZlhZhzRuVV1UAMQRzyogPjxA6YzgD1SGwrQdEJO6QE,112481
@@ -19,30 +19,30 @@ tinybird/datafile/parse_datasource.py,sha256=yd58HrUF4yNJXLn6OsvKGpZJpvrcjLGAeJG
19
19
  tinybird/datafile/parse_pipe.py,sha256=StMwF0Y1ny94hQxgPgcyx6-Jr46gCYBuPGpHF4bFiSQ,4956
20
20
  tinybird/iterating/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  tinybird/iterating/data_branch_modes.py,sha256=5YuDa-gr8mKwmES8Xro6TRKbQtaOtIw4GbC606Qhu3o,184
22
- tinybird/tb/__cli__.py,sha256=SvGe_cLMTLnOZVvc16OOiDPBbFvqvZ09nw_SxCSXi9w,247
22
+ tinybird/tb/__cli__.py,sha256=cSOtgXlDSfy7RTi_BRGXUDdv5zrVJUtpKIQ8hGf8tro,247
23
23
  tinybird/tb/check_pypi.py,sha256=Gp0HkHHDFMSDL6nxKlOY51z7z1Uv-2LRexNTZSHHGmM,552
24
24
  tinybird/tb/cli.py,sha256=IjiGfNIpxSxi1odK1kMj9s8lEhx3sAUgGA263XdmyR0,1119
25
- tinybird/tb/client.py,sha256=NGlqdw2FvBMU8F7r_sYYhwp2VqhrhTyEfh9jvuex9qM,59963
25
+ tinybird/tb/client.py,sha256=gWlh7v_tB7n2VC-sNjVQqMByWIqaR9dLYRJe7pzpb-k,59866
26
26
  tinybird/tb/config.py,sha256=l6NtYvSPZDVOtt1W4G4ExXXDIf0hm6lvRnRoDMbdrsY,5429
27
27
  tinybird/tb/modules/branch.py,sha256=U50nj2kZllCkOHQfJWQ-YQ260pvlcssgStMSovmqMiU,10157
28
28
  tinybird/tb/modules/build.py,sha256=q7-aVM7q1tApiax_y6vYSCZq4cJ9ATSAm9D-elpNp7I,6543
29
29
  tinybird/tb/modules/build_common.py,sha256=Ks_fUbS376x6KaSihUl8MMvAk4pXYIMZauVL_SOpVEA,25533
30
30
  tinybird/tb/modules/cicd.py,sha256=xKa7XBQ1wgkYeG9lBLJQcwM7P18cY0c_QeF-KFO9_HI,8287
31
31
  tinybird/tb/modules/cli.py,sha256=6leKHypUjieVS9DbEXizFmUZq2lbJFlBDXYyvy_v8G4,44230
32
- tinybird/tb/modules/common.py,sha256=tfyYO7kzGPlfCoNwl-CPctTCSSJEc83jt2IhahZ3qiw,91041
32
+ tinybird/tb/modules/common.py,sha256=pRWBwL2O8SgdGTdd8kcAPTSG9mEFXgzZGXyQZMi72HY,91037
33
33
  tinybird/tb/modules/config.py,sha256=U13HYGCILZjtWeP-LG1g63GDGR0CbqT0QE_cBZeNeOs,11157
34
34
  tinybird/tb/modules/connection.py,sha256=HwHn0YgwqcKEYCLr2OqDzJzAUFd65Wv8MZiSUw_TLII,18452
35
35
  tinybird/tb/modules/connection_dynamodb.py,sha256=vaB28d6t8nOd6kcUQGMUkl3IZin5DYSBN2TBPm2968Q,9490
36
36
  tinybird/tb/modules/connection_kafka.py,sha256=AwGRrHqkV8mb5xSsFEEZ1HuamOPRukFRr5HsUJLfnbE,33502
37
37
  tinybird/tb/modules/connection_s3.py,sha256=PeXZ4NHKhvAz0pUpXLPc7V1A8s9fllbyZJdyUFIC5Pw,16393
38
38
  tinybird/tb/modules/copy.py,sha256=Rh4R38KLsYnNlSLIFLExqjP3J6Oy4SRJktWKOOmC8fA,7440
39
- tinybird/tb/modules/create.py,sha256=kPeDZb60cfEyA8nIv_8JiMRAaZp4_03YsJfx4gQAgjs,22869
40
- tinybird/tb/modules/datasource.py,sha256=6QGnjy5RDggyXq3GJ3ejeNo0Rx8itsn6zGvLKmz-_ek,79999
39
+ tinybird/tb/modules/create.py,sha256=K4rzbQEtMyu2wqoTEHzYoBSGkMzxOfPSjgxKn0EY8io,22835
40
+ tinybird/tb/modules/datasource.py,sha256=bxmZGaoAyuk6ev9SfqJPn8kOjyk5g1RQwwaStoz5dT0,79985
41
41
  tinybird/tb/modules/deployment.py,sha256=vB4QSMsN4-DH-yDfSESQ5KKXgzC1t1fdCsqJ9lh8ej0,20956
42
- tinybird/tb/modules/deployment_common.py,sha256=vU3MsZu4FGvgx9P2i1ulPElhc6LlwA1c_KiNb1nsEBk,36137
42
+ tinybird/tb/modules/deployment_common.py,sha256=kqBGO1Psdb9eP8fxKuO7z6_rWOc4DFvO0Ra35NFP-20,36030
43
43
  tinybird/tb/modules/deprecations.py,sha256=XXekrzPO9v12F1ToQDUGzLYJJ2wrEUlGKOkLCSdfHiM,4935
44
44
  tinybird/tb/modules/endpoint.py,sha256=yRTh6rQFJ98LA0hSC8rPD3EcDaJj41gk9oCsgcZPu_c,12112
45
- tinybird/tb/modules/exceptions.py,sha256=_1BHy0OixEkeF0fOCu1_1Pj8pJS4BNfUyucqCViJGTw,5958
45
+ tinybird/tb/modules/exceptions.py,sha256=vCEaVa13BhKRSBeICVYwazdaL8VSPIDD55oTs68h-e0,5196
46
46
  tinybird/tb/modules/feedback_manager.py,sha256=B6iGI15HN2IQiJJ5J5FrwrAWsfBwPXsY6MYlTopMEgA,78236
47
47
  tinybird/tb/modules/fmt.py,sha256=ejQC1-2mK42saA2R9DA-CENYgu06SUoiCoX4bCtRXT8,3734
48
48
  tinybird/tb/modules/info.py,sha256=oO2DpPT-hpQ2Gf_TBAJHQ_xIfc1WCBpiGWhiIeM_K4w,13042
@@ -91,18 +91,18 @@ tinybird/tb/modules/datafile/format_datasource.py,sha256=_w5Y4tgl3Sejf0T8146F_ow
91
91
  tinybird/tb/modules/datafile/format_pipe.py,sha256=Y4XBrJtOlQyDiRTHAfu7jo66Ud52TayNtJ55eevOb8g,7938
92
92
  tinybird/tb/modules/datafile/pipe_checker.py,sha256=jpHfaql5sjYPKb9jSYMwV0pWSVncTZIokijKZAgWweY,24929
93
93
  tinybird/tb/modules/datafile/playground.py,sha256=LeQj8udkolUZIrSGlwiB0WKk-x4eAyWt15HusV4hxRg,41035
94
- tinybird/tb/modules/datafile/pull.py,sha256=MBLT9qO51Bz2BQwj2h1jTwlOVokFKZyfbxehRQOcdeQ,6441
94
+ tinybird/tb/modules/datafile/pull.py,sha256=OwYsa11uT7s1ALJL6EuF_gOppHR9O8Fr7kh4azVbFsM,9262
95
95
  tinybird/tb/modules/tinyunit/tinyunit.py,sha256=JZhIgyVv-S1H_gu0Tuv1U7U4OzFfVM_r7twzPM_JKeU,11047
96
96
  tinybird/tb_cli_modules/cicd.py,sha256=T5GBMRmxLeblNKV9XICGNA37mJVtUHx2sex463-QLsk,13845
97
- tinybird/tb_cli_modules/common.py,sha256=Tl_zKec6SNVUjZ2uaLEPe03k8CPoJlcU-1sjx0Vl074,76019
97
+ tinybird/tb_cli_modules/common.py,sha256=7PCdcyK1pEyDwTUjNk7-DmSaitTIqAkVx_XYC-nIqLA,76015
98
98
  tinybird/tb_cli_modules/config.py,sha256=xF4L95Flpfg-TzGkfgpYmiiDbDjxoSWJLfYt1dCdFNQ,11164
99
99
  tinybird/tb_cli_modules/exceptions.py,sha256=pmucP4kTF4irIt7dXiG-FcnI-o3mvDusPmch1L8RCWk,3367
100
100
  tinybird/tb_cli_modules/regions.py,sha256=QjsL5H6Kg-qr0aYVLrvb1STeJ5Sx_sjvbOYO0LrEGMk,166
101
101
  tinybird/tb_cli_modules/telemetry.py,sha256=W098H6jmS4kpE7hN3tadaREBTf7oMocel-lkKWN0pU8,10466
102
102
  tinybird/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
103
103
  tinybird/utils/bools.py,sha256=Rwuh_R6j62JVkcxKskLQao7DE2Jj6TQzsbgvtCsLHP4,587
104
- tinybird-4.6.22.dev0.dist-info/METADATA,sha256=vsK-j0g0Ofjw4mE6nLG5-11nDMBJikfbHVsHq9LRAlk,18280
105
- tinybird-4.6.22.dev0.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
106
- tinybird-4.6.22.dev0.dist-info/entry_points.txt,sha256=LwdHU6TfKx4Qs7BqqtaczEZbImgU7Abe9Lp920zb_fo,43
107
- tinybird-4.6.22.dev0.dist-info/top_level.txt,sha256=ZIQJTPCzMqnfDzM_hEGZrJqDSEcKnIK_49T86DGWpyQ,78
108
- tinybird-4.6.22.dev0.dist-info/RECORD,,
104
+ tinybird-4.6.24.dev0.dist-info/METADATA,sha256=kXHsAaCfmemek9Dj_XvDXDfgPZdb5Vy1fmf6qUcaBUY,18845
105
+ tinybird-4.6.24.dev0.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
106
+ tinybird-4.6.24.dev0.dist-info/entry_points.txt,sha256=LwdHU6TfKx4Qs7BqqtaczEZbImgU7Abe9Lp920zb_fo,43
107
+ tinybird-4.6.24.dev0.dist-info/top_level.txt,sha256=ZIQJTPCzMqnfDzM_hEGZrJqDSEcKnIK_49T86DGWpyQ,78
108
+ tinybird-4.6.24.dev0.dist-info/RECORD,,