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/__cli__.py +2 -2
- tinybird/ch_utils/engine.py +6 -25
- tinybird/client.py +53 -13
- tinybird/config.py +0 -6
- tinybird/datafile_common.py +50 -59
- tinybird/datatypes.py +0 -4
- tinybird/feedback_manager.py +8 -0
- tinybird/sql.py +0 -24
- tinybird/sql_template.py +92 -45
- tinybird/sql_toolset.py +28 -6
- tinybird/tb_cli_modules/branch.py +0 -1
- tinybird/tb_cli_modules/common.py +15 -17
- tinybird/tb_cli_modules/config.py +1 -15
- tinybird/tb_cli_modules/datasource.py +141 -8
- tinybird/tb_cli_modules/tinyunit/tinyunit.py +0 -9
- tinybird/tb_cli_modules/workspace.py +0 -1
- tinybird/tornado_template.py +5 -5
- {tinybird_cli-6.5.3.dev0.dist-info → tinybird_cli-6.5.5.dev0.dist-info}/METADATA +15 -1
- {tinybird_cli-6.5.3.dev0.dist-info → tinybird_cli-6.5.5.dev0.dist-info}/RECORD +22 -22
- {tinybird_cli-6.5.3.dev0.dist-info → tinybird_cli-6.5.5.dev0.dist-info}/WHEEL +0 -0
- {tinybird_cli-6.5.3.dev0.dist-info → tinybird_cli-6.5.5.dev0.dist-info}/entry_points.txt +0 -0
- {tinybird_cli-6.5.3.dev0.dist-info → tinybird_cli-6.5.5.dev0.dist-info}/top_level.txt +0 -0
tinybird/sql_template.py
CHANGED
|
@@ -1396,7 +1396,7 @@ _namespace = {
|
|
|
1396
1396
|
}
|
|
1397
1397
|
|
|
1398
1398
|
|
|
1399
|
-
reserved_vars =
|
|
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
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
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
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|