tinybird-cli 6.5.1.dev0__py3-none-any.whl → 6.5.4.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/__cli__.py CHANGED
@@ -4,5 +4,5 @@ __description__ = 'Tinybird Command Line Tool'
4
4
  __url__ = 'https://www.tinybird.co/docs/cli'
5
5
  __author__ = 'Tinybird'
6
6
  __author_email__ = 'support@tinybird.co'
7
- __version__ = '6.5.1.dev0'
8
- __revision__ = '676e19d'
7
+ __version__ = '6.5.4.dev0'
8
+ __revision__ = 'a87d7b3'
tinybird/client.py CHANGED
@@ -470,10 +470,26 @@ class TinyB:
470
470
  async def datasource_truncate(self, datasource_name: str):
471
471
  return await self._req(f"/v0/datasources/{datasource_name}/truncate", method="POST", data="")
472
472
 
473
- async def datasource_delete_rows(self, datasource_name: str, delete_condition: str, dry_run: bool = False):
474
- params = {"delete_condition": delete_condition}
473
+ async def datasource_delete_rows(
474
+ self,
475
+ datasource_name: str,
476
+ delete_condition: str,
477
+ dry_run: bool = False,
478
+ lightweight: bool = False,
479
+ wait: bool = True,
480
+ partition: Optional[str] = None,
481
+ projection_mode: Optional[str] = None,
482
+ ):
483
+ params: Dict[str, Any] = {"delete_condition": delete_condition}
484
+ if lightweight:
485
+ params["wait"] = "true" if wait else "false"
486
+ if partition:
487
+ params["partition"] = partition
488
+ if projection_mode:
489
+ params["projection_mode"] = projection_mode
490
+ return await self._req(f"/v1/datasources/{datasource_name}/delete", method="POST", data=params)
475
491
  if dry_run:
476
- params.update({"dry_run": "true"})
492
+ params["dry_run"] = "true"
477
493
  return await self._req(f"/v0/datasources/{datasource_name}/delete", method="POST", data=params)
478
494
 
479
495
  async def datasource_dependencies(
@@ -770,14 +786,11 @@ class TinyB:
770
786
  branch_name: str,
771
787
  last_partition: Optional[bool],
772
788
  all: Optional[bool],
773
- ignore_datasources: Optional[List[str]],
774
789
  ):
775
790
  params = {
776
791
  "name": branch_name,
777
792
  "data": LAST_PARTITION if last_partition else (ALL_PARTITIONS if all else ""),
778
793
  }
779
- if ignore_datasources:
780
- params["ignore_datasources"] = ",".join(ignore_datasources)
781
794
  return await self._req(f"/v0/environments?{urlencode(params)}", method="POST", data=b"")
782
795
 
783
796
  async def branch_workspace_data(
@@ -785,7 +798,6 @@ class TinyB:
785
798
  workspace_id: str,
786
799
  last_partition: bool,
787
800
  all: bool,
788
- ignore_datasources: Optional[List[str]] = None,
789
801
  ):
790
802
  params = {}
791
803
  if last_partition:
@@ -793,8 +805,6 @@ class TinyB:
793
805
 
794
806
  if all:
795
807
  params["mode"] = ALL_PARTITIONS
796
- if ignore_datasources:
797
- params["ignore_datasources"] = ",".join(ignore_datasources)
798
808
  url = f"/v0/environments/{workspace_id}/data?{urlencode(params)}"
799
809
  return await self._req(url, method="POST", data=b"")
800
810
 
@@ -1373,6 +1373,9 @@ async def process_file(
1373
1373
  if not skip_connectors:
1374
1374
  try:
1375
1375
  is_oauthbearer = params.get("kafka_sasl_mechanism") == "OAUTHBEARER"
1376
+ # PLAINTEXT brokers have no SASL at all, so a key/secret pair is
1377
+ # neither produced by the wizard nor expected on the wire.
1378
+ is_plaintext = params.get("kafka_security_protocol") == "PLAINTEXT"
1376
1379
  connector_params = {
1377
1380
  "kafka_bootstrap_servers": params.get("kafka_bootstrap_servers", None),
1378
1381
  "kafka_key": params.get("kafka_key", None),
@@ -1382,6 +1385,10 @@ async def process_file(
1382
1385
  "kafka_schema_registry_url": params.get("kafka_schema_registry_url", None),
1383
1386
  "kafka_ssl_ca_pem": get_ca_pem_content(params.get("kafka_ssl_ca_pem", None), filename),
1384
1387
  "kafka_sasl_mechanism": params.get("kafka_sasl_mechanism", None),
1388
+ # Explicit default — None would be dropped by the client's
1389
+ # `if value is not None` filter, leaving the server to pick
1390
+ # its own protocol instead of the SASL_SSL we want.
1391
+ "kafka_security_protocol": params.get("kafka_security_protocol", "SASL_SSL"),
1385
1392
  "kafka_sasl_oauthbearer_method": params.get("kafka_sasl_oauthbearer_method", None),
1386
1393
  "kafka_sasl_oauthbearer_aws_region": params.get("kafka_sasl_oauthbearer_aws_region", None),
1387
1394
  "kafka_sasl_oauthbearer_aws_role_arn": params.get("kafka_sasl_oauthbearer_aws_role_arn", None),
@@ -1402,6 +1409,11 @@ async def process_file(
1402
1409
  connector_params["kafka_sasl_oauthbearer_aws_region"],
1403
1410
  connector_params["kafka_sasl_oauthbearer_aws_role_arn"],
1404
1411
  ]
1412
+ elif is_plaintext:
1413
+ # No SASL, no credentials — only the broker address matters.
1414
+ required_params = [
1415
+ connector_params["kafka_bootstrap_servers"],
1416
+ ]
1405
1417
  else:
1406
1418
  required_params = [
1407
1419
  connector_params["kafka_bootstrap_servers"],
@@ -3134,7 +3146,7 @@ async def new_pipe(
3134
3146
  scopes.append(sc)
3135
3147
  try:
3136
3148
  r = await tb_client.alter_tokens(token_name, scopes)
3137
- token = r["token"] # type: ignore
3149
+ token = r["token"] # type: ignore[index, unused-ignore]
3138
3150
  except Exception as e:
3139
3151
  raise click.ClickException(FeedbackManager.error_creating_pipe(error=e))
3140
3152
  else:
@@ -3144,7 +3156,7 @@ async def new_pipe(
3144
3156
  r = await tb_client.create_token(
3145
3157
  token_name, [f"PIPES:{tk['permissions']}:{p['name']}"], "P", p["name"]
3146
3158
  )
3147
- token = r["token"] # type: ignore
3159
+ token = r["token"] # type: ignore[index, unused-ignore]
3148
3160
  except Exception as e:
3149
3161
  raise click.ClickException(FeedbackManager.error_creating_pipe(error=e))
3150
3162
 
@@ -5282,8 +5294,16 @@ async def _gather_with_concurrency(n, *tasks):
5282
5294
  semaphore = Semaphore(n)
5283
5295
 
5284
5296
  async def sem_task(task):
5285
- async with semaphore:
5286
- return await task
5297
+ try:
5298
+ async with semaphore:
5299
+ return await task
5300
+ finally:
5301
+ # If we never reached `await task` (e.g. cancelled while waiting
5302
+ # on the semaphore after a sibling raised), close the coroutine
5303
+ # so it doesn't surface as a "coroutine was never awaited"
5304
+ # RuntimeWarning at GC time. close() is a no-op on an already
5305
+ # exhausted coroutine, so the happy path is unaffected.
5306
+ task.close()
5287
5307
 
5288
5308
  wrapped = [ensure_future(sem_task(t)) for t in tasks]
5289
5309
  try:
@@ -879,6 +879,11 @@ class FeedbackManager:
879
879
  success_delete_rows_datasource = success_message(
880
880
  "** Data Source '{datasource}' rows deleted matching condition \"{delete_condition}\""
881
881
  )
882
+ success_lightweight_delete_rows_datasource = success_message(
883
+ "** Data Source '{datasource}' rows deleted matching condition \"{delete_condition}\""
884
+ "\n Rows affected: {rows_affected}"
885
+ "\n Partitions scanned: {partitions_scanned} (done: {partitions_done}, in progress: {partitions_in_progress})"
886
+ )
882
887
  success_dry_run_delete_rows_datasource = success_message(
883
888
  "** [DRY RUN] Data Source '{datasource}' rows '{rows}' matching condition \"{delete_condition}\" to be deleted"
884
889
  )
tinybird/sql.py CHANGED
@@ -398,6 +398,14 @@ def parse_table_structure(schema: str) -> List[Dict[str, Any]]:
398
398
  >>> parse_table_structure('c Nullable(Float32) DEFAULT NULL')
399
399
  [{'name': 'c', 'type': 'Float32', 'codec': None, 'default_value': None, 'jsonpath': None, 'nullable': True, 'normalized_name': 'c'}]
400
400
 
401
+ >>> parse_table_structure('c LowCardinality(Nullable(String))')
402
+ [{'name': 'c', 'type': 'LowCardinality(Nullable(String))', 'codec': None, 'default_value': None, 'jsonpath': None, 'nullable': False, 'normalized_name': 'c'}]
403
+
404
+ >>> parse_table_structure('c Nullable(LowCardinality(Nullable(String)))')
405
+ Traceback (most recent call last):
406
+ ...
407
+ ValueError: Nested type LowCardinality(Nullable(String)) cannot be inside Nullable type
408
+
401
409
  >>> parse_table_structure("c String DEFAULT 'bla'")
402
410
  [{'name': 'c', 'type': 'String', 'codec': None, 'default_value': "DEFAULT 'bla'", 'jsonpath': None, 'nullable': False, 'normalized_name': 'c'}]
403
411
 
tinybird/sql_template.py CHANGED
@@ -7,7 +7,7 @@ from collections import deque
7
7
  from datetime import datetime
8
8
  from functools import lru_cache
9
9
  from json import loads
10
- from typing import Any, Callable, Dict, List, Optional, Tuple, Union
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
11
11
 
12
12
  from tornado import escape
13
13
  from tornado.util import ObjectDict, exec_in, unicode_type
@@ -1499,7 +1499,7 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1499
1499
  )
1500
1500
 
1501
1501
  exec_in(self.compiled, namespace)
1502
- execute = namespace["_tt_execute"]
1502
+ execute = cast(Callable[[], bytes], namespace["_tt_execute"])
1503
1503
  # Clear the traceback module's cache of source data now that
1504
1504
  # we've generated a new template (mainly for this module's
1505
1505
  # unittests, where different tests reuse the same name).
tinybird/sql_toolset.py CHANGED
@@ -158,7 +158,7 @@ def has_unoptimized_join(sql: str, left_table: Optional[Union[Tuple[str, str], T
158
158
  raise UnoptimizedJoinException(sql)
159
159
 
160
160
 
161
- def format_where_for_mutation_command(where_clause: str) -> str:
161
+ def format_where_for_mutation_command(where_clause: str, lightweight: bool = False) -> str:
162
162
  """
163
163
  >>> format_where_for_mutation_command("numnights = 99")
164
164
  'DELETE WHERE numnights = 99'
@@ -170,11 +170,14 @@ def format_where_for_mutation_command(where_clause: str) -> str:
170
170
  "DELETE WHERE reservationid = \\\\'\\\\\\\\\\\\'foo\\\\'"
171
171
  >>> format_where_for_mutation_command("reservationid = '\\\\'foo'")
172
172
  "DELETE WHERE reservationid = \\\\'\\\\\\\\\\\\'foo\\\\'"
173
+ >>> format_where_for_mutation_command("number < 3", lightweight=True)
174
+ 'UPDATE _row_exists = 0 WHERE number < 3'
173
175
  """
174
176
  formatted_condition = chquery.format(f"""SELECT {where_clause}""").split("SELECT ")[1]
175
177
  formatted_condition = formatted_condition.replace("\\", "\\\\").replace("'", "''")
176
178
  quoted_condition = chquery.format(f"SELECT '{formatted_condition}'").split("SELECT ")[1]
177
- return f"DELETE WHERE {quoted_condition[1:-1]}"
179
+ prefix = "UPDATE _row_exists = 0 WHERE" if lightweight else "DELETE WHERE"
180
+ return f"{prefix} {quoted_condition[1:-1]}"
178
181
 
179
182
 
180
183
  # Functions that take table/dictionary names as string literal arguments.
@@ -389,11 +389,21 @@ async def branch_current() -> None:
389
389
  )
390
390
  @coro
391
391
  async def create_branch(
392
- branch_name: Optional[str], last_partition: bool, all: bool, ignore_datasources: List[str], wait: bool
392
+ branch_name: Optional[str],
393
+ last_partition: bool,
394
+ all: bool,
395
+ ignore_datasources: Tuple[str, ...],
396
+ wait: bool,
393
397
  ) -> None:
394
398
  if last_partition and all:
395
399
  raise CLIException(FeedbackManager.error_exception(error="Use --last-partition or --all but not both"))
396
- await create_workspace_branch(branch_name, last_partition, all, list(ignore_datasources), wait)
400
+ if ignore_datasources:
401
+ click.echo(
402
+ FeedbackManager.warning_deprecated(
403
+ warning="--ignore-datasource is no longer supported for `tb branch create` and will be ignored."
404
+ )
405
+ )
406
+ await create_workspace_branch(branch_name, last_partition, all, wait)
397
407
 
398
408
 
399
409
  @branch.command(name="rm", short_help="Removes a Branch from the Workspace. It can't be recovered.")
@@ -468,14 +478,6 @@ async def delete_branch(branch_name_or_id: str, yes: bool) -> None:
468
478
  help="Attach all data from 'main' to the new Branch. Use only if you actually need all the data in the Branch",
469
479
  hidden=True,
470
480
  )
471
- @click.option(
472
- "-i",
473
- "--ignore-datasource",
474
- "ignore_datasources",
475
- type=str,
476
- multiple=True,
477
- help="Ignore specified data source partitions",
478
- )
479
481
  @click.option(
480
482
  "--wait",
481
483
  is_flag=True,
@@ -483,7 +485,7 @@ async def delete_branch(branch_name_or_id: str, yes: bool) -> None:
483
485
  help="Wait for data branch jobs to finish, showing a progress bar. Disabled by default.",
484
486
  )
485
487
  @coro
486
- async def data_branch(last_partition: bool, all: bool, ignore_datasources: List[str], wait: bool) -> None:
488
+ async def data_branch(last_partition: bool, all: bool, wait: bool) -> None:
487
489
  if last_partition and all:
488
490
  raise CLIException(FeedbackManager.error_exception(error="Use --last-partition or --all but not both"))
489
491
 
@@ -500,7 +502,7 @@ async def data_branch(last_partition: bool, all: bool, ignore_datasources: List[
500
502
  raise CLIException(FeedbackManager.error_not_allowed_in_main_branch())
501
503
 
502
504
  try:
503
- response = await client.branch_workspace_data(config["id"], last_partition, all, ignore_datasources)
505
+ response = await client.branch_workspace_data(config["id"], last_partition, all)
504
506
 
505
507
  is_job: bool = "job" in response
506
508
  is_summary: bool = "partitions" in response
@@ -112,7 +112,9 @@ def echo_safe_humanfriendly_tables_format_smart_table(data: Iterable[Any], colum
112
112
  try:
113
113
  click.echo(humanfriendly.tables.format_smart_table(data, column_names=column_names))
114
114
  except ValueError as exc:
115
- if str(exc) == "max() arg is an empty sequence":
115
+ # Python 3.11 wording: "max() arg is an empty sequence"
116
+ # Python 3.12 wording: "max() iterable argument is empty"
117
+ if str(exc) in ("max() arg is an empty sequence", "max() iterable argument is empty"):
116
118
  click.echo("------------")
117
119
  click.echo("Empty")
118
120
  click.echo("------------")
@@ -677,7 +679,6 @@ async def create_workspace_branch(
677
679
  branch_name: Optional[str],
678
680
  last_partition: bool,
679
681
  all: bool,
680
- ignore_datasources: Optional[List[str]],
681
682
  wait: Optional[bool],
682
683
  ) -> None:
683
684
  """
@@ -701,7 +702,6 @@ async def create_workspace_branch(
701
702
  branch_name,
702
703
  last_partition,
703
704
  all,
704
- ignore_datasources,
705
705
  )
706
706
  assert isinstance(response, dict)
707
707
 
@@ -35,6 +35,18 @@ from tinybird.tb_cli_modules.common import (
35
35
  )
36
36
  from tinybird.tb_cli_modules.exceptions import CLIConnectionException
37
37
 
38
+
39
+ def _upper_or_none(_ctx: click.Context, _param: click.Parameter, value: Optional[str]) -> Optional[str]:
40
+ """Click callback that uppercases the option value if present.
41
+
42
+ The async client matches Kafka SASL mechanisms case-sensitively (see
43
+ ``tinybird/client.py``), so we normalize at the flag layer to avoid the
44
+ mismatch where the CLI treats ``--sasl-mechanism oauthbearer`` as IAM
45
+ but the client falls through and sends PLAIN credentials.
46
+ """
47
+ return value.upper() if value else value
48
+
49
+
38
50
  DATA_CONNECTOR_SETTINGS: Dict[DataConnectorType, List[str]] = {
39
51
  DataConnectorType.KAFKA: [
40
52
  "kafka_bootstrap_servers",
@@ -46,6 +58,10 @@ DATA_CONNECTOR_SETTINGS: Dict[DataConnectorType, List[str]] = {
46
58
  "kafka_sasl_mechanism",
47
59
  "kafka_schema_registry_url",
48
60
  "kafka_ssl_ca_pem",
61
+ "kafka_sasl_oauthbearer_method",
62
+ "kafka_sasl_oauthbearer_aws_region",
63
+ "kafka_sasl_oauthbearer_aws_role_arn",
64
+ "kafka_sasl_oauthbearer_aws_external_id",
49
65
  ],
50
66
  DataConnectorType.GCLOUD_SCHEDULER: ["gcscheduler_region"],
51
67
  DataConnectorType.GCLOUD_STORAGE: [
@@ -79,7 +95,7 @@ DATA_CONNECTOR_SETTINGS: Dict[DataConnectorType, List[str]] = {
79
95
  }
80
96
 
81
97
  SENSITIVE_CONNECTOR_SETTINGS = {
82
- DataConnectorType.KAFKA: ["kafka_sasl_plain_password"],
98
+ DataConnectorType.KAFKA: ["kafka_sasl_plain_password", "kafka_sasl_oauthbearer_aws_role_arn"],
83
99
  DataConnectorType.GCLOUD_SCHEDULER: [
84
100
  "gcscheduler_target_url",
85
101
  "gcscheduler_job_name",
@@ -120,14 +136,33 @@ def connection_create(ctx: Context) -> None:
120
136
  @click.option(
121
137
  "--sasl-mechanism",
122
138
  default="PLAIN",
139
+ callback=_upper_or_none,
123
140
  help="Authentication method for connection-based protocols. Defaults to 'PLAIN'",
124
141
  )
125
142
  @click.option(
126
143
  "--security-protocol",
127
144
  default="SASL_SSL",
145
+ callback=_upper_or_none,
128
146
  help="Security protocol for connection-based protocols. Defaults to 'SASL_SSL'",
129
147
  )
130
148
  @click.option("--ssl-ca-pem", default=None, help="Path or content of the CA Certificate file in PEM format")
149
+ @click.option(
150
+ "--oauthbearer-method",
151
+ default=None,
152
+ callback=_upper_or_none,
153
+ help="OAUTHBEARER token provider. Use 'AWS' for Amazon MSK IAM authentication.",
154
+ )
155
+ @click.option("--oauthbearer-aws-region", default=None, help="AWS region of the MSK cluster (OAUTHBEARER+AWS only)")
156
+ @click.option(
157
+ "--oauthbearer-aws-role-arn",
158
+ default=None,
159
+ help="ARN of the IAM role to assume for the MSK cluster (OAUTHBEARER+AWS only)",
160
+ )
161
+ @click.option(
162
+ "--oauthbearer-aws-external-id",
163
+ default=None,
164
+ help="Optional external_id for the AWS assume-role call. If omitted the server derives it from the workspace.",
165
+ )
131
166
  @click.pass_context
132
167
  @coro
133
168
  async def connection_create_kafka(
@@ -141,6 +176,10 @@ async def connection_create_kafka(
141
176
  sasl_mechanism: Optional[str],
142
177
  security_protocol: Optional[str],
143
178
  ssl_ca_pem: Optional[str],
179
+ oauthbearer_method: Optional[str],
180
+ oauthbearer_aws_region: Optional[str],
181
+ oauthbearer_aws_role_arn: Optional[str],
182
+ oauthbearer_aws_external_id: Optional[str],
144
183
  ) -> None:
145
184
  """
146
185
  Add a Kafka connection
@@ -149,21 +188,39 @@ async def connection_create_kafka(
149
188
  $ tb connection create kafka --bootstrap-servers google.com:80 --key a --secret b --connection-name c
150
189
  """
151
190
 
191
+ # `--sasl-mechanism` arrives uppercased via the _upper_or_none callback.
192
+ is_oauthbearer = sasl_mechanism == "OAUTHBEARER"
193
+
152
194
  bootstrap_servers and validate_kafka_bootstrap_servers(bootstrap_servers)
153
- key and validate_kafka_key(key)
154
- secret and validate_kafka_secret(secret)
195
+ if not is_oauthbearer:
196
+ key and validate_kafka_key(key)
197
+ secret and validate_kafka_secret(secret)
155
198
  schema_registry_url and validate_kafka_schema_registry_url(schema_registry_url)
156
199
  auto_offset_reset and validate_kafka_auto_offset_reset(auto_offset_reset)
157
200
 
158
201
  if not bootstrap_servers:
159
202
  bootstrap_servers = click.prompt("Kafka Bootstrap Server")
160
203
  validate_kafka_bootstrap_servers(bootstrap_servers)
161
- if key is None:
162
- key = click.prompt("Key")
163
- validate_kafka_key(key)
164
- if secret is None:
165
- secret = click.prompt("Secret", hide_input=True)
166
- validate_kafka_secret(secret)
204
+ if is_oauthbearer:
205
+ if not oauthbearer_method:
206
+ oauthbearer_method = "AWS"
207
+ if not oauthbearer_aws_region:
208
+ oauthbearer_aws_region = click.prompt("AWS region (e.g. us-east-1)")
209
+ if not oauthbearer_aws_role_arn:
210
+ oauthbearer_aws_role_arn = click.prompt("AWS IAM role ARN")
211
+ if not oauthbearer_aws_external_id:
212
+ # Pre-shared external_id (set by the cluster owner in the role's trust
213
+ # policy). Empty answer = let the server derive one from the workspace.
214
+ provided = click.prompt("External ID (optional, leave blank to let the server derive one)", default="")
215
+ if provided and provided.strip():
216
+ oauthbearer_aws_external_id = provided.strip()
217
+ else:
218
+ if key is None:
219
+ key = click.prompt("Key")
220
+ validate_kafka_key(key)
221
+ if secret is None:
222
+ secret = click.prompt("Secret", hide_input=True)
223
+ validate_kafka_secret(secret)
167
224
  if not connection_name:
168
225
  connection_name = click.prompt(
169
226
  f"Connection name (optional, current: {bootstrap_servers})", default=bootstrap_servers
@@ -172,6 +229,19 @@ async def connection_create_kafka(
172
229
  obj: Dict[str, Any] = ctx.ensure_object(dict)
173
230
  client: TinyB = obj["client"]
174
231
 
232
+ # Only forward the OAUTHBEARER kwargs when the user actually picked OAUTHBEARER.
233
+ # The receiving client accepts them as kwargs either way, but keeping the call
234
+ # symmetric with the auth mechanism makes the intent obvious to readers
235
+ oauthbearer_kwargs = (
236
+ {
237
+ "kafka_sasl_oauthbearer_method": oauthbearer_method,
238
+ "kafka_sasl_oauthbearer_aws_region": oauthbearer_aws_region,
239
+ "kafka_sasl_oauthbearer_aws_role_arn": oauthbearer_aws_role_arn,
240
+ "kafka_sasl_oauthbearer_aws_external_id": oauthbearer_aws_external_id,
241
+ }
242
+ if is_oauthbearer
243
+ else {}
244
+ )
175
245
  result = await client.connection_create_kafka(
176
246
  bootstrap_servers,
177
247
  key,
@@ -182,6 +252,7 @@ async def connection_create_kafka(
182
252
  sasl_mechanism,
183
253
  security_protocol,
184
254
  get_ca_pem_content(ssl_ca_pem),
255
+ **oauthbearer_kwargs,
185
256
  )
186
257
 
187
258
  id = result["id"]
@@ -363,23 +363,70 @@ async def datasource_truncate(ctx, datasource_name, yes, cascade):
363
363
  @click.argument("datasource_name")
364
364
  @click.option("--sql-condition", default=None, help="SQL WHERE condition to remove rows", hidden=True, required=True)
365
365
  @click.option("--yes", is_flag=True, default=False, help="Do not ask for confirmation")
366
- @click.option("--wait", is_flag=True, default=False, help="Wait for delete job to finish, disabled by default")
366
+ @click.option(
367
+ "--wait/--no-wait",
368
+ default=None,
369
+ help="Wait for the delete to finish. Defaults to true with --lightweight-delete (sync request), "
370
+ "false otherwise (returns a job id).",
371
+ )
367
372
  @click.option("--dry-run", is_flag=True, default=False, help="Run the command without deleting anything")
373
+ @click.option(
374
+ "--lightweight-delete",
375
+ "lightweight",
376
+ is_flag=True,
377
+ default=False,
378
+ help="Use ClickHouse lightweight DELETE. Defaults to waiting inline and returning rows_affected; "
379
+ "pass --no-wait to enqueue a job instead. Not compatible with --dry-run.",
380
+ )
381
+ @click.option(
382
+ "--partition",
383
+ default=None,
384
+ help="Restrict the lightweight delete to a single partition expression. Only valid with --lightweight-delete.",
385
+ )
386
+ @click.option(
387
+ "--projection-mode",
388
+ type=click.Choice(["throw", "drop", "rebuild"]),
389
+ default=None,
390
+ help="How ClickHouse should handle table projections when running the lightweight DELETE. "
391
+ "throw: fail the DELETE if the table has any projection defined (ClickHouse default). "
392
+ "drop: drop the affected projections so the DELETE can proceed; they will need to be recreated. "
393
+ "rebuild: rebuild the affected projections after the DELETE finishes. "
394
+ "Only valid with --lightweight-delete.",
395
+ )
368
396
  @click.pass_context
369
397
  @coro
370
- async def datasource_delete_rows(ctx, datasource_name, sql_condition, yes, wait, dry_run):
398
+ async def datasource_delete_rows(
399
+ ctx, datasource_name, sql_condition, yes, wait, dry_run, lightweight, partition, projection_mode
400
+ ):
371
401
  """
372
402
  Delete rows from a datasource
373
403
 
374
404
  - Delete rows with SQL condition: `tb datasource delete [datasource_name] --sql-condition "country='ES'"`
375
405
 
376
406
  - Delete rows with SQL condition and wait for the job to finish: `tb datasource delete [datasource_name] --sql-condition "country='ES'" --wait`
407
+
408
+ - Use ClickHouse lightweight DELETE (synchronous, no job): `tb datasource delete [datasource_name] --sql-condition "country='ES'" --lightweight-delete`
409
+
410
+ - Use ClickHouse lightweight DELETE and return immediately with a job id: `tb datasource delete [datasource_name] --sql-condition "country='ES'" --lightweight-delete --no-wait`
377
411
  """
378
412
 
379
413
  semver: str = ctx.ensure_object(dict)["config"]["semver"]
380
414
  await warn_if_in_live(semver)
381
415
 
382
416
  client: TinyB = ctx.ensure_object(dict)["client"]
417
+ if lightweight and dry_run:
418
+ raise CLIDatasourceException(
419
+ FeedbackManager.error_exception(error="--lightweight-delete is not compatible with --dry-run")
420
+ )
421
+ if (partition or projection_mode) and not lightweight:
422
+ raise CLIDatasourceException(
423
+ FeedbackManager.error_exception(error="--partition and --projection-mode require --lightweight-delete")
424
+ )
425
+ # Lightweight delete is sync by default (the endpoint blocks and returns
426
+ # rows_affected); the classic /v0/ delete is async by default (returns a
427
+ # job id). The tri-state --wait/--no-wait lets users override either.
428
+ if wait is None:
429
+ wait = lightweight
383
430
  if (
384
431
  dry_run
385
432
  or yes
@@ -390,7 +437,15 @@ async def datasource_delete_rows(ctx, datasource_name, sql_condition, yes, wait,
390
437
  )
391
438
  ):
392
439
  try:
393
- res = await client.datasource_delete_rows(datasource_name, sql_condition, dry_run)
440
+ res = await client.datasource_delete_rows(
441
+ datasource_name,
442
+ sql_condition,
443
+ dry_run,
444
+ lightweight=lightweight,
445
+ wait=wait,
446
+ partition=partition,
447
+ projection_mode=projection_mode,
448
+ )
394
449
  if dry_run:
395
450
  click.echo(
396
451
  FeedbackManager.success_dry_run_delete_rows_datasource(
@@ -398,10 +453,24 @@ async def datasource_delete_rows(ctx, datasource_name, sql_condition, yes, wait,
398
453
  )
399
454
  )
400
455
  return
456
+ # Lightweight sync path returns rows_affected directly, no job involved.
457
+ if lightweight and wait:
458
+ mutation = res.get("mutation") or {}
459
+ click.echo(
460
+ FeedbackManager.success_lightweight_delete_rows_datasource(
461
+ datasource=datasource_name,
462
+ delete_condition=sql_condition,
463
+ rows_affected=res.get("rows_affected", 0),
464
+ partitions_scanned=mutation.get("partitions_scanned", 0),
465
+ partitions_done=mutation.get("partitions_done", 0),
466
+ partitions_in_progress=mutation.get("partitions_in_progress", 0),
467
+ )
468
+ )
469
+ return
401
470
  job_id = res["job_id"]
402
471
  job_url = res["job_url"]
403
472
  click.echo(FeedbackManager.info_datasource_delete_rows_job_url(url=job_url))
404
- if wait:
473
+ if wait and not lightweight:
405
474
  progress_symbols = ["-", "\\", "|", "/"]
406
475
  progress_str = "Waiting for the job to finish"
407
476
  # TODO: Use click.echo instead of print and see if the behavior is the same
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: tinybird_cli
3
- Version: 6.5.1.dev0
3
+ Version: 6.5.4.dev0
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/cli
6
6
  Author: Tinybird
@@ -22,7 +22,7 @@ Requires-Dist: requests<3,>=2.28.1
22
22
  Requires-Dist: shandy-sqlfmt==0.11.1
23
23
  Requires-Dist: shandy-sqlfmt[jinjafmt]==0.11.1
24
24
  Requires-Dist: toposort==1.10
25
- Requires-Dist: tornado~=6.0.0
25
+ Requires-Dist: tornado~=6.5.5
26
26
  Requires-Dist: urllib3<2,>=1.26.14
27
27
  Requires-Dist: wheel
28
28
  Requires-Dist: packaging<24,>=23.1
@@ -43,6 +43,17 @@ The Tinybird command-line tool allows you to use all the Tinybird functionality
43
43
  Changelog
44
44
  ----------
45
45
 
46
+ 6.5.3
47
+ ***********
48
+
49
+ - `Added` ``tb datasource delete`` now accepts ``--lightweight-delete``, ``--partition`` and ``--projection-mode``, backed by the ``POST /v1/datasources/{name}/delete`` endpoint that uses ClickHouse's lightweight ``DELETE FROM`` instead of the heavy ``ALTER TABLE ... DELETE`` mutation. Lightweight delete is synchronous by default and returns the affected row count plus a partition-level mutation block; pass ``--no-wait`` to enqueue a job and receive the v0-compatible envelope.
50
+
51
+ 6.5.1
52
+ ***********
53
+
54
+ - `Added` `tb connection create kafka` accepts four new flags — ``--oauthbearer-method``, ``--oauthbearer-aws-region``, ``--oauthbearer-aws-role-arn``, ``--oauthbearer-aws-external-id`` — for creating Amazon MSK connections with SASL ``OAUTHBEARER`` (IAM) authentication. Prompts interactively for region, role ARN, and external ID when the flags are omitted.
55
+ - `Changed` `tb branch create` now shows a deprecation warning (instead of failing) when `--ignore-datasource` is provided, and continues by ignoring that flag.
56
+
46
57
  6.5.0
47
58
  ***********
48
59
 
@@ -1,29 +1,29 @@
1
- tinybird/__cli__.py,sha256=RC05z-Mi-JSxLkRFIXAyp1xsbVwUBLxxRlS5Xhl-7_Y,236
1
+ tinybird/__cli__.py,sha256=Wf0VS-wmsRJS_MkAzra29SE6k7aD2D1mjYB3EoflQZ0,236
2
2
  tinybird/check_pypi.py,sha256=_4NkharLyR_ELrAdit-ftqIWvOf7jZNPt3i76frlo9g,975
3
- tinybird/client.py,sha256=gMgXUu7ByZ2GFPd2lLv1SzQqR49m-6vw64xCrF63r-g,52119
3
+ tinybird/client.py,sha256=Tql8VEj9OJIRN7znUsEGkJ5XSwLMR2lKk5GpxTqgL14,52360
4
4
  tinybird/config.py,sha256=g74rE9jbVcyFj1bms5T3VEITLr21_WFy79Uj5ovvj90,7413
5
5
  tinybird/context.py,sha256=o4yvlXPkMLmdh-XJl3wpmqPAMeRRz5ScKzKlHHKn_I8,1201
6
- tinybird/datafile_common.py,sha256=qoY7vpBaGTozSbAZVBZoPeegzSw9zsVw5QuHuORJPP0,236285
6
+ tinybird/datafile_common.py,sha256=MyMobDF6UOFFEsXnHrUWuOb0kJ6tkzZpiq1CjL7RlKY,237598
7
7
  tinybird/datatypes.py,sha256=Lr8BIaRP--qzuZFpAdv20uQaeE1BOAMmuEwWVHheFPw,11355
8
- tinybird/feedback_manager.py,sha256=OehfKVruCHwUNN1bHIbDICvOaIovc3hb6RjGHTyIkBc,67667
8
+ tinybird/feedback_manager.py,sha256=ZnRsExCkEZKuslmSxn3m706-0UIZ_qOpdek9ar16KOI,68001
9
9
  tinybird/git_settings.py,sha256=mqWgeboOlOFsSo97qyv595UCR2R1QCAqT4GTawBNPBg,3935
10
- tinybird/sql.py,sha256=8pvjlKwdJ-PuJkCo57W8e1gj5z0RzUP7vOnum6Pi134,48901
11
- tinybird/sql_template.py,sha256=_J-QmHos4Sx9YS6tVsEyE68qrcFbj13VwCA1zB3Sz8Q,128634
10
+ tinybird/sql.py,sha256=qdqxsq1WPiGTk-SmZWaxvZb936ISdo1ChZhnC_uXeUE,49351
11
+ tinybird/sql_template.py,sha256=nSzUdaQe18Da_8qlgLW7tsZl1pOa2kDaBa8TdrF6pJ4,128667
12
12
  tinybird/sql_template_fmt.py,sha256=Ma4qcs-2r8ZXQC4GUmrCqYz34DsnGF8k5lE2Jwnr314,10638
13
- tinybird/sql_toolset.py,sha256=xWY-EtixaZKUPuNY4WBz2fDwwNnmbgtdLsmI2wy67UA,27220
13
+ tinybird/sql_toolset.py,sha256=TvWeH3DsB13SfJ97V_t_ZaFTJ1IAtsrxQ8E3FLL8_tE,27442
14
14
  tinybird/syncasync.py,sha256=rIPmCvygWSFqfnlVqhZH4N9gVVTvD6DEPsfoxGizYrI,27776
15
15
  tinybird/tb_cli.py,sha256=q1LGAsBVVMJsjR2HK62Pu6vpVtLzNmH8wHrEVUUdVkU,744
16
16
  tinybird/tornado_template.py,sha256=1_0nYFk_xJh_TMHh6AKkJILvnNY6xYmaM-uJ3Ofv7e8,42085
17
17
  tinybird/ch_utils/constants.py,sha256=yTNizMzgYNBzUc2EV3moBfdrDIggOe9hiuAgWF7sv2c,4333
18
18
  tinybird/ch_utils/engine.py,sha256=NzYUnmXsrJQimwXfCqdtIMyuS_Ad0OSdEnqNXzzStvY,37489
19
19
  tinybird/tb_cli_modules/auth.py,sha256=3xu8STgouOgLkqlBf9LWFg9Oto_NyuDKsUWF95-zGwI,8741
20
- tinybird/tb_cli_modules/branch.py,sha256=peIo1kzBSsPGYFPOqE8qCjUlxBZURlCIoYuVTKwAQaw,39354
20
+ tinybird/tb_cli_modules/branch.py,sha256=tUWGCBFbGIIfAYEHM7DK9UkoV7kTUrWyYiGVIwXyWSI,39367
21
21
  tinybird/tb_cli_modules/cicd.py,sha256=i2Mw8AbmEVNBcEPYdio7liy3PGqh1ezVFZ0OmJ9ww5o,13809
22
22
  tinybird/tb_cli_modules/cli.py,sha256=KvgZfdYRvgKG5QAiFmlfLbVGI-xmuxbs-EdkAslqrEQ,60242
23
- tinybird/tb_cli_modules/common.py,sha256=WS8on_s8sGSq27YQdmMsfQ45MP9wyoJaRN_Cfrr6lUk,77275
23
+ tinybird/tb_cli_modules/common.py,sha256=763GkBOgXIcB48UnuSjnGfxdfoSx5iqCniK15R0Aq5Y,77366
24
24
  tinybird/tb_cli_modules/config.py,sha256=0kFDmsDcjKon32rgFGMHHKSbv4j5dOrXtVOlyuAyEkk,11510
25
- tinybird/tb_cli_modules/connection.py,sha256=yoYUQo-Fl36LTHeGI3HpFOCLiP0wKhWsoP9P9G26NZ8,21305
26
- tinybird/tb_cli_modules/datasource.py,sha256=b12ClLFISGHqK7zrLZBX5OT-8Nxd2oW734-Xon0dTE8,32541
25
+ tinybird/tb_cli_modules/connection.py,sha256=JO2v2xTGoiMeThzCncvxMFUHLghyyGXNF0FcAvi5ZOk,24543
26
+ tinybird/tb_cli_modules/datasource.py,sha256=9j4cw02k8txabdUoRHap-oPwECbbBnzugsFIf6o_ljY,35688
27
27
  tinybird/tb_cli_modules/exceptions.py,sha256=pmucP4kTF4irIt7dXiG-FcnI-o3mvDusPmch1L8RCWk,3367
28
28
  tinybird/tb_cli_modules/fmt.py,sha256=edQap4tAqWMWogSIx5zriT75naLi73XTB3NwatmcrFw,3518
29
29
  tinybird/tb_cli_modules/job.py,sha256=BxiqChDYa_4W4tFXHIUz2bOzkx8WMFywynjiPWgDlnE,2936
@@ -36,9 +36,8 @@ tinybird/tb_cli_modules/token.py,sha256=JXATKTlbXohP9ZDZjlz8E4VYG6zrknKZhuz_wh1z
36
36
  tinybird/tb_cli_modules/workspace.py,sha256=Ssu-jKk7lp4Gh3qX5DXhIbImyhs9aCvL09u_T75zw1I,12421
37
37
  tinybird/tb_cli_modules/workspace_members.py,sha256=ksXsjd233y9-sNlz4Qb-meZbX4zn1B84e_bSm2i8rhg,8731
38
38
  tinybird/tb_cli_modules/tinyunit/tinyunit.py,sha256=50uqMgJD2BqSVONtCm55nuGRWhBNZWRc2GP1Qb8URdg,11246
39
- tinybird/tb_cli_modules/tinyunit/tinyunit_lib.py,sha256=NHoXcCHPDcKWYLzgP3NViho3Ey-6RV-ynPDzySPrTPE,1817
40
- tinybird_cli-6.5.1.dev0.dist-info/METADATA,sha256=dCFUx9OV1_Sq2txVdvqrWI7vPR3eJs2SZLRzfF_kq74,81774
41
- tinybird_cli-6.5.1.dev0.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
42
- tinybird_cli-6.5.1.dev0.dist-info/entry_points.txt,sha256=PKPKuPmA4IfJYnCFHHUiw-aAWZuBomFvwCklv1OyCjE,43
43
- tinybird_cli-6.5.1.dev0.dist-info/top_level.txt,sha256=ZIQJTPCzMqnfDzM_hEGZrJqDSEcKnIK_49T86DGWpyQ,78
44
- tinybird_cli-6.5.1.dev0.dist-info/RECORD,,
39
+ tinybird_cli-6.5.4.dev0.dist-info/METADATA,sha256=iftt91TP5EZwKaP-bhiMd6WA-k9WZrPSLvyqfDMUE78,82821
40
+ tinybird_cli-6.5.4.dev0.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
41
+ tinybird_cli-6.5.4.dev0.dist-info/entry_points.txt,sha256=PKPKuPmA4IfJYnCFHHUiw-aAWZuBomFvwCklv1OyCjE,43
42
+ tinybird_cli-6.5.4.dev0.dist-info/top_level.txt,sha256=ZIQJTPCzMqnfDzM_hEGZrJqDSEcKnIK_49T86DGWpyQ,78
43
+ tinybird_cli-6.5.4.dev0.dist-info/RECORD,,
@@ -1,65 +0,0 @@
1
- import json
2
- from collections import namedtuple
3
- from json import JSONEncoder
4
- from typing import Optional
5
-
6
-
7
- class MyJSONEncoder(JSONEncoder):
8
- # def default(self, in_obj):
9
- # loaded_data = [
10
- # DataUnitTest(
11
- # getattr(unitDataTest, 'name'),
12
- # getattr(unitDataTest, 'description'),
13
- # getattr(unitDataTest, 'enabled'),
14
- # getattr(unitDataTest, 'endpoint'),
15
- # getattr(unitDataTest, 'result'),
16
- # getattr(unitDataTest, 'time'),
17
- # getattr(unitDataTest, 'sql'))
18
- # for unitDataTest in in_obj]
19
- # return loaded_data
20
- def default(self, obj):
21
- return obj.to_json()
22
-
23
-
24
- class DataUnitTest:
25
- def __init__(
26
- self,
27
- name: str,
28
- description: str,
29
- enabled: bool,
30
- endpoint: Optional[str],
31
- result: Optional[str],
32
- time: int,
33
- sql: str,
34
- ):
35
- self.name = name
36
- self.description = description
37
- self.enabled = enabled
38
- self.endpoint = endpoint
39
- self.result = result
40
- self.time = time
41
- self.sql = sql
42
-
43
- def __iter__(self):
44
- yield from {
45
- "name": self.name,
46
- "description": self.description,
47
- "enabled": self.enabled,
48
- "endpoint": self.endpoint,
49
- "sql": self.sql,
50
- "result": self.result,
51
- "time": self.time,
52
- }.items()
53
-
54
- def __str__(self):
55
- return json.dumps(dict(self), ensure_ascii=False)
56
-
57
- def __repr__(self):
58
- return self.__str__()
59
-
60
- def to_json(self):
61
- return self.__str__()
62
-
63
-
64
- def customDataUnitTestDecoder(dataUnitTestDict):
65
- return namedtuple("X", dataUnitTestDict.keys())(*dataUnitTestDict.values())