tinybird 0.0.1.dev291__py3-none-any.whl → 1.0.5__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.
Files changed (76) hide show
  1. tinybird/ch_utils/constants.py +5 -0
  2. tinybird/connectors.py +1 -7
  3. tinybird/context.py +3 -3
  4. tinybird/datafile/common.py +10 -8
  5. tinybird/datafile/parse_pipe.py +2 -2
  6. tinybird/feedback_manager.py +3 -0
  7. tinybird/prompts.py +1 -0
  8. tinybird/service_datasources.py +223 -0
  9. tinybird/sql_template.py +26 -11
  10. tinybird/sql_template_fmt.py +14 -4
  11. tinybird/tb/__cli__.py +2 -2
  12. tinybird/tb/cli.py +1 -0
  13. tinybird/tb/client.py +104 -26
  14. tinybird/tb/config.py +24 -0
  15. tinybird/tb/modules/agent/agent.py +103 -67
  16. tinybird/tb/modules/agent/banner.py +15 -15
  17. tinybird/tb/modules/agent/explore_agent.py +5 -0
  18. tinybird/tb/modules/agent/mock_agent.py +5 -1
  19. tinybird/tb/modules/agent/models.py +6 -2
  20. tinybird/tb/modules/agent/prompts.py +49 -2
  21. tinybird/tb/modules/agent/tools/deploy.py +1 -1
  22. tinybird/tb/modules/agent/tools/execute_query.py +15 -18
  23. tinybird/tb/modules/agent/tools/request_endpoint.py +1 -1
  24. tinybird/tb/modules/agent/tools/run_command.py +9 -0
  25. tinybird/tb/modules/agent/utils.py +38 -48
  26. tinybird/tb/modules/branch.py +150 -0
  27. tinybird/tb/modules/build.py +58 -13
  28. tinybird/tb/modules/build_common.py +209 -25
  29. tinybird/tb/modules/cli.py +129 -16
  30. tinybird/tb/modules/common.py +172 -146
  31. tinybird/tb/modules/connection.py +125 -194
  32. tinybird/tb/modules/connection_kafka.py +382 -0
  33. tinybird/tb/modules/copy.py +3 -1
  34. tinybird/tb/modules/create.py +83 -150
  35. tinybird/tb/modules/datafile/build.py +27 -38
  36. tinybird/tb/modules/datafile/build_datasource.py +21 -25
  37. tinybird/tb/modules/datafile/diff.py +1 -1
  38. tinybird/tb/modules/datafile/format_pipe.py +46 -7
  39. tinybird/tb/modules/datafile/playground.py +59 -68
  40. tinybird/tb/modules/datafile/pull.py +2 -3
  41. tinybird/tb/modules/datasource.py +477 -308
  42. tinybird/tb/modules/deployment.py +2 -0
  43. tinybird/tb/modules/deployment_common.py +84 -44
  44. tinybird/tb/modules/deprecations.py +4 -4
  45. tinybird/tb/modules/dev_server.py +33 -12
  46. tinybird/tb/modules/exceptions.py +14 -0
  47. tinybird/tb/modules/feedback_manager.py +1 -1
  48. tinybird/tb/modules/info.py +69 -12
  49. tinybird/tb/modules/infra.py +4 -5
  50. tinybird/tb/modules/job_common.py +15 -0
  51. tinybird/tb/modules/local.py +143 -23
  52. tinybird/tb/modules/local_common.py +347 -19
  53. tinybird/tb/modules/local_logs.py +209 -0
  54. tinybird/tb/modules/login.py +21 -2
  55. tinybird/tb/modules/login_common.py +254 -12
  56. tinybird/tb/modules/mock.py +5 -54
  57. tinybird/tb/modules/mock_common.py +0 -54
  58. tinybird/tb/modules/open.py +10 -5
  59. tinybird/tb/modules/project.py +14 -5
  60. tinybird/tb/modules/shell.py +15 -7
  61. tinybird/tb/modules/sink.py +3 -1
  62. tinybird/tb/modules/telemetry.py +11 -3
  63. tinybird/tb/modules/test.py +13 -9
  64. tinybird/tb/modules/test_common.py +13 -87
  65. tinybird/tb/modules/tinyunit/tinyunit.py +0 -14
  66. tinybird/tb/modules/tinyunit/tinyunit_lib.py +0 -6
  67. tinybird/tb/modules/watch.py +5 -3
  68. tinybird/tb_cli_modules/common.py +2 -2
  69. tinybird/tb_cli_modules/telemetry.py +1 -1
  70. tinybird/tornado_template.py +6 -7
  71. {tinybird-0.0.1.dev291.dist-info → tinybird-1.0.5.dist-info}/METADATA +32 -6
  72. tinybird-1.0.5.dist-info/RECORD +132 -0
  73. {tinybird-0.0.1.dev291.dist-info → tinybird-1.0.5.dist-info}/WHEEL +1 -1
  74. tinybird-0.0.1.dev291.dist-info/RECORD +0 -128
  75. {tinybird-0.0.1.dev291.dist-info → tinybird-1.0.5.dist-info}/entry_points.txt +0 -0
  76. {tinybird-0.0.1.dev291.dist-info → tinybird-1.0.5.dist-info}/top_level.txt +0 -0
@@ -1,18 +1,22 @@
1
1
  import json
2
2
  import logging
3
+ import re
3
4
  import time
5
+ from copy import deepcopy
4
6
  from pathlib import Path
5
- from typing import Optional
7
+ from typing import Any, Optional
6
8
  from urllib.parse import urlencode, urljoin
7
9
 
8
10
  import click
9
11
  import requests
10
12
 
13
+ from tinybird.datafile.parse_datasource import parse_datasource
11
14
  from tinybird.tb.client import TinyB
12
15
  from tinybird.tb.modules.common import push_data, sys_exit
13
16
  from tinybird.tb.modules.datafile.fixture import FixtureExtension, get_fixture_dir, persist_fixture
14
17
  from tinybird.tb.modules.dev_server import BuildStatus
15
18
  from tinybird.tb.modules.feedback_manager import FeedbackManager
19
+ from tinybird.tb.modules.local_common import get_local_tokens
16
20
  from tinybird.tb.modules.project import Project
17
21
  from tinybird.tb.modules.shell import print_table_formatted
18
22
 
@@ -21,14 +25,26 @@ def process(
21
25
  project: Project,
22
26
  tb_client: TinyB,
23
27
  watch: bool,
28
+ config: dict[str, Any],
24
29
  file_changed: Optional[str] = None,
25
30
  diff: Optional[str] = None,
26
31
  silent: bool = False,
27
32
  build_status: Optional[BuildStatus] = None,
28
33
  exit_on_error: bool = True,
29
34
  load_fixtures: bool = True,
35
+ project_with_vendors: Optional[Project] = None,
36
+ is_branch: bool = False,
30
37
  ) -> Optional[str]:
31
38
  time_start = time.time()
39
+
40
+ # Build vendored workspaces before build
41
+ if not project_with_vendors and not is_branch:
42
+ build_vendored_workspaces(project=project, tb_client=tb_client, config=config)
43
+
44
+ # Ensure SHARED_WITH workspaces exist before build
45
+ if not is_branch:
46
+ build_shared_with_workspaces(project=project, tb_client=tb_client, config=config)
47
+
32
48
  build_failed = False
33
49
  build_error: Optional[str] = None
34
50
  build_result: Optional[bool] = None
@@ -37,7 +53,7 @@ def process(
37
53
  return build_status.error
38
54
  else:
39
55
  build_status.building = True
40
- if file_changed and (file_changed.endswith(FixtureExtension.NDJSON) or file_changed.endswith(FixtureExtension.CSV)):
56
+ if file_changed and file_changed.endswith((FixtureExtension.NDJSON, FixtureExtension.CSV)):
41
57
  rebuild_fixture(project, tb_client, file_changed)
42
58
  if build_status:
43
59
  build_status.building = False
@@ -47,13 +63,19 @@ def process(
47
63
  if build_status:
48
64
  build_status.building = False
49
65
  build_status.error = None
50
- elif file_changed and (file_changed.endswith(".env.local") or file_changed.endswith(".env")):
66
+ elif file_changed and file_changed.endswith((".env.local", ".env")):
51
67
  if build_status:
52
68
  build_status.building = False
53
69
  build_status.error = None
54
70
  else:
55
71
  try:
56
- build_result = build_project(project, tb_client, silent, load_fixtures)
72
+ build_result = build_project(
73
+ project,
74
+ tb_client,
75
+ silent,
76
+ load_fixtures,
77
+ project_with_vendors=project_with_vendors,
78
+ )
57
79
  if build_status:
58
80
  build_status.building = False
59
81
  build_status.error = None
@@ -82,12 +104,12 @@ def process(
82
104
  build_status.error = build_error
83
105
  build_status.building = False
84
106
  return build_error
85
- else:
86
- if not silent:
87
- if build_result == False: # noqa: E712
88
- click.echo(FeedbackManager.info(message="No changes. Build skipped."))
89
- else:
90
- click.echo(FeedbackManager.success(message=f"\n✓ {rebuild_str} completed in {elapsed_time:.1f}s"))
107
+
108
+ if not silent:
109
+ if build_result == False: # noqa: E712
110
+ click.echo(FeedbackManager.info(message="No changes. Build skipped."))
111
+ else:
112
+ click.echo(FeedbackManager.success(message=f"\n✓ {rebuild_str} completed in {elapsed_time:.1f}s"))
91
113
 
92
114
  return None
93
115
 
@@ -182,7 +204,11 @@ def show_data(tb_client: TinyB, filename: str, diff: Optional[str] = None):
182
204
 
183
205
 
184
206
  def build_project(
185
- project: Project, tb_client: TinyB, silent: bool = False, load_fixtures: bool = True
207
+ project: Project,
208
+ tb_client: TinyB,
209
+ silent: bool = False,
210
+ load_fixtures: bool = True,
211
+ project_with_vendors: Optional[Project] = None,
186
212
  ) -> Optional[bool]:
187
213
  MULTIPART_BOUNDARY_DATA_PROJECT = "data_project://"
188
214
  DATAFILE_TYPE_TO_CONTENT_TYPE = {
@@ -194,6 +220,7 @@ def build_project(
194
220
  logging.debug(TINYBIRD_API_URL)
195
221
  TINYBIRD_API_KEY = tb_client.token
196
222
  error: Optional[str] = None
223
+
197
224
  try:
198
225
  files = [
199
226
  ("context://", ("cli-version", "1.0.0", "text/plain")),
@@ -208,9 +235,15 @@ def build_project(
208
235
  relative_path = Path(file_path).relative_to(project_path).as_posix()
209
236
  with open(file_path, "rb") as fd:
210
237
  content_type = DATAFILE_TYPE_TO_CONTENT_TYPE.get(Path(file_path).suffix, "application/unknown")
211
- files.append(
212
- (MULTIPART_BOUNDARY_DATA_PROJECT, (relative_path, fd.read().decode("utf-8"), content_type))
213
- )
238
+ content = fd.read().decode("utf-8")
239
+ if project_with_vendors:
240
+ # Replace 'SHARED_WITH' and everything that comes after, including new lines, with 'SHARED_WITH Tinybird_Local_Test_'
241
+ content = replace_shared_with(
242
+ content,
243
+ [project_with_vendors.workspace_name],
244
+ )
245
+
246
+ files.append((MULTIPART_BOUNDARY_DATA_PROJECT, (relative_path, content, content_type)))
214
247
  HEADERS = {"Authorization": f"Bearer {TINYBIRD_API_KEY}"}
215
248
 
216
249
  r = requests.post(TINYBIRD_API_URL, files=files, headers=HEADERS)
@@ -249,17 +282,16 @@ def build_project(
249
282
  )
250
283
  if no_changes:
251
284
  return False
252
- else:
253
- if not silent:
254
- echo_changes(project, new_datasources, ".datasource", "created")
255
- echo_changes(project, changed_datasources, ".datasource", "changed")
256
- echo_changes(project, deleted_datasources, ".datasource", "deleted")
257
- echo_changes(project, new_pipes, ".pipe", "created")
258
- echo_changes(project, changed_pipes, ".pipe", "changed")
259
- echo_changes(project, deleted_pipes, ".pipe", "deleted")
260
- echo_changes(project, new_connections, ".connection", "created")
261
- echo_changes(project, changed_connections, ".connection", "changed")
262
- echo_changes(project, deleted_connections, ".connection", "deleted")
285
+ if not silent:
286
+ echo_changes(project, new_datasources, ".datasource", "created")
287
+ echo_changes(project, changed_datasources, ".datasource", "changed")
288
+ echo_changes(project, deleted_datasources, ".datasource", "deleted")
289
+ echo_changes(project, new_pipes, ".pipe", "created")
290
+ echo_changes(project, changed_pipes, ".pipe", "changed")
291
+ echo_changes(project, deleted_pipes, ".pipe", "deleted")
292
+ echo_changes(project, new_connections, ".connection", "created")
293
+ echo_changes(project, changed_connections, ".connection", "changed")
294
+ echo_changes(project, deleted_connections, ".connection", "deleted")
263
295
  if load_fixtures:
264
296
  try:
265
297
  for filename in project_files:
@@ -320,3 +352,155 @@ def echo_changes(project: Project, changes: list[str], extension: str, status: s
320
352
  if path_str:
321
353
  path_str = path_str.replace(f"{project.folder}/", "")
322
354
  click.echo(FeedbackManager.info(message=f"✓ {path_str} {status}"))
355
+
356
+
357
+ def find_workspace_or_create(user_client: TinyB, workspace_name: str) -> Optional[str]:
358
+ # Get a client scoped to the vendored workspace using the user token
359
+ ws_token = None
360
+ org_id = None
361
+ try:
362
+ # Fetch org id and workspaces with tokens
363
+ info = user_client.user_workspaces_with_organization(version="v1")
364
+ org_id = info.get("organization_id")
365
+ workspaces = info.get("workspaces", [])
366
+ found = next((w for w in workspaces if w.get("name") == workspace_name), None)
367
+ if found:
368
+ ws_token = found.get("token")
369
+ # If still not found, try the generic listing
370
+ if not ws_token:
371
+ workspaces_full = user_client.user_workspaces_and_branches(version="v1")
372
+ created_ws = next(
373
+ (w for w in workspaces_full.get("workspaces", []) if w.get("name") == workspace_name), None
374
+ )
375
+ if created_ws:
376
+ ws_token = created_ws.get("token")
377
+ except Exception:
378
+ ws_token = None
379
+
380
+ # If workspace doesn't exist, try to create it and fetch its token
381
+ if not ws_token:
382
+ try:
383
+ user_client.create_workspace(workspace_name, assign_to_organization_id=org_id, version="v1")
384
+ # Fetch token for newly created workspace
385
+ info_after = user_client.user_workspaces_and_branches(version="v1")
386
+ created = next((w for w in info_after.get("workspaces", []) if w.get("name") == workspace_name), None)
387
+ ws_token = created.get("token") if created else None
388
+ except Exception as e:
389
+ click.echo(
390
+ FeedbackManager.warning(
391
+ message=(f"Skipping vendored workspace '{workspace_name}': unable to create or resolve token ({e})")
392
+ )
393
+ )
394
+
395
+ return ws_token
396
+
397
+
398
+ def build_vendored_workspaces(project: Project, tb_client: TinyB, config: dict[str, Any]) -> None:
399
+ """Build each vendored workspace under project.vendor_path if present.
400
+
401
+ Directory structure expected: vendor/<workspace_name>/<data_project_inside>
402
+ Each top-level directory under vendor is treated as a separate workspace
403
+ whose project files will be built using that workspace's token.
404
+ """
405
+ try:
406
+ vendor_root = Path(project.vendor_path)
407
+
408
+ if not vendor_root.exists() or not vendor_root.is_dir():
409
+ return
410
+
411
+ tokens = get_local_tokens()
412
+ user_token = tokens["user_token"]
413
+ user_client = deepcopy(tb_client)
414
+ user_client.token = user_token
415
+
416
+ # Iterate over vendored workspace folders
417
+ for ws_dir in sorted([p for p in vendor_root.iterdir() if p.is_dir()]):
418
+ workspace_name = ws_dir.name
419
+ ws_token = find_workspace_or_create(user_client, workspace_name)
420
+
421
+ if not ws_token:
422
+ click.echo(
423
+ FeedbackManager.warning(
424
+ message=f"Skipping vendored workspace '{workspace_name}': could not resolve token after creation"
425
+ )
426
+ )
427
+ continue
428
+
429
+ # Build using a client scoped to the vendor workspace token
430
+ vendor_client = deepcopy(tb_client)
431
+ vendor_client.token = ws_token
432
+ vendor_project = Project(folder=str(ws_dir), workspace_name=workspace_name, max_depth=project.max_depth)
433
+ workspace_info = tb_client.workspace_info(version="v1")
434
+ project.workspace_name = workspace_info.get("name", "")
435
+ # Do not exit on error to allow main project to continue
436
+ process(
437
+ project=vendor_project,
438
+ tb_client=vendor_client,
439
+ watch=False,
440
+ silent=True,
441
+ exit_on_error=True,
442
+ load_fixtures=True,
443
+ config=config,
444
+ project_with_vendors=project,
445
+ )
446
+ except Exception as e:
447
+ # Never break the main build due to vendored build errors
448
+ click.echo(FeedbackManager.error_exception(error=e))
449
+
450
+
451
+ def build_shared_with_workspaces(project: Project, tb_client: TinyB, config: dict[str, Any]) -> None:
452
+ """Scan project for .datasource files and ensure SHARED_WITH workspaces exist."""
453
+
454
+ try:
455
+ # Gather SHARED_WITH workspace names from all .datasource files
456
+ datasource_files = project.get_datasource_files()
457
+ shared_ws_names = set()
458
+
459
+ for filename in datasource_files:
460
+ try:
461
+ doc = parse_datasource(filename).datafile
462
+ for ws_name in doc.shared_with or []:
463
+ shared_ws_names.add(ws_name)
464
+ except Exception:
465
+ # Ignore parse errors here; they'll be handled during the main process()
466
+ continue
467
+
468
+ if not shared_ws_names:
469
+ return
470
+
471
+ # Need a user token to list/create workspaces
472
+ tokens = get_local_tokens()
473
+ user_token = tokens.get("user_token")
474
+ if not user_token:
475
+ click.echo(FeedbackManager.info_skipping_shared_with_entry())
476
+ return
477
+
478
+ user_client = deepcopy(tb_client)
479
+ user_client.token = user_token
480
+
481
+ # Ensure each SHARED_WITH workspace exists
482
+ for ws_name in sorted(shared_ws_names):
483
+ find_workspace_or_create(user_client, ws_name)
484
+ except Exception as e:
485
+ click.echo(FeedbackManager.error_exception(error=e))
486
+
487
+
488
+ def replace_shared_with(text: str, new_workspaces: list[str]) -> str:
489
+ replacement = ", ".join(new_workspaces)
490
+
491
+ # 1) Formato multilinea:
492
+ # SHARED_WITH >
493
+ # workspace1, workspace2
494
+ #
495
+ # Solo sustituimos la LÍNEA de workspaces (grupo 3), no usamos DOTALL.
496
+ pat_multiline = re.compile(r"(?m)^(SHARED_WITH\s*>\s*)\n([ \t]*)([^\n]*)$")
497
+ if pat_multiline.search(text):
498
+ return pat_multiline.sub(lambda m: f"{m.group(1)}\n{m.group(2)}{replacement}", text)
499
+
500
+ # 2) Formato inline:
501
+ # SHARED_WITH workspace1, workspace2
502
+ pat_inline = re.compile(r"(?m)^(SHARED_WITH\s+)([^\n]*)$")
503
+ if pat_inline.search(text):
504
+ return pat_inline.sub(lambda m: f"{m.group(1)}{replacement}", text)
505
+
506
+ return text
@@ -2,7 +2,6 @@
2
2
  #
3
3
  # - If it makes sense and only when strictly necessary, you can create utility functions in this file.
4
4
  # - But please, **do not** interleave utility functions and command definitions.
5
-
6
5
  import json
7
6
  import logging
8
7
  import os
@@ -11,9 +10,11 @@ import sys
11
10
  from os import environ, getcwd
12
11
  from pathlib import Path
13
12
  from typing import Any, Callable, Dict, List, Optional, Tuple, Union
13
+ from urllib.parse import urlencode
14
14
 
15
15
  import click
16
16
  import humanfriendly
17
+ import requests
17
18
  from click import Context
18
19
 
19
20
  from tinybird.tb import __cli__
@@ -21,7 +22,9 @@ from tinybird.tb.check_pypi import CheckPypi
21
22
  from tinybird.tb.client import (
22
23
  AuthException,
23
24
  AuthNoTokenException,
25
+ TinyB,
24
26
  )
27
+ from tinybird.tb.config import get_clickhouse_host
25
28
  from tinybird.tb.modules.agent import run_agent
26
29
  from tinybird.tb.modules.common import (
27
30
  CatchAuthExceptions,
@@ -36,8 +39,10 @@ from tinybird.tb.modules.common import (
36
39
  from tinybird.tb.modules.config import CURRENT_VERSION, CLIConfig
37
40
  from tinybird.tb.modules.datafile.build import build_graph
38
41
  from tinybird.tb.modules.datafile.pull import folder_pull
42
+ from tinybird.tb.modules.exceptions import CLIChException
39
43
  from tinybird.tb.modules.feedback_manager import FeedbackManager
40
44
  from tinybird.tb.modules.local_common import get_tinybird_local_client
45
+ from tinybird.tb.modules.login_common import check_current_folder_in_sessions
41
46
  from tinybird.tb.modules.project import Project
42
47
 
43
48
  __old_click_echo = click.echo
@@ -72,6 +77,7 @@ VERSION = f"{__cli__.__version__} (rev {__cli__.__revision__})"
72
77
  )
73
78
  @click.option("--show-tokens", is_flag=True, default=False, help="Enable the output of tokens.")
74
79
  @click.option("--cloud/--local", is_flag=True, default=False, help="Run against cloud or local.")
80
+ @click.option("--branch", help="Run against a branch.")
75
81
  @click.option("--staging", is_flag=True, default=False, help="Run against a staging deployment.")
76
82
  @click.option(
77
83
  "--output", type=click.Choice(["human", "json", "csv"], case_sensitive=False), default="human", help="Output format"
@@ -99,6 +105,7 @@ def cli(
99
105
  version_warning: bool,
100
106
  show_tokens: bool,
101
107
  cloud: bool,
108
+ branch: Optional[str],
102
109
  staging: bool,
103
110
  output: str,
104
111
  max_depth: int,
@@ -106,17 +113,16 @@ def cli(
106
113
  prompt: Optional[str] = None,
107
114
  ) -> None:
108
115
  """
109
- Use Tinybird Code to interact with your project.
116
+ Run just `tb` to use Tinybird Code to interact with your project.
110
117
  """
111
118
 
112
119
  # We need to unpatch for our tests not to break
113
120
  if output != "human":
114
121
  __hide_click_output()
122
+ elif show_tokens or not cloud or ctx.invoked_subcommand == "build":
123
+ __unpatch_click_output()
115
124
  else:
116
- if show_tokens or not cloud or ctx.invoked_subcommand == "build":
117
- __unpatch_click_output()
118
- else:
119
- __patch_click_output()
125
+ __patch_click_output()
120
126
 
121
127
  if getenv_bool("TB_DISABLE_SSL_CHECKS", False):
122
128
  click.echo(FeedbackManager.warning_disabled_ssl_checks())
@@ -197,17 +203,36 @@ def cli(
197
203
  return
198
204
 
199
205
  ctx.ensure_object(dict)["project"] = project
200
- client = create_ctx_client(ctx, config, cloud, staging, project=project, show_warnings=version_warning)
206
+ client = create_ctx_client(
207
+ ctx,
208
+ config,
209
+ cloud or bool(branch),
210
+ staging,
211
+ project=project,
212
+ show_warnings=version_warning,
213
+ branch=branch,
214
+ )
201
215
 
202
216
  if client:
203
217
  ctx.ensure_object(dict)["client"] = client
204
218
 
205
- ctx.ensure_object(dict)["env"] = get_target_env(cloud)
219
+ ctx.ensure_object(dict)["env"] = get_target_env(cloud, branch)
220
+ ctx.ensure_object(dict)["branch"] = branch
206
221
  ctx.ensure_object(dict)["output"] = output
207
222
 
223
+ # Check if current folder is tracked from previous sessions
224
+ check_current_folder_in_sessions(ctx)
225
+
208
226
  is_prompt_mode = prompt is not None
209
227
 
210
228
  if is_agent_mode or is_prompt_mode:
229
+ if any(arg in sys.argv for arg in ["--cloud", "--local", "--branch"]):
230
+ raise CLIException(
231
+ FeedbackManager.error(
232
+ message="Tinybird Code does not support --cloud, --local or --branch flags. It will choose the correct environment based on your prompts."
233
+ )
234
+ )
235
+
211
236
  run_agent(config, project, dangerously_skip_permissions, prompt=prompt)
212
237
 
213
238
 
@@ -298,7 +323,7 @@ def sql(
298
323
  )
299
324
 
300
325
  query = ""
301
- for _, elem in dependencies_graph.to_run.items():
326
+ for elem in dependencies_graph.to_run.values():
302
327
  for _node in elem["nodes"]:
303
328
  if _node["params"]["name"].lower() == node.lower():
304
329
  query = "".join(_node["sql"])
@@ -336,6 +361,88 @@ def sql(
336
361
  click.echo(FeedbackManager.info_no_rows())
337
362
 
338
363
 
364
+ @cli.command(
365
+ name="ch",
366
+ context_settings=dict(
367
+ ignore_unknown_options=True,
368
+ allow_extra_args=True,
369
+ ),
370
+ )
371
+ @click.option(
372
+ "--query",
373
+ type=str,
374
+ default=None,
375
+ required=False,
376
+ help="The query to run against ClickHouse.",
377
+ )
378
+ @click.option(
379
+ "--user",
380
+ required=False,
381
+ help="User field is not used for authentication but helps identify the connection.",
382
+ )
383
+ @click.option(
384
+ "--password",
385
+ required=False,
386
+ help="Your Tinybird Auth Token. If not provided, the token will be your current workspace token.",
387
+ )
388
+ @click.option(
389
+ "-m",
390
+ "--multiline",
391
+ is_flag=True,
392
+ default=False,
393
+ help="Enable multiline mode - read the query from multiple lines until a semicolon.",
394
+ )
395
+ @click.pass_context
396
+ def ch(ctx: Context, query: str, user: Optional[str], password: Optional[str], multiline: bool) -> None:
397
+ """Run a query against ClickHouse native HTTP interface."""
398
+ try:
399
+ query_arg = next((arg for arg in ctx.args if not arg.startswith("--param_")), None)
400
+ if query_arg and not query:
401
+ query = query_arg
402
+
403
+ if not query and not sys.stdin.isatty(): # Check if there's piped input
404
+ query = sys.stdin.read().strip()
405
+
406
+ if not query:
407
+ click.echo(FeedbackManager.warning(message="Nothing to do. No query provided"))
408
+ return
409
+
410
+ if multiline:
411
+ queries = [query.strip() for query in query.split(";") if query.strip()]
412
+ else:
413
+ queries = [query]
414
+
415
+ client: TinyB = ctx.ensure_object(dict)["client"]
416
+ config = ctx.ensure_object(dict)["config"]
417
+ password = password or client.token
418
+ user = user or config.get("name", None)
419
+ ch_host = get_clickhouse_host(client.host)
420
+ headers = {"X-ClickHouse-Key": password}
421
+ if user:
422
+ headers["X-ClickHouse-User"] = user
423
+
424
+ params = {}
425
+
426
+ for param in ctx.args:
427
+ if param.startswith("--param_"):
428
+ param_name = param.split("=")[0].replace("--", "")
429
+ param_value = param.split("=")[1]
430
+ params[param_name] = param_value
431
+
432
+ for query in queries:
433
+ query_params = {**params, "query": query}
434
+ url = f"{ch_host}?{urlencode(query_params)}"
435
+ res = requests.get(url=url, headers=headers)
436
+
437
+ if res.status_code != 200:
438
+ raise Exception(res.text)
439
+
440
+ click.echo(res.text)
441
+
442
+ except Exception as e:
443
+ raise CLIChException(FeedbackManager.error(message=str(e)))
444
+
445
+
339
446
  def __patch_click_output():
340
447
  import re
341
448
 
@@ -392,7 +499,13 @@ def __hide_click_output() -> None:
392
499
 
393
500
 
394
501
  def create_ctx_client(
395
- ctx: Context, config: Dict[str, Any], cloud: bool, staging: bool, project: Project, show_warnings: bool = True
502
+ ctx: Context,
503
+ config: Dict[str, Any],
504
+ cloud: bool,
505
+ staging: bool,
506
+ project: Project,
507
+ show_warnings: bool = True,
508
+ branch: Optional[str] = None,
396
509
  ):
397
510
  commands_without_ctx_client = [
398
511
  "auth",
@@ -402,11 +515,11 @@ def create_ctx_client(
402
515
  "logout",
403
516
  "update",
404
517
  "upgrade",
405
- "create",
406
518
  "info",
407
519
  "tag",
408
520
  "push",
409
521
  "branch",
522
+ "environment",
410
523
  "diff",
411
524
  "fmt",
412
525
  "init",
@@ -415,8 +528,8 @@ def create_ctx_client(
415
528
  if not command or command in commands_without_ctx_client:
416
529
  return None
417
530
 
418
- commands_always_cloud = ["infra"]
419
- commands_always_local = ["build", "dev"]
531
+ commands_always_cloud = ["infra", "branch", "environment"]
532
+ commands_always_local = ["create"]
420
533
  command_always_test = ["test"]
421
534
 
422
535
  if (
@@ -439,7 +552,7 @@ def create_ctx_client(
439
552
  if method and show_warnings:
440
553
  click.echo(FeedbackManager.gray(message=f"Authentication method: {method}"))
441
554
 
442
- return _get_tb_client(config.get("token", ""), config["host"], staging=staging)
555
+ return _get_tb_client(config.get("token", ""), config["host"], staging=staging, branch=branch)
443
556
  local = command in commands_always_local
444
557
  test = command in command_always_test
445
558
  if show_warnings and not local and command not in commands_always_local and command:
@@ -447,8 +560,8 @@ def create_ctx_client(
447
560
  return get_tinybird_local_client(config, test=test, staging=staging)
448
561
 
449
562
 
450
- def get_target_env(cloud: bool) -> str:
451
- if cloud:
563
+ def get_target_env(cloud: bool, branch: Optional[str]) -> str:
564
+ if cloud or bool(branch):
452
565
  return "cloud"
453
566
  return "local"
454
567