tinybird-cli 6.5.3.dev0__py3-none-any.whl → 6.5.5.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
@@ -1396,7 +1396,7 @@ _namespace = {
1396
1396
  }
1397
1397
 
1398
1398
 
1399
- reserved_vars = set(["_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *list(vars(builtins))])
1399
+ reserved_vars = {"_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *list(vars(builtins))}
1400
1400
  for p in DEFAULT_PARAM_NAMES: # we handle these in an specific manner
1401
1401
  reserved_vars.discard(p) # `format` is part of builtins
1402
1402
  # Allow 'id' to be used as a template parameter - https://gitlab.com/tinybird/analytics/-/issues/19119
@@ -1542,35 +1542,6 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1542
1542
  raise e
1543
1543
 
1544
1544
 
1545
- class CodeWriter:
1546
- def __init__(self, file, template):
1547
- self.file = file
1548
- self.current_template = template
1549
- self.apply_counter = 0
1550
- self._indent = 0
1551
-
1552
- def indent_size(self):
1553
- return self._indent
1554
-
1555
- def indent(self):
1556
- class Indenter:
1557
- def __enter__(_):
1558
- self._indent += 1
1559
- return self
1560
-
1561
- def __exit__(_, *args):
1562
- assert self._indent > 0
1563
- self._indent -= 1
1564
-
1565
- return Indenter()
1566
-
1567
- def write_line(self, line, line_number, indent=None):
1568
- if indent is None:
1569
- indent = self._indent
1570
- line_comment = " # %s:%d" % ("<generated>", line_number)
1571
- print(" " * indent + line + line_comment, file=self.file)
1572
-
1573
-
1574
1545
  def get_var_names(t: Template):
1575
1546
  """
1576
1547
  Extract variable names from a template.
@@ -1982,11 +1953,12 @@ def get_var_data(content, node_id=None):
1982
1953
  """
1983
1954
 
1984
1955
  def node_to_value(x):
1985
- if type(x) in (ast.Bytes, ast.Str):
1986
- return x.s
1987
- elif type(x) == ast.Num: # noqa: E721
1988
- return x.n
1989
- elif type(x) == ast.NameConstant: # noqa: E721
1956
+ # ast.Constant supersedes the deprecated ast.Bytes/ast.Str/ast.Num/
1957
+ # ast.NameConstant node classes. Those were aliases of ast.Constant
1958
+ # since Python 3.8 (emitting DeprecationWarning on access in 3.12) and
1959
+ # were removed in Python 3.14. The parser only ever produces
1960
+ # ast.Constant nodes, so .value holds str/bytes/int/float/bool/None.
1961
+ if type(x) == ast.Constant: # noqa: E721
1990
1962
  return x.value
1991
1963
  elif type(x) == ast.Name: # noqa: E721
1992
1964
  return x.id
@@ -2000,8 +1972,6 @@ def get_var_data(content, node_id=None):
2000
1972
  if not r:
2001
1973
  r = node_to_value(x.right)
2002
1974
  return r
2003
- elif type(x) == ast.Constant: # noqa: E721
2004
- return x.value
2005
1975
  elif type(x) == ast.UnaryOp and type(x.operand) == ast.Constant: # noqa: E721
2006
1976
  if type(x.op) == ast.USub: # noqa: E721
2007
1977
  return x.operand.value * -1
@@ -2020,11 +1990,9 @@ def get_var_data(content, node_id=None):
2020
1990
  return []
2021
1991
 
2022
1992
  first_elem = x.elts[0]
2023
- if type(first_elem) in (ast.Bytes, ast.Str):
2024
- return [elem.s for elem in x.elts]
2025
- elif type(first_elem) == ast.Num: # noqa: E721
2026
- return [elem.n for elem in x.elts]
2027
- elif type(first_elem) == ast.NameConstant or type(first_elem) == ast.Constant: # noqa: E721
1993
+ # See node_to_value: ast.Constant supersedes the deprecated
1994
+ # ast.Bytes/ast.Str/ast.Num/ast.NameConstant (removed in 3.14).
1995
+ if type(first_elem) == ast.Constant: # noqa: E721
2028
1996
  return [elem.value for elem in x.elts]
2029
1997
  elif type(first_elem) == ast.Name: # noqa: E721
2030
1998
  return [elem.id for elem in x.elts]
@@ -2295,7 +2263,12 @@ def get_var_names_and_types(t: Template, node_id: Optional[str] = None) -> List[
2295
2263
  vars_out.extend(
2296
2264
  parse_statement_code_if_new_vars(statement_code, skip_names=typed_names | statement_expr_names)
2297
2265
  )
2298
- return vars_out
2266
+ # Inside a {% %} statement a type function with a literal first arg
2267
+ # (e.g. {% set x = Float32(1.5) %}) is a plain value cast: the
2268
+ # assigned variable is the parameter, the literal is not. Drop
2269
+ # these artifacts so they don't surface as bogus params nor get
2270
+ # rejected by validate_template_parameter_names.
2271
+ return [vd for vd in vars_out if isinstance(vd["name"], str) and vd["name"]]
2299
2272
 
2300
2273
  def _n(chunks: list, vars_out: list[dict[str, Any]]) -> None:
2301
2274
  for x in chunks:
@@ -2342,6 +2315,43 @@ def get_var_names_and_types_cached(t: Template):
2342
2315
  return get_var_names_and_types(t)
2343
2316
 
2344
2317
 
2318
+ def validate_template_parameter_names(sql: str, node_id: Optional[str] = None) -> None:
2319
+ """Reject template parameters that don't have a usable name.
2320
+
2321
+ Type functions such as ``{{Float32(x)}}`` double as casts, so a literal
2322
+ first argument (e.g. ``{{Float32(1682892000.0)}}`` or ``{{String('')}}``)
2323
+ is parsed as a parameter whose name is a number or an empty string instead
2324
+ of an identifier. Such a "parameter" can never be supplied by a caller and
2325
+ breaks parameter metadata consumers, so it must be rejected at write time.
2326
+
2327
+ Raises ``ValueError`` with the same wording used elsewhere for invalid names.
2328
+
2329
+ Templates that fail to parse are left untouched: there are no parameters to
2330
+ validate, and reporting/deferring those errors is the job of the existing
2331
+ render/validation flow (some paths, e.g. ``ignore_sql_errors``, tolerate
2332
+ unparseable SQL on purpose). This keeps the check strictly additive. The
2333
+ only exception is the invalid-name ``ValueError`` that extraction itself
2334
+ raises (e.g. for a list literal used as first argument), which is exactly
2335
+ what this validator exists to report.
2336
+ """
2337
+ try:
2338
+ params = get_var_names_and_types(Template(sql), node_id=node_id)
2339
+ except SQLTemplateException:
2340
+ # Template-level problem (e.g. a SecurityException converted by
2341
+ # get_var_names_and_types), not an invalid parameter name.
2342
+ return
2343
+ except ValueError:
2344
+ # Extraction itself rejects some invalid names (e.g. a list literal
2345
+ # as first argument) with the same ValueError this function raises.
2346
+ raise
2347
+ except Exception:
2348
+ return
2349
+ for param in params:
2350
+ name = param.get("name")
2351
+ if not isinstance(name, str) or not name:
2352
+ raise ValueError(f'"{name}" can not be used as a variable name')
2353
+
2354
+
2345
2355
  def wrap_vars(t, escape_arrays: bool = False):
2346
2356
  def _n(chunks, v):
2347
2357
  for x in chunks:
@@ -2943,13 +2953,21 @@ def extract_variables_from_sql(sql: str, params: List[Dict[str, Any]]) -> Dict[s
2943
2953
  return defaults
2944
2954
 
2945
2955
 
2946
- def render_template_with_secrets(name: str, content: str, secrets: Optional[Dict[str, str]] = None) -> str:
2956
+ def render_template_with_secrets(
2957
+ name: str,
2958
+ content: str,
2959
+ secrets: Optional[Dict[str, str]] = None,
2960
+ empty_secret_raises: bool = False,
2961
+ ) -> str:
2947
2962
  """Renders a template with secrets, allowing for default values.
2948
2963
 
2949
2964
  Args:
2950
2965
  name: The name of the template
2951
2966
  content: The template content
2952
2967
  secrets: A dictionary mapping secret names to their values
2968
+ empty_secret_raises: When True, empty secret values or empty defaults raise
2969
+ instead of returning '""'. Used for sink metadata (Forward deploy) where
2970
+ an empty bucket path must not be baked as s3://"".
2953
2971
 
2954
2972
  Returns:
2955
2973
  The rendered template
@@ -2991,6 +3009,26 @@ def render_template_with_secrets(name: str, content: str, secrets: Optional[Dict
2991
3009
  Traceback (most recent call last):
2992
3010
  ...
2993
3011
  tinybird.sql_template.SQLTemplateException: Template Syntax Error: Cannot access secret 'MISSING_SECRET'. Check the secret exists in the Workspace and the token has the required scope.
3012
+
3013
+ >>> render_template_with_secrets(
3014
+ ... "sink_bucket",
3015
+ ... "s3://{{ tb_secret('MISSING_SECRET', '') }}",
3016
+ ... secrets = {},
3017
+ ... empty_secret_raises=True,
3018
+ ... )
3019
+ Traceback (most recent call last):
3020
+ ...
3021
+ tinybird.sql_template.SQLTemplateException: Template Syntax Error: Secret 'MISSING_SECRET' resolves to an empty value and cannot be used in sink metadata.
3022
+
3023
+ >>> render_template_with_secrets(
3024
+ ... "sink_bucket",
3025
+ ... "s3://{{ tb_secret('EMPTY_SECRET', 'fallback') }}",
3026
+ ... secrets = {'EMPTY_SECRET': ''},
3027
+ ... empty_secret_raises=True,
3028
+ ... )
3029
+ Traceback (most recent call last):
3030
+ ...
3031
+ tinybird.sql_template.SQLTemplateException: Template Syntax Error: Secret 'EMPTY_SECRET' resolves to an empty value and cannot be used in sink metadata.
2994
3032
  """
2995
3033
  if not secrets:
2996
3034
  secrets = {}
@@ -3006,15 +3044,24 @@ def render_template_with_secrets(name: str, content: str, secrets: Optional[Dict
3006
3044
  The secret value or default
3007
3045
 
3008
3046
  Raises:
3009
- SQLTemplateException: If the secret is not found and no default is provided
3047
+ SQLTemplateException: If the secret is not found and no default is provided,
3048
+ or if empty_secret_raises is True and the resolved value is empty
3010
3049
  """
3011
3050
  if secret_name in secrets:
3012
3051
  value = secrets[secret_name]
3013
3052
  if isinstance(value, str) and len(value) == 0:
3053
+ if empty_secret_raises:
3054
+ raise SQLTemplateException(
3055
+ f"Secret '{secret_name}' resolves to an empty value and cannot be used in sink metadata."
3056
+ )
3014
3057
  return '""'
3015
3058
  return value
3016
3059
  elif default is not None:
3017
3060
  if isinstance(default, str) and len(default) == 0:
3061
+ if empty_secret_raises:
3062
+ raise SQLTemplateException(
3063
+ f"Secret '{secret_name}' resolves to an empty value and cannot be used in sink metadata."
3064
+ )
3018
3065
  return '""'
3019
3066
  return default
3020
3067
  else:
tinybird/sql_toolset.py CHANGED
@@ -158,7 +158,20 @@ 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_fragment_for_mutation_command(fragment: str) -> str:
162
+ """Normalize a SQL fragment the way CH stores it in the
163
+ `system.mutations.command` column: format the expression with CH's own
164
+ formatter, then escape it as it appears inside the serialized command
165
+ (string literals keep backslash-escaped quotes)."""
166
+ formatted = chquery.format(f"""SELECT {fragment}""").split("SELECT ")[1]
167
+ formatted = formatted.replace("\\", "\\\\").replace("'", "''")
168
+ quoted = chquery.format(f"SELECT '{formatted}'").split("SELECT ")[1]
169
+ return quoted[1:-1]
170
+
171
+
172
+ def format_where_for_mutation_command(
173
+ where_clause: str, lightweight: bool = False, partition: Optional[str] = None
174
+ ) -> str:
162
175
  """
163
176
  >>> format_where_for_mutation_command("numnights = 99")
164
177
  'DELETE WHERE numnights = 99'
@@ -170,11 +183,20 @@ def format_where_for_mutation_command(where_clause: str) -> str:
170
183
  "DELETE WHERE reservationid = \\\\'\\\\\\\\\\\\'foo\\\\'"
171
184
  >>> format_where_for_mutation_command("reservationid = '\\\\'foo'")
172
185
  "DELETE WHERE reservationid = \\\\'\\\\\\\\\\\\'foo\\\\'"
186
+ >>> format_where_for_mutation_command("number < 3", lightweight=True)
187
+ 'UPDATE _row_exists = 0 WHERE number < 3'
188
+ >>> format_where_for_mutation_command("number < 3", lightweight=True, partition="201901")
189
+ 'UPDATE _row_exists = 0 IN PARTITION 201901 WHERE number < 3'
190
+ >>> format_where_for_mutation_command("number < 3", lightweight=True, partition="'2019-01-01'")
191
+ "UPDATE _row_exists = 0 IN PARTITION \\\\'2019-01-01\\\\' WHERE number < 3"
173
192
  """
174
- formatted_condition = chquery.format(f"""SELECT {where_clause}""").split("SELECT ")[1]
175
- formatted_condition = formatted_condition.replace("\\", "\\\\").replace("'", "''")
176
- quoted_condition = chquery.format(f"SELECT '{formatted_condition}'").split("SELECT ")[1]
177
- return f"DELETE WHERE {quoted_condition[1:-1]}"
193
+ quoted_condition = _format_fragment_for_mutation_command(where_clause)
194
+ prefix = "UPDATE _row_exists = 0" if lightweight else "DELETE"
195
+ # Partition-scoped mutations store the partition expression in the
196
+ # command (`… IN PARTITION <expr> WHERE <cond>`), normalized by the same
197
+ # CH formatter, so it must be part of the match.
198
+ partition_clause = f" IN PARTITION {_format_fragment_for_mutation_command(partition)}" if partition else ""
199
+ return f"{prefix}{partition_clause} WHERE {quoted_condition}"
178
200
 
179
201
 
180
202
  # Functions that take table/dictionary names as string literal arguments.
@@ -621,7 +643,7 @@ def replace_tables(
621
643
 
622
644
  if current_replacements:
623
645
  # We need to transform the dictionary into something cacheable, so a sorted tuple of tuples it is
624
- r = tuple(sorted([(k, v) for k, v in current_replacements.items()]))
646
+ r = tuple(sorted(current_replacements.items()))
625
647
  sql = replace_tables_chquery_cached(
626
648
  sql,
627
649
  r,
@@ -590,7 +590,6 @@ async def regression_tests(
590
590
 
591
591
  if current_main_workspace["id"] == config["id"]:
592
592
  raise CLIException(FeedbackManager.error_not_allowed_in_main_branch())
593
- return
594
593
  try:
595
594
  response = await client.branch_regression_tests_file(
596
595
  config["id"], regression_tests_commands, run_in_main=main
@@ -175,11 +175,6 @@ def _get_current_workspace_common(
175
175
  return next((workspace for workspace in workspaces if workspace["id"] == current_workspace_id), None)
176
176
 
177
177
 
178
- async def get_current_environment(client, config):
179
- workspaces: List[Dict[str, Any]] = (await client.user_workspaces_and_branches()).get("workspaces", [])
180
- return next((workspace for workspace in workspaces if workspace["id"] == config["id"]), None)
181
-
182
-
183
178
  async def get_current_workspace_branches(config: CLIConfig) -> List[Dict[str, Any]]:
184
179
  current_main_workspace: Optional[Dict[str, Any]] = await get_current_main_workspace(config)
185
180
  if not current_main_workspace:
@@ -508,12 +503,6 @@ def format_host(host: str, subdomain: Optional[str] = None) -> str:
508
503
  return host
509
504
 
510
505
 
511
- def region_from_host(region_name_or_host, regions):
512
- """Returns the region that matches region_name_or_host"""
513
-
514
- return next((r for r in regions if _compare_region_host(region_name_or_host, r)), None)
515
-
516
-
517
506
  def ask_for_user_token(action: str, ui_host: str) -> str:
518
507
  return click.prompt(
519
508
  f'\nUse the token called "user token" in order to {action}. Copy it from {ui_host}/tokens and paste it here',
@@ -974,7 +963,8 @@ async def push_data(
974
963
  sql_condition: Optional[str] = None,
975
964
  replace_options=None,
976
965
  concurrency: int = 1,
977
- ):
966
+ use_v1: bool = False,
967
+ ) -> Optional[list[str]]:
978
968
  if url and type(url) is tuple:
979
969
  url = url[0]
980
970
  client: TinyB = ctx.obj["client"]
@@ -1011,6 +1001,8 @@ async def push_data(
1011
1001
  datasource_name: str, url: str, mode: str, sql_condition: Optional[str], replace_options: Optional[Set[str]]
1012
1002
  ):
1013
1003
  parsed = urlparse(url)
1004
+ if use_v1 and parsed.scheme in ("http", "https"):
1005
+ raise CLIException("--experimental=use_v1 only supports local files.")
1014
1006
  # poor man's format detection
1015
1007
  _format = get_format_from_filename_or_url(url)
1016
1008
  if parsed.scheme in ("http", "https"):
@@ -1031,8 +1023,15 @@ async def push_data(
1031
1023
  sql_condition=sql_condition,
1032
1024
  format=_format,
1033
1025
  replace_options=replace_options,
1026
+ use_v1=use_v1,
1034
1027
  )
1035
1028
 
1029
+ if use_v1:
1030
+ job_id = res.get("id") or res.get("import_id")
1031
+ if not isinstance(job_id, str):
1032
+ raise CLIException("The v1 import response did not include a job ID.")
1033
+ return job_id
1034
+
1036
1035
  datasource_name = res["datasource"]["name"]
1037
1036
  try:
1038
1037
  datasource = await client.get_datasource(datasource_name)
@@ -1063,6 +1062,8 @@ async def push_data(
1063
1062
  try:
1064
1063
  tasks = [process_url(datasource_name, url, mode, sql_condition, replace_options) for url in urls]
1065
1064
  output = await gather_with_concurrency(concurrency, *tasks)
1065
+ if use_v1:
1066
+ return list(output)
1066
1067
  parser, total_rows, appended_rows = list(output)[-1]
1067
1068
  except AuthNoTokenException:
1068
1069
  raise
@@ -1083,6 +1084,8 @@ async def push_data(
1083
1084
  click.echo(FeedbackManager.success_appended_datasource(datasource=datasource_name))
1084
1085
  click.echo(FeedbackManager.info_data_pushed(datasource=datasource_name))
1085
1086
 
1087
+ return None
1088
+
1086
1089
 
1087
1090
  async def sync_data(ctx, datasource_name: str, yes: bool):
1088
1091
  client: TinyB = ctx.obj["client"]
@@ -1121,11 +1124,6 @@ def validate_datasource_name(name):
1121
1124
  raise CLIException(FeedbackManager.error_datasource_name())
1122
1125
 
1123
1126
 
1124
- def validate_connection_id(connection_id):
1125
- if not isinstance(connection_id, str) or connection_id == "":
1126
- raise CLIException(FeedbackManager.error_datasource_connection_id())
1127
-
1128
-
1129
1127
  def validate_kafka_topic(topic):
1130
1128
  if not isinstance(topic, str):
1131
1129
  raise CLIException(FeedbackManager.error_kafka_topic())
@@ -6,8 +6,6 @@ from enum import Enum
6
6
  from typing import Any, Dict, List, Optional, Tuple, Union
7
7
  from urllib.parse import urlparse
8
8
 
9
- from packaging import version
10
-
11
9
  import tinybird.client as tbc
12
10
  from tinybird.config import CURRENT_VERSION, DEFAULT_API_HOST, DEFAULT_LOCALHOST
13
11
 
@@ -34,12 +32,6 @@ class FeatureFlags:
34
32
  return not ("x.y.z" in CURRENT_VERSION and os.environ.get("TB_CLI_TELEMETRY_SEND_IN_LOCAL", "0") == "0")
35
33
 
36
34
 
37
- def compare_versions(a: str, b: str) -> int:
38
- va = version.parse(a)
39
- vb = version.parse(b)
40
- return -1 if va < vb else (1 if va > vb else 0)
41
-
42
-
43
35
  class ConfigValueOrigin(Enum):
44
36
  # Sources for config values (environment variables, .tinyb file or default value)
45
37
 
@@ -96,7 +88,7 @@ class CLIConfig:
96
88
  def to_dict(self) -> Dict[str, Any]:
97
89
  """Helper to ease"""
98
90
  result: Dict[str, Any] = self._parent.to_dict() if self._parent else {}
99
- result.update(dict((v.name, deepcopy(v.value)) for v in self._values.values()))
91
+ result.update({v.name: deepcopy(v.value) for v in self._values.values()})
100
92
  return result
101
93
 
102
94
  def __getitem__(self, key) -> Any:
@@ -179,12 +171,6 @@ class CLIConfig:
179
171
  def set_semver(self, semver: Optional[str]) -> None:
180
172
  self["semver"] = semver
181
173
 
182
- def get_semver(self) -> Optional[str]:
183
- try:
184
- return self["semver"]
185
- except KeyError:
186
- return None
187
-
188
174
  def set_token_for_host(self, token: Optional[str], host: Optional[str]) -> None:
189
175
  """Sets the token for the specified host.
190
176
 
@@ -15,7 +15,7 @@ from click import Context
15
15
 
16
16
  from tinybird.client import AuthNoTokenException, CanNotBeDeletedException, DoesNotExistException, TinyB
17
17
  from tinybird.config import get_display_host
18
- from tinybird.datafile_common import get_name_version, wait_job
18
+ from tinybird.datafile_common import get_name_version
19
19
  from tinybird.feedback_manager import FeedbackManager
20
20
  from tinybird.tb_cli_modules.branch import warn_if_in_live
21
21
  from tinybird.tb_cli_modules.cli import cli
@@ -34,9 +34,12 @@ from tinybird.tb_cli_modules.common import (
34
34
  validate_kafka_auto_offset_reset,
35
35
  validate_kafka_group,
36
36
  validate_kafka_topic,
37
+ wait_job,
37
38
  )
38
39
  from tinybird.tb_cli_modules.config import CLIConfig
39
- from tinybird.tb_cli_modules.exceptions import CLIDatasourceException
40
+ from tinybird.tb_cli_modules.exceptions import CLIDatasourceException, CLIException
41
+
42
+ EXPERIMENTAL_FEATURE_USE_V1 = "use_v1"
40
43
 
41
44
 
42
45
  @cli.group()
@@ -45,6 +48,34 @@ def datasource(ctx):
45
48
  """Data Sources commands"""
46
49
 
47
50
 
51
+ def _echo_v1_import_jobs_queued(job_ids: list[str], operation: str) -> None:
52
+ for job_id in job_ids:
53
+ click.echo(FeedbackManager.success_import_job_queued(operation=operation, job_id=job_id))
54
+ click.echo(FeedbackManager.info_import_job_status(job_id=job_id))
55
+
56
+
57
+ async def _wait_for_v1_import_jobs(client: TinyB, job_ids: list[str], operation: str) -> None:
58
+ for job_id in job_ids:
59
+ try:
60
+ await wait_job(client, job_id, f"/v0/jobs/{job_id}", f"{operation} import")
61
+ except CLIException:
62
+ if await _echo_v1_import_job_details(client, job_id):
63
+ raise click.exceptions.Exit(1)
64
+ raise
65
+ click.echo(FeedbackManager.success_import_job_completed(operation=operation, job_id=job_id))
66
+
67
+
68
+ async def _echo_v1_import_job_details(client: TinyB, job_id: str) -> bool:
69
+ try:
70
+ job = await client.job(job_id)
71
+ except Exception:
72
+ return False
73
+ click.echo(FeedbackManager.info_job(job=job_id))
74
+ echo_safe_humanfriendly_tables_format_smart_table([job.values()], column_names=job.keys())
75
+ click.echo("\n")
76
+ return True
77
+
78
+
48
79
  @datasource.command(name="ls")
49
80
  @click.option("--match", default=None, help="Retrieve any resources matching the pattern. eg --match _test")
50
81
  @click.option(
@@ -135,6 +166,13 @@ async def datasource_ls(ctx: Context, match: Optional[str], format_: str):
135
166
  hidden=True,
136
167
  )
137
168
  @click.option("--concurrency", help="How many files to submit concurrently", default=1, hidden=True)
169
+ @click.option("--wait", is_flag=True, default=False, help="Wait for a v1 import job to finish.")
170
+ @click.option(
171
+ "--experimental",
172
+ type=click.Choice([EXPERIMENTAL_FEATURE_USE_V1]),
173
+ multiple=True,
174
+ help="Enable an experimental feature. May be specified multiple times.",
175
+ )
138
176
  @click.pass_context
139
177
  @coro
140
178
  async def datasource_append(
@@ -145,6 +183,8 @@ async def datasource_append(
145
183
  incremental: Optional[str],
146
184
  ignore_empty: bool,
147
185
  concurrency: int,
186
+ experimental: tuple[str, ...],
187
+ wait: bool,
148
188
  ):
149
189
  """
150
190
  Appends data to an existing Data Source from URL, local file or a connector
@@ -157,7 +197,14 @@ async def datasource_append(
157
197
 
158
198
  if not url:
159
199
  raise CLIDatasourceException(FeedbackManager.error_missing_url(datasource=datasource_name))
160
- await push_data(ctx, datasource_name, url, mode="append", concurrency=concurrency)
200
+ use_v1 = EXPERIMENTAL_FEATURE_USE_V1 in experimental
201
+ if wait and not use_v1:
202
+ raise CLIDatasourceException("--wait requires --experimental=use_v1.")
203
+ job_ids = await push_data(ctx, datasource_name, url, mode="append", concurrency=concurrency, use_v1=use_v1)
204
+ if use_v1:
205
+ _echo_v1_import_jobs_queued(job_ids or [], "Append")
206
+ if wait:
207
+ await _wait_for_v1_import_jobs(ctx.obj["client"], job_ids or [], "Append")
161
208
 
162
209
 
163
210
  @datasource.command(name="replace")
@@ -165,6 +212,13 @@ async def datasource_append(
165
212
  @click.argument("url", nargs=-1)
166
213
  @click.option("--sql-condition", default=None, help="SQL WHERE condition to replace data", hidden=True)
167
214
  @click.option("--skip-incompatible-partition-key", is_flag=True, default=False, hidden=True)
215
+ @click.option("--wait", is_flag=True, default=False, help="Wait for a v1 import job to finish.")
216
+ @click.option(
217
+ "--experimental",
218
+ type=click.Choice([EXPERIMENTAL_FEATURE_USE_V1]),
219
+ multiple=True,
220
+ help="Enable an experimental feature. May be specified multiple times.",
221
+ )
168
222
  @click.pass_context
169
223
  @coro
170
224
  async def datasource_replace(
@@ -173,6 +227,8 @@ async def datasource_replace(
173
227
  url,
174
228
  sql_condition,
175
229
  skip_incompatible_partition_key,
230
+ experimental: tuple[str, ...],
231
+ wait: bool,
176
232
  ):
177
233
  """
178
234
  Replaces the data in a data source from a URL, local file or a connector
@@ -189,14 +245,22 @@ async def datasource_replace(
189
245
  replace_options = set()
190
246
  if skip_incompatible_partition_key:
191
247
  replace_options.add("skip_incompatible_partition_key")
192
- await push_data(
248
+ use_v1 = EXPERIMENTAL_FEATURE_USE_V1 in experimental
249
+ if wait and not use_v1:
250
+ raise CLIDatasourceException("--wait requires --experimental=use_v1.")
251
+ job_ids = await push_data(
193
252
  ctx,
194
253
  datasource_name,
195
254
  url,
196
255
  mode="replace",
197
256
  sql_condition=sql_condition,
198
257
  replace_options=replace_options,
258
+ use_v1=use_v1,
199
259
  )
260
+ if use_v1:
261
+ _echo_v1_import_jobs_queued(job_ids or [], "Replace")
262
+ if wait:
263
+ await _wait_for_v1_import_jobs(ctx.obj["client"], job_ids or [], "Replace")
200
264
 
201
265
 
202
266
  @datasource.command(name="analyze")
@@ -363,23 +427,70 @@ async def datasource_truncate(ctx, datasource_name, yes, cascade):
363
427
  @click.argument("datasource_name")
364
428
  @click.option("--sql-condition", default=None, help="SQL WHERE condition to remove rows", hidden=True, required=True)
365
429
  @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")
430
+ @click.option(
431
+ "--wait/--no-wait",
432
+ default=None,
433
+ help="Wait for the delete to finish. Defaults to true with --lightweight-delete (sync request), "
434
+ "false otherwise (returns a job id).",
435
+ )
367
436
  @click.option("--dry-run", is_flag=True, default=False, help="Run the command without deleting anything")
437
+ @click.option(
438
+ "--lightweight-delete",
439
+ "lightweight",
440
+ is_flag=True,
441
+ default=False,
442
+ help="Use ClickHouse lightweight DELETE. Defaults to waiting inline and returning rows_affected; "
443
+ "pass --no-wait to enqueue a job instead. Not compatible with --dry-run.",
444
+ )
445
+ @click.option(
446
+ "--partition",
447
+ default=None,
448
+ help="Restrict the lightweight delete to a single partition expression. Only valid with --lightweight-delete.",
449
+ )
450
+ @click.option(
451
+ "--projection-mode",
452
+ type=click.Choice(["throw", "drop", "rebuild"]),
453
+ default=None,
454
+ help="How ClickHouse should handle table projections when running the lightweight DELETE. "
455
+ "throw: fail the DELETE if the table has any projection defined (ClickHouse default). "
456
+ "drop: drop the affected projections so the DELETE can proceed; they will need to be recreated. "
457
+ "rebuild: rebuild the affected projections after the DELETE finishes. "
458
+ "Only valid with --lightweight-delete.",
459
+ )
368
460
  @click.pass_context
369
461
  @coro
370
- async def datasource_delete_rows(ctx, datasource_name, sql_condition, yes, wait, dry_run):
462
+ async def datasource_delete_rows(
463
+ ctx, datasource_name, sql_condition, yes, wait, dry_run, lightweight, partition, projection_mode
464
+ ):
371
465
  """
372
466
  Delete rows from a datasource
373
467
 
374
468
  - Delete rows with SQL condition: `tb datasource delete [datasource_name] --sql-condition "country='ES'"`
375
469
 
376
470
  - Delete rows with SQL condition and wait for the job to finish: `tb datasource delete [datasource_name] --sql-condition "country='ES'" --wait`
471
+
472
+ - Use ClickHouse lightweight DELETE (synchronous, no job): `tb datasource delete [datasource_name] --sql-condition "country='ES'" --lightweight-delete`
473
+
474
+ - 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
475
  """
378
476
 
379
477
  semver: str = ctx.ensure_object(dict)["config"]["semver"]
380
478
  await warn_if_in_live(semver)
381
479
 
382
480
  client: TinyB = ctx.ensure_object(dict)["client"]
481
+ if lightweight and dry_run:
482
+ raise CLIDatasourceException(
483
+ FeedbackManager.error_exception(error="--lightweight-delete is not compatible with --dry-run")
484
+ )
485
+ if (partition or projection_mode) and not lightweight:
486
+ raise CLIDatasourceException(
487
+ FeedbackManager.error_exception(error="--partition and --projection-mode require --lightweight-delete")
488
+ )
489
+ # Lightweight delete is sync by default (the endpoint blocks and returns
490
+ # rows_affected); the classic /v0/ delete is async by default (returns a
491
+ # job id). The tri-state --wait/--no-wait lets users override either.
492
+ if wait is None:
493
+ wait = lightweight
383
494
  if (
384
495
  dry_run
385
496
  or yes
@@ -390,7 +501,15 @@ async def datasource_delete_rows(ctx, datasource_name, sql_condition, yes, wait,
390
501
  )
391
502
  ):
392
503
  try:
393
- res = await client.datasource_delete_rows(datasource_name, sql_condition, dry_run)
504
+ res = await client.datasource_delete_rows(
505
+ datasource_name,
506
+ sql_condition,
507
+ dry_run,
508
+ lightweight=lightweight,
509
+ wait=wait,
510
+ partition=partition,
511
+ projection_mode=projection_mode,
512
+ )
394
513
  if dry_run:
395
514
  click.echo(
396
515
  FeedbackManager.success_dry_run_delete_rows_datasource(
@@ -398,10 +517,24 @@ async def datasource_delete_rows(ctx, datasource_name, sql_condition, yes, wait,
398
517
  )
399
518
  )
400
519
  return
520
+ # Lightweight sync path returns rows_affected directly, no job involved.
521
+ if lightweight and wait:
522
+ mutation = res.get("mutation") or {}
523
+ click.echo(
524
+ FeedbackManager.success_lightweight_delete_rows_datasource(
525
+ datasource=datasource_name,
526
+ delete_condition=sql_condition,
527
+ rows_affected=res.get("rows_affected", 0),
528
+ partitions_scanned=mutation.get("partitions_scanned", 0),
529
+ partitions_done=mutation.get("partitions_done", 0),
530
+ partitions_in_progress=mutation.get("partitions_in_progress", 0),
531
+ )
532
+ )
533
+ return
401
534
  job_id = res["job_id"]
402
535
  job_url = res["job_url"]
403
536
  click.echo(FeedbackManager.info_datasource_delete_rows_job_url(url=job_url))
404
- if wait:
537
+ if wait and not lightweight:
405
538
  progress_symbols = ["-", "\\", "|", "/"]
406
539
  progress_str = "Waiting for the job to finish"
407
540
  # TODO: Use click.echo instead of print and see if the behavior is the same