tinybird-cli 6.5.7.dev0__py3-none-any.whl → 6.5.8.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.
@@ -70,6 +70,7 @@ from tinybird.tb_cli_modules.common import (
70
70
  )
71
71
  from tinybird.tb_cli_modules.config import CLIConfig
72
72
  from tinybird.tb_cli_modules.exceptions import CLIGitReleaseException, CLIPipeException
73
+ from tinybird.utils.bools import parse_bool, parse_optional_bool
73
74
 
74
75
  from .ch_utils.engine import ENABLED_ENGINES
75
76
  from .client import AuthException, AuthNoTokenException, CanNotBeDeletedException, DoesNotExistException, TinyB
@@ -130,6 +131,7 @@ class CopyParameters:
130
131
  TARGET_DATASOURCE = "target_datasource"
131
132
  COPY_SCHEDULE = "copy_schedule"
132
133
  COPY_MODE = "copy_mode"
134
+ ON_DEMAND_COMPUTE = "on_demand_compute"
133
135
 
134
136
 
135
137
  class CopyModes:
@@ -424,8 +426,7 @@ class CLIGitRelease:
424
426
  def is_dirty_to_release(self, use_include_dir: bool = True) -> bool:
425
427
  if use_include_dir:
426
428
  return any(self.repo.is_dirty(path=p) for p in self.paths) or self.has_untracked_files()
427
- else:
428
- return self.repo.is_dirty(path=self.path) or self.has_untracked_files()
429
+ return self.repo.is_dirty(path=self.path) or self.has_untracked_files()
429
430
 
430
431
  def is_dirty_to_init(self) -> bool:
431
432
  return self.is_dirty_to_release(use_include_dir=False) or self.is_head_empty()
@@ -474,8 +475,7 @@ class CLIGitRelease:
474
475
  if not self.is_head_outdated(current_release["commit"]):
475
476
  if check_outdated:
476
477
  raise CLIGitReleaseException(FeedbackManager.error_head_outdated(commit=self.head_commit()))
477
- else:
478
- click.echo(FeedbackManager.warning_head_outdated(commit=self.head_commit()))
478
+ click.echo(FeedbackManager.warning_head_outdated(commit=self.head_commit()))
479
479
  except CLIGitReleaseException as e:
480
480
  if check_outdated:
481
481
  raise e
@@ -533,8 +533,7 @@ class CLIGitRelease:
533
533
  async def update_release(
534
534
  self, tb_client: TinyB, current_ws: Dict[str, Any], commit: Optional[str] = None
535
535
  ) -> Dict[str, Any]:
536
- release = await tb_client.workspace_commit_update(current_ws["id"], commit or self.head().commit.hexsha)
537
- return release
536
+ return await tb_client.workspace_commit_update(current_ws["id"], commit or self.head().commit.hexsha)
538
537
 
539
538
 
540
539
  class Deployment:
@@ -851,6 +850,11 @@ def parse_pipe(
851
850
  sql = ""
852
851
  doc = parse(s, basepath=basepath, replace_includes=replace_includes, skip_eval=skip_eval)
853
852
  for node in doc.nodes:
853
+ if node.get("type", "").lower() == PipeNodeTypes.COPY and CopyParameters.ON_DEMAND_COMPUTE in node:
854
+ try:
855
+ node[CopyParameters.ON_DEMAND_COMPUTE] = parse_optional_bool(node[CopyParameters.ON_DEMAND_COMPUTE])
856
+ except ValueError:
857
+ raise click.ClickException("ON_DEMAND_COMPUTE must be true or false")
854
858
  sql = node.get("sql", "")
855
859
  source_sql = sql.strip()
856
860
  if not source_sql:
@@ -1229,6 +1233,7 @@ def parse(
1229
1233
  "copy_schedule": assign_node_var(CopyParameters.COPY_SCHEDULE),
1230
1234
  "copy_mode": assign_node_var("mode"),
1231
1235
  "mode": assign_node_var("mode"),
1236
+ "on_demand_compute": assign_node_var(CopyParameters.ON_DEMAND_COMPUTE),
1232
1237
  "resource": assign_node_var("resource"),
1233
1238
  "filter": assign_node_var("filter"),
1234
1239
  "token": add_token,
@@ -1345,12 +1350,11 @@ def parse(
1345
1350
  except IndexError as e:
1346
1351
  if "node" in line.lower():
1347
1352
  raise click.ClickException(FeedbackManager.error_missing_node_name())
1348
- elif "sql" in line.lower():
1353
+ if "sql" in line.lower():
1349
1354
  raise click.ClickException(FeedbackManager.error_missing_sql_command())
1350
- elif "datasource" in line.lower():
1355
+ if "datasource" in line.lower():
1351
1356
  raise click.ClickException(FeedbackManager.error_missing_datasource_name())
1352
- else:
1353
- raise ValidationException(f"Validation error, found {line} in line {str(lineno)}: {str(e)}", lineno=lineno)
1357
+ raise ValidationException(f"Validation error, found {line} in line {str(lineno)}: {str(e)}", lineno=lineno)
1354
1358
  except IncludeFileNotFoundException as e:
1355
1359
  raise IncludeFileNotFoundException(str(e), lineno=lineno)
1356
1360
  except Exception as e:
@@ -1671,7 +1675,7 @@ async def process_file(
1671
1675
 
1672
1676
  return resources
1673
1677
 
1674
- elif DataFileExtensions.PIPE in filename:
1678
+ if DataFileExtensions.PIPE in filename:
1675
1679
  doc = parse_pipe(filename)
1676
1680
  version = f"__v{doc.version}" if doc.version is not None else ""
1677
1681
  name = os.path.basename(filename).split(".")[0]
@@ -1691,6 +1695,7 @@ async def process_file(
1691
1695
  "target_datasource": node.get("target_datasource", None),
1692
1696
  "copy_schedule": node.get(CopyParameters.COPY_SCHEDULE, None),
1693
1697
  "mode": node.get("mode", CopyModes.APPEND),
1698
+ "on_demand_compute": node.get(CopyParameters.ON_DEMAND_COMPUTE, None),
1694
1699
  }
1695
1700
 
1696
1701
  is_export_node = ExportReplacements.is_export_node(node)
@@ -1777,7 +1782,7 @@ async def process_file(
1777
1782
  "filtering_tags": doc.filtering_tags,
1778
1783
  }
1779
1784
  ]
1780
- elif DataFileExtensions.TOKEN in filename:
1785
+ if DataFileExtensions.TOKEN in filename:
1781
1786
  doc = parse_token(filename)
1782
1787
  name = os.path.basename(filename).split(DataFileExtensions.TOKEN)[0]
1783
1788
  description = doc.description or ""
@@ -1794,8 +1799,7 @@ async def process_file(
1794
1799
  "description": description,
1795
1800
  }
1796
1801
  ]
1797
- else:
1798
- raise click.ClickException(FeedbackManager.error_file_extension(filename=filename))
1802
+ raise click.ClickException(FeedbackManager.error_file_extension(filename=filename))
1799
1803
 
1800
1804
 
1801
1805
  def full_path_by_name(
@@ -1831,6 +1835,21 @@ def full_path_by_name(
1831
1835
  return None
1832
1836
 
1833
1837
 
1838
+ def local_copy_node_uses_on_demand_compute(folder: str, pipe_name: str) -> Optional[bool]:
1839
+ """Reads ON_DEMAND_COMPUTE from the local datafile. Returns None when there is no parseable copy pipe to compare"""
1840
+ pipe_path = next((f for f in get_project_filenames(folder) if f.endswith(f"/{pipe_name}.pipe")), None)
1841
+ if pipe_path is None:
1842
+ return None
1843
+ try:
1844
+ datafile = parse_pipe(pipe_path)
1845
+ except Exception:
1846
+ return None
1847
+ copy_node = next((node for node in datafile.nodes if node.get("type", "").lower() == PipeNodeTypes.COPY), None)
1848
+ if copy_node is None:
1849
+ return None
1850
+ return parse_optional_bool(copy_node.get(CopyParameters.ON_DEMAND_COMPUTE))
1851
+
1852
+
1834
1853
  def find_file_by_name(
1835
1854
  folder: str,
1836
1855
  name: str,
@@ -2045,13 +2064,12 @@ class PipeChecker(unittest.TestCase):
2045
2064
  headers = {"Authorization": f"Bearer {self.token}"}
2046
2065
  if self.http_method == "GET":
2047
2066
  return requests.get(pipe_url, headers=headers, verify=not getenv_bool("TB_DISABLE_SSL_CHECKS", False))
2048
- else:
2049
- return requests.post(
2050
- pipe_url,
2051
- headers=headers,
2052
- verify=not getenv_bool("TB_DISABLE_SSL_CHECKS", False),
2053
- data=self.pipe_request_params,
2054
- )
2067
+ return requests.post(
2068
+ pipe_url,
2069
+ headers=headers,
2070
+ verify=not getenv_bool("TB_DISABLE_SSL_CHECKS", False),
2071
+ data=self.pipe_request_params,
2072
+ )
2055
2073
 
2056
2074
  def _write_performance(self):
2057
2075
  return ""
@@ -2706,6 +2724,13 @@ async def check_copy_pipe(pipe, copy_node, tb_client: TinyB):
2706
2724
  if not is_valid_mode:
2707
2725
  raise CLIPipeException(FeedbackManager.error_creating_copy_pipe_invalid_mode(mode=mode))
2708
2726
 
2727
+ on_demand_compute = copy_node["params"].get(CopyParameters.ON_DEMAND_COMPUTE)
2728
+ if on_demand_compute not in (None, ""):
2729
+ try:
2730
+ parse_bool(on_demand_compute)
2731
+ except ValueError:
2732
+ raise CLIPipeException("ON_DEMAND_COMPUTE must be true or false")
2733
+
2709
2734
  if not pipe:
2710
2735
  return
2711
2736
 
@@ -3150,6 +3175,7 @@ async def new_pipe(
3150
3175
  schedule_cron_should_be_removed = current_schedule_cron and not schedule_cron
3151
3176
  copy_params["schedule_cron"] = "None" if schedule_cron_should_be_removed else schedule_cron
3152
3177
  copy_params["mode"] = mode
3178
+ copy_params["on_demand_compute"] = parse_optional_bool(copy_node.get(CopyParameters.ON_DEMAND_COMPUTE))
3153
3179
  await tb_client.pipe_update_copy(**copy_params)
3154
3180
  except Exception as e:
3155
3181
  raise Exception(
@@ -3798,12 +3824,11 @@ def get_name_version(ds: str) -> Dict[str, Any]:
3798
3824
  tk = ds.rsplit("__", 2)
3799
3825
  if len(tk) == 1:
3800
3826
  return {"name": tk[0], "version": None}
3801
- elif len(tk) == 2:
3827
+ if len(tk) == 2:
3802
3828
  if len(tk[1]):
3803
3829
  if tk[1][0] == "v" and _PATTERN_VERSION_NUMBER.match(tk[1][1:]):
3804
3830
  return {"name": tk[0], "version": int(tk[1][1:])}
3805
- else:
3806
- return {"name": tk[0] + "__" + tk[1], "version": None}
3831
+ return {"name": tk[0] + "__" + tk[1], "version": None}
3807
3832
  elif len(tk) == 3 and len(tk[2]):
3808
3833
  if tk[2] == "checker":
3809
3834
  return {"name": tk[0] + "__" + tk[1] + "__" + tk[2], "version": None}
@@ -4667,7 +4692,7 @@ async def folder_push(
4667
4692
  fork,
4668
4693
  )
4669
4694
  else:
4670
- # If we are trying to modify a Kafka or CDK datasource, we need to inform the user that the resource needs to be post-released
4695
+ # Connected datasources require post-release changes to avoid modifying the live connection.
4671
4696
  kafka_connection_name = (
4672
4697
  resources_to_run_fork_downstream[name].get("params", {}).get("kafka_connection_name")
4673
4698
  )
@@ -5007,8 +5032,7 @@ async def format_datasource(
5007
5032
  format_import_settings(file_parts, doc.nodes[0])
5008
5033
  format_shared_with(file_parts, doc)
5009
5034
  result = "".join(file_parts)
5010
- result = result.rstrip("\n") + "\n"
5011
- return result
5035
+ return result.rstrip("\n") + "\n"
5012
5036
 
5013
5037
 
5014
5038
  def format_version(file_parts: List[str], doc: Datafile) -> List[str]:
@@ -5120,17 +5144,16 @@ async def format_engine(
5120
5144
  file_parts.append(DATAFILE_NEW_LINE)
5121
5145
  file_parts.append(DATAFILE_NEW_LINE)
5122
5146
  return file_parts
5123
- else:
5124
- if node.get("engine", None):
5125
- empty = '""'
5126
- file_parts.append(f"ENGINE {node['engine']['type']}" if node.get("engine", {}).get("type") else empty)
5127
- file_parts.append(DATAFILE_NEW_LINE)
5128
- for arg in sorted(node["engine"].get("args", [])):
5129
- elem = ", ".join([x.strip() for x in arg[1].split(",")])
5130
- file_parts.append(f"ENGINE_{arg[0].upper()} {elem or empty}")
5131
- file_parts.append(DATAFILE_NEW_LINE)
5147
+ if node.get("engine", None):
5148
+ empty = '""'
5149
+ file_parts.append(f"ENGINE {node['engine']['type']}" if node.get("engine", {}).get("type") else empty)
5150
+ file_parts.append(DATAFILE_NEW_LINE)
5151
+ for arg in sorted(node["engine"].get("args", [])):
5152
+ elem = ", ".join([x.strip() for x in arg[1].split(",")])
5153
+ file_parts.append(f"ENGINE_{arg[0].upper()} {elem or empty}")
5132
5154
  file_parts.append(DATAFILE_NEW_LINE)
5133
- return file_parts
5155
+ file_parts.append(DATAFILE_NEW_LINE)
5156
+ return file_parts
5134
5157
 
5135
5158
 
5136
5159
  async def format_node_type(file_parts: List[str], node: Dict[str, Any]) -> List[str]:
@@ -5162,6 +5185,9 @@ async def format_node_type(file_parts: List[str], node: Dict[str, Any]) -> List[
5162
5185
  else:
5163
5186
  file_parts.append(DATAFILE_NEW_LINE)
5164
5187
  file_parts.append(f"{CopyParameters.COPY_SCHEDULE.upper()} {ON_DEMAND}")
5188
+ if parse_optional_bool(node.get(CopyParameters.ON_DEMAND_COMPUTE)):
5189
+ file_parts.append(DATAFILE_NEW_LINE)
5190
+ file_parts.append("ON_DEMAND_COMPUTE true")
5165
5191
  file_parts.append(DATAFILE_NEW_LINE)
5166
5192
 
5167
5193
  # Sink or Stream pipe
@@ -5296,8 +5322,7 @@ async def format_pipe(
5296
5322
  file_parts.append(DATAFILE_NEW_LINE)
5297
5323
 
5298
5324
  result = "".join(file_parts)
5299
- result = result.rstrip("\n") + "\n"
5300
- return result
5325
+ return result.rstrip("\n") + "\n"
5301
5326
 
5302
5327
 
5303
5328
  def format_sql(
@@ -5632,6 +5657,7 @@ async def diff_files(
5632
5657
  click.echo(f"Unsupported file type: {filename}")
5633
5658
  if lines:
5634
5659
  return [f"{l}\n" for l in lines.split("\n")] if with_format else lines # noqa: E741
5660
+ return None
5635
5661
 
5636
5662
  try:
5637
5663
  lines1 = await parse(from_file, with_format, resource_source="remote")
@@ -5641,7 +5667,7 @@ async def diff_files(
5641
5667
  raise click.ClickException(FeedbackManager.error_diff_file(filename=filename))
5642
5668
 
5643
5669
  if not lines1 or not lines2:
5644
- return
5670
+ return None
5645
5671
 
5646
5672
  diff = difflib.unified_diff(
5647
5673
  lines1, lines2, fromfile=f"{Path(from_file).name} {from_file_suffix}", tofile=f"{to_file} {to_file_suffix}"
@@ -5661,10 +5687,7 @@ def is_materialized(resource: Optional[Dict[str, Any]]) -> bool:
5661
5687
  if not resource:
5662
5688
  return False
5663
5689
 
5664
- is_materialized = any(
5665
- node.get("params", {}).get("type", None) == "materialized" for node in resource.get("nodes", []) or []
5666
- )
5667
- return is_materialized
5690
+ return any(node.get("params", {}).get("type", None) == "materialized" for node in resource.get("nodes", []) or [])
5668
5691
 
5669
5692
 
5670
5693
  def is_endpoint_with_no_dependencies(
tinybird/datatypes.py CHANGED
@@ -120,52 +120,74 @@ def datetime_test(x: str) -> bool:
120
120
  return any(p.match(x) for p in datetime_patterns)
121
121
 
122
122
 
123
+ def bounded_integer(x: str, pattern: re.Pattern[str]) -> Optional[int]:
124
+ if pattern.match(x) is None:
125
+ return None
126
+ digits = x.lstrip("+-").lstrip("0")
127
+ # 256-bit unsigned integers have at most 78 decimal digits.
128
+ if len(digits) > 78:
129
+ return None
130
+ return int(x) if len(x) <= 78 else int(digits or "0") * (-1 if x.startswith("-") else 1)
131
+
132
+
123
133
  def int_8_test(x: str) -> bool:
124
- return intx_re.match(x) is not None and -int_8_max <= int(x) < int_8_max
134
+ value = bounded_integer(x, intx_re)
135
+ return value is not None and -int_8_max <= value < int_8_max
125
136
 
126
137
 
127
138
  def int16_test(x: str) -> bool:
128
- return intx_re.match(x) is not None and -int16_max <= int(x) < int16_max
139
+ value = bounded_integer(x, intx_re)
140
+ return value is not None and -int16_max <= value < int16_max
129
141
 
130
142
 
131
143
  def int32_test(x: str) -> bool:
132
- return intx_re.match(x) is not None and -int32_max <= int(x) < int32_max
144
+ value = bounded_integer(x, intx_re)
145
+ return value is not None and -int32_max <= value < int32_max
133
146
 
134
147
 
135
148
  def int64_test(x: str) -> bool:
136
- return intx_re.match(x) is not None and -int64_max <= int(x) < int64_max
149
+ value = bounded_integer(x, intx_re)
150
+ return value is not None and -int64_max <= value < int64_max
137
151
 
138
152
 
139
153
  def int128_test(x: str) -> bool:
140
- return intx_re.match(x) is not None and -int128_max <= int(x) < int128_max
154
+ value = bounded_integer(x, intx_re)
155
+ return value is not None and -int128_max <= value < int128_max
141
156
 
142
157
 
143
158
  def int256_test(x: str) -> bool:
144
- return intx_re.match(x) is not None and -int256_max <= int(x) < int256_max
159
+ value = bounded_integer(x, intx_re)
160
+ return value is not None and -int256_max <= value < int256_max
145
161
 
146
162
 
147
163
  def uint_8_test(x: str) -> bool:
148
- return uintx_re.match(x) is not None and 0 <= int(x) < uint_8_max
164
+ value = bounded_integer(x, uintx_re)
165
+ return value is not None and 0 <= value < uint_8_max
149
166
 
150
167
 
151
168
  def uint16_test(x: str) -> bool:
152
- return uintx_re.match(x) is not None and 0 <= int(x) < uint16_max
169
+ value = bounded_integer(x, uintx_re)
170
+ return value is not None and 0 <= value < uint16_max
153
171
 
154
172
 
155
173
  def uint32_test(x: str) -> bool:
156
- return uintx_re.match(x) is not None and 0 <= int(x) < uint32_max
174
+ value = bounded_integer(x, uintx_re)
175
+ return value is not None and 0 <= value < uint32_max
157
176
 
158
177
 
159
178
  def uint64_test(x: str) -> bool:
160
- return uintx_re.match(x) is not None and 0 <= int(x) < uint64_max
179
+ value = bounded_integer(x, uintx_re)
180
+ return value is not None and 0 <= value < uint64_max
161
181
 
162
182
 
163
183
  def uint128_test(x: str) -> bool:
164
- return intx_re.match(x) is not None and 0 <= int(x) < uint128_max
184
+ value = bounded_integer(x, intx_re)
185
+ return value is not None and 0 <= value < uint128_max
165
186
 
166
187
 
167
188
  def uint256_test(x: str) -> bool:
168
- return intx_re.match(x) is not None and 0 <= int(x) < uint256_max
189
+ value = bounded_integer(x, intx_re)
190
+ return value is not None and 0 <= value < uint256_max
169
191
 
170
192
 
171
193
  def float_test(x: str) -> bool:
@@ -572,6 +572,11 @@ class FeedbackManager:
572
572
  "You can read more about the pricing at https://www.tinybird.co/docs/classic/work-with-data/process-and-copy/materialized-views#compute-compute-separation-for-populates\n\n"
573
573
  "Do you want to proceed with on-demand compute?"
574
574
  )
575
+ warning_confirm_on_demand_compute_differs_from_datafile = warning_message(
576
+ "The local datafile of pipe '{pipe}' does not set ON_DEMAND_COMPUTE true, but --on-demand-compute was passed.\n"
577
+ "On-demand compute uses dedicated resources that are billed separately from your regular usage.\n\n"
578
+ "Do you want to proceed?"
579
+ )
575
580
 
576
581
  warning_confirm_delete_token = prompt_message("Do you want to delete Token {token}?")
577
582
  warning_confirm_refresh_token = prompt_message("Do you want to refresh Token {token}?")
tinybird/git_settings.py CHANGED
@@ -137,9 +137,7 @@ def bump_version(version: str, next_version: Optional[str]) -> str:
137
137
 
138
138
 
139
139
  def get_default_init_files_deploy(workspace_name: str) -> Dict[str, str]:
140
- modified_files = {
140
+ return {
141
141
  path.replace("tinybird_", f"tinybird_{workspace_name.lower()}_"): content
142
142
  for path, content in DEFAULT_INIT_FILES_DEPLOY.items()
143
143
  }
144
-
145
- return modified_files
tinybird/sql.py CHANGED
@@ -129,15 +129,9 @@ class TableProjection:
129
129
  def add_projection_sql(self):
130
130
  return f"ADD {self.to_sql()}"
131
131
 
132
- def drop_projection_sql(self):
133
- return f"DROP PROJECTION IF EXISTS {self.name}"
134
-
135
132
  def materialize_projection_sql(self):
136
133
  return f"MATERIALIZE PROJECTION IF EXISTS {self.name}"
137
134
 
138
- def clear_projection_sql(self):
139
- return f"CLEAR PROJECTION IF EXISTS {self.name}"
140
-
141
135
 
142
136
  def as_subquery(sql: str) -> str:
143
137
  return f"""(\n{sql}\n)"""
@@ -675,10 +669,9 @@ def _parse_table_structure(schema: str) -> List[Dict[str, Any]]:
675
669
  )
676
670
  advance_single_char()
677
671
  return schema[begin:i]
678
- else:
679
- # backticked name
680
- advance_single_char()
681
- return get_backticked()
672
+ # backticked name
673
+ advance_single_char()
674
+ return get_backticked()
682
675
 
683
676
  def parse_expr(lookup: Iterable[SyntaxExpr]) -> str:
684
677
  nonlocal i, line, pos