tinybird 0.0.1.dev262__py3-none-any.whl → 0.0.1.dev263__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.

Potentially problematic release.


This version of tinybird might be problematic. Click here for more details.

@@ -461,6 +461,15 @@ class Datafile:
461
461
  raise DatafileValidationError(
462
462
  f"Invalid permission {token['permission']} for token {token['token_name']}. Only READ and APPEND are allowed for datasources"
463
463
  )
464
+
465
+ # Validate sorting key if present
466
+ if "engine" in node and isinstance(node["engine"], dict) and "args" in node["engine"]:
467
+ for arg_name, arg_value in node["engine"]["args"]:
468
+ if arg_name.lower() == "sorting_key":
469
+ # Check for sorting key constraints
470
+ self._validate_sorting_key(arg_value, node)
471
+ break
472
+
464
473
  # Validate Kafka params
465
474
  if any(param in node for param in KAFKA_PARAMS) and (
466
475
  missing := [param for param in REQUIRED_KAFKA_PARAMS if param not in node]
@@ -483,6 +492,148 @@ class Datafile:
483
492
  # We cannot validate a datafile whose kind is unknown
484
493
  pass
485
494
 
495
+ def _validate_sorting_key(self, sorting_key: str, node: Dict[str, Any]) -> None:
496
+ """
497
+ Validates that a sorting key doesn't reference:
498
+ - Nullable columns
499
+ - AggregateFunction types
500
+ - Engine version columns for ReplacingMergeTree
501
+ """
502
+ if sorting_key == "tuple()" or not sorting_key:
503
+ return # Empty sorting key is valid
504
+
505
+ engine_ver_column = self._extract_engine_ver_column(node)
506
+ schema_columns = {col["name"]: col for col in node["columns"]}
507
+ sorting_key_columns = self._parse_sorting_key_columns(sorting_key, engine_ver_column)
508
+
509
+ self._validate_columns_against_schema(sorting_key_columns, schema_columns)
510
+
511
+ def _extract_engine_ver_column(self, node: Dict[str, Any]) -> Optional[str]:
512
+ engine_info = node.get("engine", {})
513
+
514
+ if not isinstance(engine_info, dict):
515
+ return None
516
+
517
+ engine_type = engine_info.get("type", "")
518
+ if engine_type != "ReplacingMergeTree":
519
+ return None
520
+
521
+ engine_args = engine_info.get("args", [])
522
+ for arg_name, arg_value in engine_args:
523
+ if arg_name == "ver":
524
+ return arg_value
525
+
526
+ return None
527
+
528
+ def _parse_sorting_key_columns(self, sorting_key: str, engine_ver_column: Optional[str]) -> List[str]:
529
+ """Parse sorting key to extract column names and validate constraints."""
530
+ # Validate ENGINE_VER column constraint early
531
+ if engine_ver_column and engine_ver_column in sorting_key:
532
+ raise DatafileValidationError(
533
+ f"ENGINE_VER column '{engine_ver_column}' cannot be included in the sorting key for ReplacingMergeTree. "
534
+ f"Including the version column in the sorting key prevents deduplication because rows with different "
535
+ f"versions will have different sorting keys and won't be considered duplicates. The sorting key should "
536
+ f"define the record identity (what makes it unique), while ENGINE_VER tracks which version to keep."
537
+ )
538
+
539
+ # Remove tuple() wrapper if present
540
+ column_str = sorting_key
541
+ if column_str.startswith("tuple(") and column_str.endswith(")"):
542
+ column_str = column_str[6:-1]
543
+
544
+ sorting_key_columns = []
545
+
546
+ for part in column_str.split(","):
547
+ part = part.strip()
548
+
549
+ if self._is_aggregate_function_expression(part):
550
+ raise DatafileValidationError(
551
+ f"Sorting key contains aggregate function expression '{part}'. Aggregate function expressions cannot be used in sorting keys."
552
+ )
553
+
554
+ # Extract column names from the part
555
+ extracted_columns = self._extract_column_names_from_part(part)
556
+ sorting_key_columns.extend(extracted_columns)
557
+
558
+ return sorting_key_columns
559
+
560
+ def _is_aggregate_function_expression(self, part: str) -> bool:
561
+ """Check if a sorting key part is an aggregate function expression."""
562
+ if not ("(" in part and part.endswith(")")):
563
+ return False
564
+
565
+ func_start = part.find("(")
566
+ func_name = part[:func_start].strip().lower()
567
+
568
+ aggregate_function_names = {
569
+ "sum",
570
+ "count",
571
+ "avg",
572
+ "min",
573
+ "max",
574
+ "any",
575
+ "grouparray",
576
+ "groupuniqarray",
577
+ "uniq",
578
+ "summerge",
579
+ "countmerge",
580
+ "avgmerge",
581
+ "minmerge",
582
+ "maxmerge",
583
+ "anymerge",
584
+ "grouparraymerge",
585
+ "groupuniqarraymerge",
586
+ "uniqmerge",
587
+ }
588
+
589
+ return func_name in aggregate_function_names
590
+
591
+ def _extract_column_names_from_part(self, part: str) -> List[str]:
592
+ """Extract column names from a sorting key part."""
593
+ columns = []
594
+
595
+ if "(" in part and part.endswith(")"):
596
+ # Function expression - extract column names from inside parentheses
597
+ func_start = part.find("(")
598
+ inner_content = part[func_start + 1 : -1].strip()
599
+ for inner_part in inner_content.split(","):
600
+ inner_part = inner_part.strip().strip("`")
601
+ if inner_part and inner_part.isidentifier():
602
+ columns.append(inner_part)
603
+ elif part:
604
+ # Simple column name
605
+ column_name = part.strip("`")
606
+ if column_name:
607
+ columns.append(column_name)
608
+
609
+ return columns
610
+
611
+ def _validate_columns_against_schema(
612
+ self, sorting_key_columns: List[str], schema_columns: Dict[str, Dict[str, Any]]
613
+ ) -> None:
614
+ """Validate each column in the sorting key against the schema."""
615
+ if not schema_columns:
616
+ return # No schema information available, can't validate
617
+
618
+ for col_name in sorting_key_columns:
619
+ if col_name not in schema_columns:
620
+ continue
621
+
622
+ self._validate_single_column(col_name, schema_columns[col_name])
623
+
624
+ def _validate_single_column(self, col_name: str, column_info: Dict[str, Any]) -> None:
625
+ """Validate a single column for use in sorting keys."""
626
+ col_type = column_info.get("type", "").lower()
627
+ is_nullable = column_info.get("nullable", False)
628
+ if is_nullable:
629
+ raise DatafileValidationError(
630
+ f"Sorting key contains nullable column '{col_name}'. Nullable columns cannot be used in sorting keys."
631
+ )
632
+ if "aggregatefunction" in col_type:
633
+ raise DatafileValidationError(
634
+ f"Sorting key contains column '{col_name}' with AggregateFunction type. AggregateFunction columns cannot be used in sorting keys."
635
+ )
636
+
486
637
 
487
638
  def format_filename(filename: str, hide_folders: bool = False):
488
639
  return os.path.basename(filename) if hide_folders else filename
tinybird/tb/__cli__.py CHANGED
@@ -4,5 +4,5 @@ __description__ = 'Tinybird Command Line Tool'
4
4
  __url__ = 'https://www.tinybird.co/docs/forward/commands'
5
5
  __author__ = 'Tinybird'
6
6
  __author_email__ = 'support@tinybird.co'
7
- __version__ = '0.0.1.dev262'
8
- __revision__ = 'b28ec2c'
7
+ __version__ = '0.0.1.dev263'
8
+ __revision__ = '1d13423'
@@ -2,6 +2,7 @@ import asyncio
2
2
  import shlex
3
3
  import subprocess
4
4
  import sys
5
+ import urllib.parse
5
6
  from functools import partial
6
7
  from pathlib import Path
7
8
  from typing import Any, Optional
@@ -11,13 +12,16 @@ import humanfriendly
11
12
  from pydantic_ai import Agent, RunContext, Tool
12
13
  from pydantic_ai.agent import AgentRunResult
13
14
  from pydantic_ai.messages import ModelMessage, ModelRequest, UserPromptPart
15
+ from requests import Response
14
16
 
15
17
  from tinybird.tb.client import TinyB
16
18
  from tinybird.tb.modules.agent.animations import ThinkingAnimation
17
19
  from tinybird.tb.modules.agent.banner import display_banner
20
+ from tinybird.tb.modules.agent.command_agent import CommandAgent
18
21
  from tinybird.tb.modules.agent.memory import clear_history, clear_messages, load_messages, save_messages
19
22
  from tinybird.tb.modules.agent.models import create_model, model_costs
20
23
  from tinybird.tb.modules.agent.prompts import agent_system_prompt, load_custom_project_rules, resources_prompt
24
+ from tinybird.tb.modules.agent.testing_agent import TestingAgent
21
25
  from tinybird.tb.modules.agent.tools.analyze import analyze_file, analyze_url
22
26
  from tinybird.tb.modules.agent.tools.append import append_file, append_url
23
27
  from tinybird.tb.modules.agent.tools.build import build
@@ -32,9 +36,7 @@ from tinybird.tb.modules.agent.tools.mock import mock
32
36
  from tinybird.tb.modules.agent.tools.plan import plan
33
37
  from tinybird.tb.modules.agent.tools.preview_datafile import preview_datafile
34
38
  from tinybird.tb.modules.agent.tools.request_endpoint import request_endpoint
35
- from tinybird.tb.modules.agent.tools.test import create_tests as create_tests_tool
36
- from tinybird.tb.modules.agent.tools.test import run_tests as run_tests_tool
37
- from tinybird.tb.modules.agent.utils import AgentRunCancelled, TinybirdAgentContext, show_input
39
+ from tinybird.tb.modules.agent.utils import AgentRunCancelled, TinybirdAgentContext, show_confirmation, show_input
38
40
  from tinybird.tb.modules.build_common import process as build_process
39
41
  from tinybird.tb.modules.common import _analyze, _get_tb_client, echo_safe_humanfriendly_tables_format_pretty_table
40
42
  from tinybird.tb.modules.config import CLIConfig
@@ -64,6 +66,7 @@ class TinybirdAgent:
64
66
  self.host = host
65
67
  self.dangerously_skip_permissions = dangerously_skip_permissions or prompt_mode
66
68
  self.project = project
69
+ self.thinking_animation = ThinkingAnimation()
67
70
  if prompt_mode:
68
71
  self.messages: list[ModelMessage] = load_messages()[-5:]
69
72
  else:
@@ -102,12 +105,61 @@ class TinybirdAgent:
102
105
  Tool(execute_query, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
103
106
  Tool(request_endpoint, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
104
107
  Tool(diff_resource, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
105
- Tool(create_tests_tool, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
106
- Tool(run_tests_tool, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
107
108
  ],
108
109
  history_processors=[self._context_aware_processor],
109
110
  )
110
111
 
112
+ self.testing_agent = TestingAgent(
113
+ dangerously_skip_permissions=self.dangerously_skip_permissions,
114
+ prompt_mode=prompt_mode,
115
+ thinking_animation=self.thinking_animation,
116
+ token=self.token,
117
+ user_token=self.user_token,
118
+ host=self.host,
119
+ workspace_id=workspace_id,
120
+ project=self.project,
121
+ )
122
+ self.command_agent = CommandAgent(
123
+ dangerously_skip_permissions=self.dangerously_skip_permissions,
124
+ prompt_mode=prompt_mode,
125
+ thinking_animation=self.thinking_animation,
126
+ token=self.token,
127
+ user_token=self.user_token,
128
+ host=self.host,
129
+ workspace_id=workspace_id,
130
+ project=self.project,
131
+ )
132
+
133
+ @self.agent.tool
134
+ def manage_tests(ctx: RunContext[TinybirdAgentContext], task: str) -> str:
135
+ """Delegate test management to the test agent:
136
+
137
+ Args:
138
+ task (str): The detailed task to perform. Required.
139
+
140
+ Returns:
141
+ str: The result of the query.
142
+ """
143
+ result = self.testing_agent.run(task, deps=ctx.deps, usage=ctx.usage)
144
+
145
+ if not result:
146
+ return "Could not solve the task using the test agent"
147
+
148
+ return result.output
149
+
150
+ @self.agent.tool
151
+ def run_command(ctx: RunContext[TinybirdAgentContext], task: str) -> str:
152
+ """Solve a task using directly Tinybird CLI commands.
153
+
154
+ Args:
155
+ task (str): The task to solve. Required.
156
+
157
+ Returns:
158
+ str: The result of the command.
159
+ """
160
+ result = self.command_agent.run(task, deps=ctx.deps, usage=ctx.usage)
161
+ return result.output
162
+
111
163
  @self.agent.instructions
112
164
  def get_local_host(ctx: RunContext[TinybirdAgentContext]) -> str:
113
165
  return f"Tinybird Local host: {ctx.deps.local_host}"
@@ -128,8 +180,6 @@ class TinybirdAgent:
128
180
  def get_project_files(ctx: RunContext[TinybirdAgentContext]) -> str:
129
181
  return resources_prompt(self.project)
130
182
 
131
- self.thinking_animation = ThinkingAnimation()
132
-
133
183
  def add_message(self, message: ModelMessage) -> None:
134
184
  self.messages.append(message)
135
185
 
@@ -155,6 +205,7 @@ class TinybirdAgent:
155
205
  project = self.project
156
206
  folder = self.project.folder
157
207
  local_client = get_tinybird_local_client(config, test=False, silent=False)
208
+ test_client = get_tinybird_local_client(config, test=True, silent=True)
158
209
  return TinybirdAgentContext(
159
210
  # context does not support the whole client, so we need to pass only the functions we need
160
211
  explore_data=client.explore_data,
@@ -169,6 +220,8 @@ class TinybirdAgent:
169
220
  execute_query_local=partial(execute_query_local, config=config),
170
221
  request_endpoint_cloud=partial(request_endpoint_cloud, config=config),
171
222
  request_endpoint_local=partial(request_endpoint_local, config=config),
223
+ build_project_test=partial(build_project_test, project=project, client=test_client),
224
+ get_pipe_data_test=partial(get_pipe_data_test, client=test_client),
172
225
  get_datasource_datafile_cloud=partial(get_datasource_datafile_cloud, config=config),
173
226
  get_datasource_datafile_local=partial(get_datasource_datafile_local, config=config),
174
227
  get_pipe_datafile_cloud=partial(get_pipe_datafile_cloud, config=config),
@@ -176,7 +229,7 @@ class TinybirdAgent:
176
229
  get_connection_datafile_cloud=partial(get_connection_datafile_cloud, config=config),
177
230
  get_connection_datafile_local=partial(get_connection_datafile_local, config=config),
178
231
  get_project_files=project.get_project_files,
179
- run_tests=partial(run_tests, project=project, config=config),
232
+ run_tests=partial(run_tests, project=project, client=test_client),
180
233
  folder=folder,
181
234
  thinking_animation=self.thinking_animation,
182
235
  workspace_name=self.project.workspace_name,
@@ -277,15 +330,40 @@ def run_agent(
277
330
  FeedbackManager.error(message="Tinybird Code requires authentication. Run 'tb login' first.")
278
331
  )
279
332
  return
333
+ build_user_input: Optional[str] = None
334
+ try:
335
+ build_project(config, project, test=False, silent=True)
336
+ except CLIBuildException as e:
337
+ if prompt:
338
+ raise e
339
+ click.echo(FeedbackManager.error(message=e))
340
+ try:
341
+ show_confirmation(
342
+ title="Fix project errors?", skip_confirmation=dangerously_skip_permissions, show_review=False
343
+ )
344
+ except AgentRunCancelled:
345
+ click.echo(FeedbackManager.info(message="User cancelled the operation"))
346
+ return
280
347
 
281
- build_project(config, project, test=False, silent=True)
348
+ build_user_input = f"Error building project. Fix the errors before continuing. {e}"
282
349
 
283
350
  # In prompt mode, always skip permissions to avoid interactive prompts
284
351
  prompt_mode = prompt is not None
285
- agent = TinybirdAgent(token, user_token, host, workspace_id, project, dangerously_skip_permissions, prompt_mode)
352
+
353
+ agent = TinybirdAgent(
354
+ token,
355
+ user_token,
356
+ host,
357
+ workspace_id,
358
+ project,
359
+ dangerously_skip_permissions,
360
+ prompt_mode,
361
+ )
286
362
 
287
363
  # Print mode: run once with the provided prompt and exit
288
364
  if prompt:
365
+ if build_user_input:
366
+ prompt = f"User input: {prompt}\n\n{build_user_input}"
289
367
  agent.run(prompt, config)
290
368
  return
291
369
 
@@ -302,7 +380,8 @@ def run_agent(
302
380
  try:
303
381
  while True:
304
382
  try:
305
- user_input = show_input(workspace_name)
383
+ user_input = build_user_input or show_input(workspace_name)
384
+ build_user_input = None
306
385
  if user_input.startswith("tb "):
307
386
  cmd_parts = shlex.split(user_input)
308
387
  subprocess.run(cmd_parts)
@@ -373,12 +452,12 @@ def run_agent(
373
452
 
374
453
 
375
454
  def build_project(
376
- config: dict[str, Any], project: Project, silent: bool = True, test: bool = True, load_fixtures: bool = False
455
+ config: dict[str, Any], project: Project, silent: bool = False, test: bool = True, load_fixtures: bool = False
377
456
  ) -> None:
378
- local_client = get_tinybird_local_client(config, test=test, silent=silent)
457
+ client = get_tinybird_local_client(config, test=test, silent=silent)
379
458
  build_error = build_process(
380
459
  project=project,
381
- tb_client=local_client,
460
+ tb_client=client,
382
461
  watch=False,
383
462
  silent=silent,
384
463
  exit_on_error=False,
@@ -388,6 +467,23 @@ def build_project(
388
467
  raise CLIBuildException(build_error)
389
468
 
390
469
 
470
+ def build_project_test(
471
+ client: TinyB,
472
+ project: Project,
473
+ silent: bool = False,
474
+ ) -> None:
475
+ build_error = build_process(
476
+ project=project,
477
+ tb_client=client,
478
+ watch=False,
479
+ silent=silent,
480
+ exit_on_error=False,
481
+ load_fixtures=True,
482
+ )
483
+ if build_error:
484
+ raise CLIBuildException(build_error)
485
+
486
+
391
487
  def deploy_project(config: dict[str, Any], project: Project) -> None:
392
488
  client = _get_tb_client(config["token"], config["host"])
393
489
  try:
@@ -408,6 +504,8 @@ def deploy_check_project(config: dict[str, Any], project: Project) -> None:
408
504
  try:
409
505
  create_deployment(project=project, client=client, config=config, check=True, wait=True, auto=True)
410
506
  except SystemExit as e:
507
+ if hasattr(e, "code") and e.code == 0:
508
+ return
411
509
  raise CLIDeploymentException(e.args[0])
412
510
 
413
511
 
@@ -529,9 +627,24 @@ def get_connection_datafile_local(config: dict[str, Any], connection_name: str)
529
627
  return "Connection not found"
530
628
 
531
629
 
532
- def run_tests(config: dict[str, Any], project: Project, pipe_name: Optional[str] = None) -> None:
533
- local_client = get_tinybird_local_client(config, test=True, silent=True)
630
+ def run_tests(client: TinyB, project: Project, pipe_name: Optional[str] = None) -> None:
534
631
  try:
535
- run_tests_common(name=(pipe_name,) if pipe_name else (), project=project, client=local_client)
632
+ run_tests_common(name=(pipe_name,) if pipe_name else (), project=project, client=client)
536
633
  except SystemExit as e:
537
634
  raise Exception(e.args[0])
635
+
636
+
637
+ def get_pipe_data_test(client: TinyB, pipe_name: str, test_params: Optional[dict[str, str]] = None) -> Response:
638
+ pipe = client._req(f"/v0/pipes/{pipe_name}")
639
+ output_node = next(
640
+ (node for node in pipe["nodes"] if node["node_type"] != "default" and node["node_type"] != "standard"),
641
+ {"name": "not_found"},
642
+ )
643
+ if output_node["node_type"] == "endpoint":
644
+ return client._req_raw(f"/v0/pipes/{pipe_name}.ndjson?{test_params}")
645
+
646
+ params = {
647
+ "q": output_node["sql"],
648
+ "pipeline": pipe_name,
649
+ }
650
+ return client._req_raw(f"""/v0/sql?{urllib.parse.urlencode(params)}&{test_params}""")
@@ -1,56 +1,131 @@
1
+ import os
2
+ import sys
3
+
1
4
  import click
2
5
 
3
6
 
7
+ def detect_terminal_capabilities():
8
+ """Detect terminal color and Unicode capabilities"""
9
+ # Check for true color support
10
+ colorterm = os.environ.get("COLORTERM", "").lower()
11
+ term = os.environ.get("TERM", "").lower()
12
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
13
+
14
+ # Known terminals with good true color support
15
+ modern_terminals = ["warp", "ghostty", "iterm2", "alacritty", "kitty", "hyper"]
16
+
17
+ # Check for true color support
18
+ has_truecolor = (
19
+ colorterm in ["truecolor", "24bit"]
20
+ or term_program in modern_terminals
21
+ or "truecolor" in term
22
+ or "24bit" in term
23
+ )
24
+
25
+ # Check if it's standard macOS Terminal
26
+ is_macos_terminal = term_program == "apple_terminal"
27
+
28
+ # Check for Unicode support (most modern terminals support this)
29
+ has_unicode = sys.stdout.encoding and "utf" in sys.stdout.encoding.lower()
30
+
31
+ return {
32
+ "truecolor": has_truecolor and not is_macos_terminal,
33
+ "unicode": has_unicode,
34
+ "is_macos_terminal": is_macos_terminal,
35
+ }
36
+
37
+
4
38
  def display_banner():
5
39
  reset = "\033[0m"
40
+ capabilities = detect_terminal_capabilities()
6
41
 
7
42
  click.echo("\n")
8
- # The Tinybird Code ASCII art banner
9
- banner = [
10
- " ████████╗██╗███╗ ██╗██╗ ██╗██████╗ ██╗██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗",
11
- " ╚══██╔══╝██║████╗ ██║╚██╗ ██╔╝██╔══██╗██║██╔══██╗██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
12
- " ██║ ██║██╔██╗ ██║ ╚████╔╝ ██████╔╝██║██████╔╝██║ ██║ ██║ ██║ ██║██║ ██║█████╗ ",
13
- " ██║ ██║██║╚██╗██║ ╚██╔╝ ██╔══██╗██║██╔══██╗██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝ ",
14
- " ██║ ██║██║ ╚████║ ██║ ██████╔╝██║██║ ██║██████╔╝ ╚██████╗╚██████╔╝██████╔╝███████╗",
15
- " ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
16
- ]
43
+
44
+ # Choose banner based on Unicode support
45
+ if capabilities["unicode"]:
46
+ # Unicode box-drawing characters banner
47
+ banner = [
48
+ " ████████╗██╗███╗ ██╗██╗ ██╗██████╗ ██╗██████╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗",
49
+ " ╚══██╔══╝██║████╗ ██║╚██╗ ██╔╝██╔══██╗██║██╔══██╗██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
50
+ " ██║ ██║██╔██╗ ██║ ╚████╔╝ ██████╔╝██║██████╔╝██║ ██║ ██║ ██║ ██║██║ ██║█████╗ ",
51
+ " ██║ ██║██║╚██╗██║ ╚██╔╝ ██╔══██╗██║██╔══██╗██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝ ",
52
+ " ██║ ██║██║ ╚████║ ██║ ██████╔╝██║██║ ██║██████╔╝ ╚██████╗╚██████╔╝██████╔╝███████╗",
53
+ " ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
54
+ ]
55
+ else:
56
+ # ASCII fallback banner
57
+ banner = [
58
+ " ████████T██I███N ██N██ ██Y██████B ██I██████B ██████B ██████C ██████O ██████D ███████E",
59
+ " ╚══██╔══╝██║████╗ ██║╚██╗ ██╔╝██╔══██╗██║██╔══██╗██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
60
+ " ██║ ██║██╔██╗ ██║ ╚████╔╝ ██████╔╝██║██████╔╝██║ ██║ ██║ ██║ ██║██║ ██║█████╗ ",
61
+ " ██║ ██║██║╚██╗██║ ╚██╔╝ ██╔══██╗██║██╔══██╗██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝ ",
62
+ " ██║ ██║██║ ╚████║ ██║ ██████╔╝██║██║ ██║██████╔╝ ╚██████╗╚██████╔╝██████╔╝███████╗",
63
+ " ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
64
+ ]
17
65
 
18
66
  def interpolate_color(start_rgb, end_rgb, factor):
19
67
  """Interpolate between two RGB colors"""
20
68
  return [int(start_rgb[i] + (end_rgb[i] - start_rgb[i]) * factor) for i in range(3)]
21
69
 
22
- def rgb_to_ansi(r, g, b):
70
+ def rgb_to_ansi(r: int, g: int, b: int, use_truecolor: bool):
23
71
  """Convert RGB values to ANSI escape code"""
24
- return f"\033[38;2;{r};{g};{b}m"
72
+ if use_truecolor:
73
+ return f"\033[38;2;{r};{g};{b}m"
74
+ else:
75
+ # Convert to 8-bit color (256 color palette)
76
+ # Simple approximation: map RGB to 216-color cube + grayscale
77
+ if r == g == b:
78
+ # Grayscale
79
+ gray = int(r / 255 * 23) + 232
80
+ return f"\033[38;5;{gray}m"
81
+ else:
82
+ # Color cube (6x6x6)
83
+ r_idx = int(r / 255 * 5)
84
+ g_idx = int(g / 255 * 5)
85
+ b_idx = int(b / 255 * 5)
86
+ color_idx = 16 + (36 * r_idx) + (6 * g_idx) + b_idx
87
+ return f"\033[38;5;{color_idx}m"
25
88
 
26
89
  # Define start and end colors for smooth gradient
27
90
  start_color = [0, 128, 128] # Deep teal
28
- end_color = [100, 200, 180] # Light turquoise
91
+ end_color = [100, 190, 190] # Light turquoise (balanced green and blue)
29
92
 
30
- # Print each line with a very smooth horizontal gradient
93
+ # Print each line with gradient for modern terminals, solid color for limited terminals
31
94
  for line in banner:
32
95
  colored_line = ""
33
- # Count non-space characters for gradient calculation
34
- non_space_chars = sum(1 for char in line if char != " ")
35
- char_count = 0
36
-
37
- for char in line:
38
- if char == " ":
39
- colored_line += char
40
- continue
41
-
42
- # Calculate smooth gradient position (0.0 to 1.0)
43
- if non_space_chars > 1:
44
- gradient_position = char_count / (non_space_chars - 1)
45
- else:
46
- gradient_position = 0
47
96
 
48
- # Interpolate color
49
- current_rgb = interpolate_color(start_color, end_color, gradient_position)
50
- color_code = rgb_to_ansi(*current_rgb)
97
+ if capabilities["truecolor"]:
98
+ # Use gradient for modern terminals
99
+ non_space_chars = sum(1 for char in line if char != " ")
100
+ char_count = 0
101
+
102
+ for char in line:
103
+ if char == " ":
104
+ colored_line += char
105
+ continue
106
+
107
+ # Calculate smooth gradient position (0.0 to 1.0)
108
+ if non_space_chars > 1:
109
+ gradient_position = char_count / (non_space_chars - 1)
110
+ else:
111
+ gradient_position = 0
112
+
113
+ # Interpolate color
114
+ current_rgb = interpolate_color(start_color, end_color, gradient_position)
115
+ color_code = rgb_to_ansi(*current_rgb, use_truecolor=True) # type: ignore
116
+
117
+ colored_line += f"{color_code}{char}"
118
+ char_count += 1
119
+ else:
120
+ # Use solid color for limited terminals (like macOS Terminal)
121
+ solid_color = start_color # Use the deep teal consistently
122
+ color_code = rgb_to_ansi(*solid_color, use_truecolor=False) # type: ignore
51
123
 
52
- colored_line += f"{color_code}{char}"
53
- char_count += 1
124
+ for char in line:
125
+ if char == " ":
126
+ colored_line += char
127
+ else:
128
+ colored_line += f"{color_code}{char}"
54
129
 
55
130
  click.echo(colored_line + reset)
56
131
  click.echo()
@@ -0,0 +1,59 @@
1
+ from pydantic_ai import Agent, RunContext, Tool
2
+ from pydantic_ai.messages import ModelMessage
3
+ from pydantic_ai.usage import Usage
4
+
5
+ from tinybird.tb.modules.agent.animations import ThinkingAnimation
6
+ from tinybird.tb.modules.agent.models import create_model
7
+ from tinybird.tb.modules.agent.prompts import tests_files_prompt
8
+ from tinybird.tb.modules.agent.tools.run_command import run_command
9
+ from tinybird.tb.modules.agent.utils import TinybirdAgentContext
10
+ from tinybird.tb.modules.project import Project
11
+
12
+
13
+ class CommandAgent:
14
+ def __init__(
15
+ self,
16
+ token: str,
17
+ user_token: str,
18
+ host: str,
19
+ workspace_id: str,
20
+ project: Project,
21
+ dangerously_skip_permissions: bool,
22
+ prompt_mode: bool,
23
+ thinking_animation: ThinkingAnimation,
24
+ ):
25
+ self.token = token
26
+ self.user_token = user_token
27
+ self.host = host
28
+ self.dangerously_skip_permissions = dangerously_skip_permissions or prompt_mode
29
+ self.project = project
30
+ self.thinking_animation = thinking_animation
31
+ self.messages: list[ModelMessage] = []
32
+ self.agent = Agent(
33
+ model=create_model(user_token, host, workspace_id),
34
+ deps_type=TinybirdAgentContext,
35
+ instructions=[
36
+ """
37
+ You are part of Tinybird Code, an agentic CLI that can help users to work with Tinybird.
38
+ You are a sub-agent of the main Tinybird Code agent. You are responsible for running commands on the user's machine.
39
+ You will be given a task to perform and you will use `run_command` tool to complete it.
40
+ If you do not find a command that can solve the task, just say that there is no command that can solve the task.
41
+ You can run `-h` in every level of the command to get help. E.g. `tb -h`, `tb datasource -h`, `tb datasource ls -h`.
42
+ When you need to access Tinybird Cloud, add the `--cloud` flag. E.g. `tb --cloud datasource ls`.
43
+ Token and host are not required to add to the commands.
44
+ Always run first help commands to be sure that the commands you are running is not interactive.
45
+ """,
46
+ ],
47
+ tools=[
48
+ Tool(run_command, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
49
+ ],
50
+ )
51
+
52
+ @self.agent.instructions
53
+ def get_tests_files(ctx: RunContext[TinybirdAgentContext]) -> str:
54
+ return tests_files_prompt(self.project)
55
+
56
+ def run(self, task: str, deps: TinybirdAgentContext, usage: Usage):
57
+ result = self.agent.run_sync(task, deps=deps, usage=usage, message_history=self.messages)
58
+ self.messages.extend(result.new_messages())
59
+ return result
@@ -16,7 +16,6 @@ from tinybird.prompts import (
16
16
  pipe_instructions,
17
17
  s3_connection_example,
18
18
  sink_pipe_instructions,
19
- test_instructions,
20
19
  )
21
20
  from tinybird.tb.modules.project import Project
22
21
 
@@ -89,10 +88,7 @@ sql_instructions = """
89
88
  - Use node names as table names only when nodes are present in the same file.
90
89
  - Do not reference the current node name in the SQL.
91
90
  - SQL queries only accept SELECT statements with conditions, aggregations, joins, etc.
92
- - Do NOT use CREATE TABLE, INSERT INTO, CREATE DATABASE, SHOW TABLES, etc.
93
- - Use ONLY SELECT statements in the SQL section.
94
- - INSERT INTO is not supported in SQL section.
95
- - Do NOT query system.<table_name> tables.
91
+ - ONLY SELECT statements are allowed in any sql query.
96
92
  - When using functions try always ClickHouse functions first, then SQL functions.
97
93
  - Parameters are never quoted in any case.
98
94
  - Use the following syntax in the SQL section for the iceberg table function: iceberg('s3://bucket/path/to/table', {{tb_secret('aws_access_key_id')}}, {{tb_secret('aws_secret_access_key')}})
@@ -115,7 +111,6 @@ datafile_instructions = """
115
111
  def resources_prompt(project: Project) -> str:
116
112
  files = project.get_project_files()
117
113
  fixture_files = project.get_fixture_files()
118
- test_files = project.get_test_files()
119
114
 
120
115
  resources_content = "# Existing resources in the project:\n"
121
116
  if files:
@@ -148,6 +143,29 @@ def resources_prompt(project: Project) -> str:
148
143
  else:
149
144
  fixture_content += "No fixture files found"
150
145
 
146
+ return resources_content + "\n" + fixture_content
147
+
148
+
149
+ def tests_files_prompt(project: Project) -> str:
150
+ files = project.get_project_files()
151
+ test_files = project.get_test_files()
152
+
153
+ resources_content = "# Existing resources in the project:\n"
154
+ if files:
155
+ resources: list[dict[str, Any]] = []
156
+ for filename in files:
157
+ file_path = Path(filename)
158
+ resource = {
159
+ "path": str(file_path.relative_to(project.folder)),
160
+ "type": get_resource_type(file_path),
161
+ "name": file_path.stem,
162
+ "content": file_path.read_text(),
163
+ }
164
+ resources.append(resource)
165
+ resources_content = format_as_xml(resources, root_tag="resources", item_tag="resource")
166
+ else:
167
+ resources_content += "No resources found"
168
+
151
169
  test_content = "# Test files in the project:\n"
152
170
  if test_files:
153
171
  tests: list[dict[str, Any]] = []
@@ -163,7 +181,7 @@ def resources_prompt(project: Project) -> str:
163
181
  else:
164
182
  test_content += "No test files found"
165
183
 
166
- return resources_content + "\n" + fixture_content + "\n" + test_content
184
+ return resources_content + "\n" + test_content
167
185
 
168
186
 
169
187
  def get_resource_type(path: Path) -> str:
@@ -522,14 +540,62 @@ FROM small_table
522
540
  JOIN (SELECT id, col FROM huge_table WHERE id IN (SELECT id FROM small_table)) filtered
523
541
  ON small_table.id = filtered.id
524
542
  ```
525
- """
526
543
 
544
+ <dev_notes>
545
+ IMPORTANT: DO NOT USE THE FOLLOWING WHEN QUERYING:
546
+ - CREATE TABLE, INSERT INTO, CREATE DATABASE, SHOW TABLES, TRUNCATE TABLE, DELETE FROM, SHOW DATASOURCES, etc. are not allowed.
547
+ - ONLY use SELECT statements.
548
+ - currentDatabase is not allowed.
549
+ - system tables are not allowed: system.tables, system.datasources, information_schema.tables...
550
+ </dev_notes>
551
+ """
527
552
 
528
- agent_system_prompt = f"""
529
- You are a Tinybird Code, an agentic CLI that can help users to work with Tinybird.
530
553
 
531
- You are an interactive CLI tool that helps users with data engineering tasks. Use the instructions below and the tools available to you to assist the user.
554
+ test_instructions = """
555
+ # Working with test files:
556
+ - The test file name must match the name of the pipe it is testing.
557
+ - Every scenario name must be unique inside the test file.
558
+ - When looking for the parameters available, you will find them in the pipe file in the following format: {{{{String(my_param_name, default_value)}}}}.
559
+ - If the resource has no parameters, generate a single test with empty parameters.
560
+ - The format of the parameters is the following: param1=value1&param2=value2&param3=value3
561
+ - If some parameters are provided by the user and you need to use them, preserve in the same format as they were provided, like case sensitive
562
+ - Test as many scenarios as possible.
563
+ - Create tests only when the user explicitly asks for it with prompts like "Create tests for this endpoint" or "Create tests for this pipe".
564
+ - If the user asks for "testing an endpoint" or "call an endpoint", just request to the endpoint.
565
+ - The data that the tests are using is the data provided in the fixtures folder, so do not use `execute_query` or `request_endpoint` tools to analyze the data.
566
+ - MANDATORY: Before creating the test, analyze the fixture files that the tables of the endpoint are using so you can create relevant tests.
567
+ - IMPORTANT: expected_result field should always be an empty string, because it will be filled by the `create_test` tool.
568
+ - If the endpoint does not have parameters, you can omit parameters and generate a single test.
569
+ - The format of the test file is the following:
570
+ <test_file_format>
571
+ - name: kpis_single_day
572
+ description: Test hourly granularity for a single day
573
+ parameters: date_from=2024-01-01&date_to=2024-01-01
574
+ expected_result: ''
575
+
576
+ - name: kpis_date_range
577
+ description: Test daily granularity for a date range
578
+ parameters: date_from=2024-01-01&date_to=2024-01-31
579
+ expected_result: ''
580
+
581
+ - name: kpis_default_range
582
+ description: Test default behavior without date parameters (last 7 days)
583
+ parameters: ''
584
+ expected_result: ''
585
+
586
+ - name: kpis_fixed_time
587
+ description: Test with fixed timestamp for consistent testing
588
+ parameters: fixed_time=2024-01-15T12:00:00
589
+ expected_result: ''
590
+
591
+ - name: kpis_single_day
592
+ description: Test single day with hourly granularity
593
+ parameters: date_from=2024-01-01&date_to=2024-01-01
594
+ expected_result: ''
595
+ </test_file_format>
596
+ """
532
597
 
598
+ tone_and_style_instructions = """
533
599
  # Tone and style
534
600
  You should be concise, direct, and to the point. Maintain a professional tone. Do not use emojis.
535
601
  Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting.
@@ -548,6 +614,15 @@ Do not add additional code explanation summary unless requested by the user. Aft
548
614
 
549
615
  # Code style
550
616
  IMPORTANT: DO NOT ADD ANY COMMENTS unless asked by the user.
617
+ """
618
+
619
+
620
+ agent_system_prompt = f"""
621
+ You are a Tinybird Code, an agentic CLI that can help users to work with Tinybird.
622
+
623
+ You are an interactive CLI tool that helps users with data engineering tasks. Use the instructions below and the tools available to you to assist the user.
624
+
625
+ {tone_and_style_instructions}
551
626
 
552
627
  # Tools
553
628
  You have access to the following tools:
@@ -662,15 +737,11 @@ GCS: {gcs_connection_example}
662
737
  - `DateTime` parameters accept values in format `YYYY-MM-DD HH:MM:SS`
663
738
  - `Date` parameters accept values in format `YYYYMMDD`
664
739
 
665
- # Working with tests:
666
- {test_instructions}
667
- <dev_notes>
668
- - Create tests only when the user explicitly asks for it with prompts like "Create tests for this endpoint" or "Create tests for this pipe".
669
- - If the user asks for "testing an endpoint" or "call an endpoint", just request to the endpoint.
670
- - The data that the tests are using is the data provided in the fixtures folder.
671
- - Querying data or requesting endpoints won't return the data that the tests are using.
672
- - MANDATORY: Before creating the test, analyze the fixture files that the tables of the endpoint are using so you can create relevant tests.
673
- </dev_notes>
740
+ # Working with test files:
741
+ - Use `manage_tests` tool to create, update or run tests.
742
+
743
+ # Working with commands:
744
+ - If you dont have a tool that can solve the task, use `run_command` tool to check if the task can be solved with a normal tinybird cli command.
674
745
 
675
746
  # Info
676
747
  Today is {datetime.now().strftime("%Y-%m-%d")}
@@ -0,0 +1,62 @@
1
+ from pydantic_ai import Agent, RunContext, Tool
2
+ from pydantic_ai.messages import ModelMessage
3
+ from pydantic_ai.usage import Usage
4
+
5
+ from tinybird.tb.modules.agent.animations import ThinkingAnimation
6
+ from tinybird.tb.modules.agent.models import create_model
7
+ from tinybird.tb.modules.agent.prompts import test_instructions, tests_files_prompt, tone_and_style_instructions
8
+ from tinybird.tb.modules.agent.tools.test import create_tests as create_tests_tool
9
+ from tinybird.tb.modules.agent.tools.test import run_tests as run_tests_tool
10
+ from tinybird.tb.modules.agent.utils import TinybirdAgentContext
11
+ from tinybird.tb.modules.project import Project
12
+
13
+
14
+ class TestingAgent:
15
+ def __init__(
16
+ self,
17
+ token: str,
18
+ user_token: str,
19
+ host: str,
20
+ workspace_id: str,
21
+ project: Project,
22
+ dangerously_skip_permissions: bool,
23
+ prompt_mode: bool,
24
+ thinking_animation: ThinkingAnimation,
25
+ ):
26
+ self.token = token
27
+ self.user_token = user_token
28
+ self.host = host
29
+ self.dangerously_skip_permissions = dangerously_skip_permissions or prompt_mode
30
+ self.project = project
31
+ self.thinking_animation = thinking_animation
32
+ self.messages: list[ModelMessage] = []
33
+ self.agent = Agent(
34
+ model=create_model(user_token, host, workspace_id),
35
+ deps_type=TinybirdAgentContext,
36
+ instructions=[
37
+ """
38
+ You are part of Tinybird Code, an agentic CLI that can help users to work with Tinybird.
39
+ You are a sub-agent of the main Tinybird Code agent. You are responsible for managing test files.
40
+ You can do the following:
41
+ - Create new test files.
42
+ - Update existing test files.
43
+ - Run tests.
44
+ """,
45
+ tone_and_style_instructions,
46
+ test_instructions,
47
+ ],
48
+ tools=[
49
+ Tool(create_tests_tool, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
50
+ Tool(run_tests_tool, docstring_format="google", require_parameter_descriptions=True, takes_ctx=True),
51
+ ],
52
+ )
53
+
54
+ @self.agent.instructions
55
+ def get_tests_files(ctx: RunContext[TinybirdAgentContext]) -> str:
56
+ return tests_files_prompt(self.project)
57
+
58
+ def run(self, task: str, deps: TinybirdAgentContext, usage: Usage):
59
+ result = self.agent.run_sync(task, deps=deps, usage=usage, message_history=self.messages)
60
+ new_messages = result.new_messages()
61
+ self.messages.extend(new_messages)
62
+ return result
@@ -56,7 +56,7 @@ def create_datafile(ctx: RunContext[TinybirdAgentContext], resource: Datafile) -
56
56
  action_text = "created" if not exists else "updated"
57
57
  click.echo(FeedbackManager.success(message=f"✓ {resource.pathname} {action_text}"))
58
58
  ctx.deps.thinking_animation.start()
59
- return f"{action_text} {resource.pathname}"
59
+ return f"{action_text} {resource.pathname}. Project built successfully."
60
60
  except AgentRunCancelled as e:
61
61
  raise e
62
62
  except CLIBuildException as e:
@@ -6,6 +6,20 @@ from tinybird.tb.modules.agent.utils import TinybirdAgentContext
6
6
  from tinybird.tb.modules.common import echo_safe_humanfriendly_tables_format_pretty_table
7
7
  from tinybird.tb.modules.feedback_manager import FeedbackManager
8
8
 
9
+ forbidden_commands = [
10
+ "currentDatabase()",
11
+ "create table",
12
+ "insert into",
13
+ "create database",
14
+ "show tables",
15
+ "show datasources",
16
+ "truncate table",
17
+ "delete from",
18
+ "system.tables",
19
+ "system.datasources",
20
+ "information_schema.tables",
21
+ ]
22
+
9
23
 
10
24
  def execute_query(ctx: RunContext[TinybirdAgentContext], query: str, task: str, cloud: bool = False):
11
25
  """Execute a query:
@@ -19,6 +33,10 @@ def execute_query(ctx: RunContext[TinybirdAgentContext], query: str, task: str,
19
33
  str: The result of the query.
20
34
  """
21
35
  try:
36
+ for forbidden_command in forbidden_commands:
37
+ if forbidden_command in query.lower():
38
+ return f"Error executing query: {forbidden_command} is not allowed."
39
+
22
40
  cloud_or_local = "cloud" if cloud else "local"
23
41
  ctx.deps.thinking_animation.stop()
24
42
 
@@ -0,0 +1,38 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from pydantic_ai import RunContext
5
+
6
+ from tinybird.tb.modules.agent.utils import AgentRunCancelled, TinybirdAgentContext, show_confirmation, show_input
7
+ from tinybird.tb.modules.feedback_manager import FeedbackManager
8
+
9
+
10
+ def run_command(ctx: RunContext[TinybirdAgentContext], command: str):
11
+ """Run a tinybird CLI command with the given arguments
12
+
13
+ Args:
14
+ command (str): The command to run. Required. Examples: `tb --local sql "select 1"`, `tb --cloud datasource ls`, `tb --help`
15
+ """
16
+ try:
17
+ ctx.deps.thinking_animation.stop()
18
+ confirmation = show_confirmation(
19
+ title=f"Run command: {command}?", skip_confirmation=ctx.deps.dangerously_skip_permissions
20
+ )
21
+
22
+ if confirmation == "review":
23
+ feedback = show_input(ctx.deps.workspace_name)
24
+ ctx.deps.thinking_animation.start()
25
+ return f"User did not confirm the command and gave the following feedback: {feedback}"
26
+
27
+ click.echo(FeedbackManager.highlight(message=f"» Running command: {command}"))
28
+ command = command.replace("tb", "tb --no-version-warning")
29
+ result = subprocess.run(command, shell=True, capture_output=True, text=True)
30
+ click.echo(result.stdout)
31
+ ctx.deps.thinking_animation.start()
32
+ return result.stdout
33
+ except AgentRunCancelled as e:
34
+ raise e
35
+ except Exception as e:
36
+ click.echo(FeedbackManager.error(message=f"Error running command: {e}"))
37
+ ctx.deps.thinking_animation.start()
38
+ return f"Error running command: {e}"
@@ -11,7 +11,9 @@ from tinybird.tb.modules.agent.utils import (
11
11
  show_confirmation,
12
12
  show_input,
13
13
  )
14
+ from tinybird.tb.modules.exceptions import CLIBuildException
14
15
  from tinybird.tb.modules.feedback_manager import FeedbackManager
16
+ from tinybird.tb.modules.test_common import dump_tests, parse_tests
15
17
 
16
18
 
17
19
  def create_tests(ctx: RunContext[TinybirdAgentContext], pipe_name: str, test_content: str) -> str:
@@ -27,11 +29,32 @@ def create_tests(ctx: RunContext[TinybirdAgentContext], pipe_name: str, test_con
27
29
  running_tests = False
28
30
  try:
29
31
  ctx.deps.thinking_animation.stop()
32
+ ctx.deps.build_project_test(silent=True)
30
33
  path = Path(ctx.deps.folder) / "tests" / f"{pipe_name}.yaml"
31
34
  current_test_content: Optional[str] = None
32
35
  if path.exists():
33
36
  current_test_content = path.read_text()
34
37
 
38
+ pipe_tests_content = parse_tests(test_content)
39
+ for test in pipe_tests_content:
40
+ test_params = test["parameters"].split("?")[1] if "?" in test["parameters"] else test["parameters"]
41
+ response = None
42
+ try:
43
+ response = ctx.deps.get_pipe_data_test(pipe_name=pipe_name, test_params=test_params)
44
+ except Exception:
45
+ continue
46
+
47
+ if response.status_code >= 400:
48
+ test["expected_http_status"] = response.status_code
49
+ test["expected_result"] = response.json()["error"]
50
+ else:
51
+ if "expected_http_status" in test:
52
+ del test["expected_http_status"]
53
+
54
+ test["expected_result"] = response.text or ""
55
+
56
+ test_content = dump_tests(pipe_tests_content)
57
+
35
58
  if current_test_content:
36
59
  content = create_terminal_box(current_test_content, new_content=test_content, title=path.name)
37
60
  else:
@@ -107,6 +130,11 @@ def run_tests(ctx: RunContext[TinybirdAgentContext], pipe_name: Optional[str] =
107
130
  return f"All tests in the project ran successfully\n{test_output}"
108
131
  except AgentRunCancelled as e:
109
132
  raise e
133
+ except CLIBuildException as e:
134
+ ctx.deps.thinking_animation.stop()
135
+ click.echo(FeedbackManager.error(message=e))
136
+ ctx.deps.thinking_animation.start()
137
+ return f"Error building project: {e}"
110
138
  except Exception as e:
111
139
  error_message = str(e)
112
140
  test_exit_code = "test_error__error__"
@@ -20,6 +20,7 @@ from prompt_toolkit.shortcuts import PromptSession
20
20
  from prompt_toolkit.styles import Style as PromptStyle
21
21
  from pydantic import BaseModel, Field
22
22
  from pydantic_ai import RunContext
23
+ from requests import Response
23
24
 
24
25
  from tinybird.tb.modules.agent.memory import load_history
25
26
  from tinybird.tb.modules.feedback_manager import FeedbackManager
@@ -40,6 +41,7 @@ class TinybirdAgentContext(BaseModel):
40
41
  get_project_files: Callable[[], List[str]]
41
42
  explore_data: Callable[[str], str]
42
43
  build_project: Callable[..., None]
44
+ build_project_test: Callable[..., None]
43
45
  deploy_project: Callable[[], None]
44
46
  deploy_check_project: Callable[[], None]
45
47
  mock_data: Callable[..., list[dict[str, Any]]]
@@ -50,6 +52,7 @@ class TinybirdAgentContext(BaseModel):
50
52
  execute_query_local: Callable[..., dict[str, Any]]
51
53
  request_endpoint_cloud: Callable[..., dict[str, Any]]
52
54
  request_endpoint_local: Callable[..., dict[str, Any]]
55
+ get_pipe_data_test: Callable[..., Response]
53
56
  get_datasource_datafile_cloud: Callable[..., str]
54
57
  get_datasource_datafile_local: Callable[..., str]
55
58
  get_pipe_datafile_cloud: Callable[..., str]
@@ -714,13 +717,15 @@ class AgentRunCancelled(Exception):
714
717
  pass
715
718
 
716
719
 
717
- def show_confirmation(title: str, skip_confirmation: bool = False) -> ConfirmationResult:
720
+ def show_confirmation(title: str, skip_confirmation: bool = False, show_review: bool = True) -> ConfirmationResult:
718
721
  if skip_confirmation:
719
722
  return "yes"
720
723
 
721
724
  while True:
722
725
  result = show_options(
723
- options=["Yes, continue", "No, tell Tinybird Code what to do", "Cancel"],
726
+ options=["Yes, continue", "No, tell Tinybird Code what to do", "Cancel"]
727
+ if show_review
728
+ else ["Yes, continue", "Cancel"],
724
729
  title=title,
725
730
  )
726
731
 
@@ -399,21 +399,23 @@ def create_ctx_client(ctx: Context, config: Dict[str, Any], cloud: bool, staging
399
399
  command_always_test = ["test"]
400
400
 
401
401
  if (
402
- show_warnings
403
- and (cloud or command in commands_always_cloud)
402
+ (cloud or command in commands_always_cloud)
404
403
  and command not in commands_always_local
405
404
  and command not in command_always_test
406
405
  ):
407
- click.echo(
408
- FeedbackManager.gray(message=f"Running against Tinybird Cloud: Workspace {config.get('name', 'default')}")
409
- )
406
+ if show_warnings:
407
+ click.echo(
408
+ FeedbackManager.gray(
409
+ message=f"Running against Tinybird Cloud: Workspace {config.get('name', 'default')}"
410
+ )
411
+ )
410
412
 
411
413
  method = None
412
414
  if ctx.params.get("token"):
413
415
  method = "token via --token option"
414
416
  elif os.environ.get("TB_TOKEN"):
415
417
  method = "token from TB_TOKEN environment variable"
416
- if method:
418
+ if method and show_warnings:
417
419
  click.echo(FeedbackManager.gray(message=f"Authentication method: {method}"))
418
420
 
419
421
  return _get_tb_client(config.get("token", ""), config["host"], staging=staging)
@@ -95,7 +95,7 @@ def datasource_ls(ctx: Context, match: Optional[str], format_: str):
95
95
  shared_from,
96
96
  name,
97
97
  humanfriendly.format_number(stats.get("row_count")) if stats.get("row_count", None) else "-",
98
- humanfriendly.format_size(int(stats.get("bytes"))) if stats.get("bytes", None) else "-",
98
+ humanfriendly.format_size(int(stats.get("bytes", 0))) if stats.get("bytes", None) else "-",
99
99
  t["created_at"][:-7],
100
100
  t["updated_at"][:-7],
101
101
  t.get("service", ""),
@@ -455,6 +455,8 @@ def datasource_truncate(ctx, datasource_name, yes, cascade):
455
455
  except Exception as e:
456
456
  raise CLIDatasourceException(FeedbackManager.error_exception(error=e))
457
457
  click.echo(FeedbackManager.success_truncate_datasource(datasource=cascade_ds))
458
+ else:
459
+ click.echo(FeedbackManager.info(message="Operation cancelled by user"))
458
460
 
459
461
 
460
462
  @datasource.command(name="delete")
@@ -133,6 +133,15 @@ def create_test(
133
133
  return tests
134
134
 
135
135
 
136
+ def parse_tests(tests_content: str) -> List[Dict[str, Any]]:
137
+ return yaml.safe_load(tests_content)
138
+
139
+
140
+ def dump_tests(tests: List[Dict[str, Any]]) -> str:
141
+ yaml_str = yaml.safe_dump(tests, sort_keys=False)
142
+ return yaml_str.replace("- name:", "\n- name:")
143
+
144
+
136
145
  def update_test(pipe: str, project: Project, client: TinyB) -> None:
137
146
  try:
138
147
  folder = project.folder
@@ -149,7 +158,7 @@ def update_test(pipe: str, project: Project, client: TinyB) -> None:
149
158
 
150
159
  click.echo(FeedbackManager.highlight(message=f"\n» Updating tests expectations for {pipe_name} endpoint..."))
151
160
  pipe_tests_path = Path(project.folder) / pipe_tests_path
152
- pipe_tests_content = yaml.safe_load(pipe_tests_path.read_text())
161
+ pipe_tests_content = parse_tests(pipe_tests_path.read_text())
153
162
  for test in pipe_tests_content:
154
163
  test_params = test["parameters"].split("?")[1] if "?" in test["parameters"] else test["parameters"]
155
164
  response = None
@@ -197,7 +206,7 @@ def run_tests(name: Tuple[str, ...], project: Project, client: TinyB) -> None:
197
206
  def run_test(test_file) -> Optional[str]:
198
207
  test_file_path = Path(test_file)
199
208
  click.echo(FeedbackManager.info(message=f"* {test_file_path.stem}{test_file_path.suffix}"))
200
- test_file_content = yaml.safe_load(test_file_path.read_text())
209
+ test_file_content = parse_tests(test_file_path.read_text())
201
210
  test_file_errors = ""
202
211
  for test in test_file_content:
203
212
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: tinybird
3
- Version: 0.0.1.dev262
3
+ Version: 0.0.1.dev263
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/forward/commands
6
6
  Author: Tinybird
@@ -12,12 +12,12 @@ tinybird/syncasync.py,sha256=IPnOx6lMbf9SNddN1eBtssg8vCLHMt76SuZ6YNYm-Yk,27761
12
12
  tinybird/tornado_template.py,sha256=jjNVDMnkYFWXflmT8KU_Ssbo5vR8KQq3EJMk5vYgXRw,41959
13
13
  tinybird/ch_utils/constants.py,sha256=fPgZtwbr1ymxaW7uqVWHKmAbt7uGj3SxCCS3xsEMJqA,4151
14
14
  tinybird/ch_utils/engine.py,sha256=4X1B-iuhdW_mxKnX_m3iCsxgP9RPVgR75g7yH1vsJ6A,40851
15
- tinybird/datafile/common.py,sha256=Xd_abjiTAK1oGxg2T59rLePNEdGMwSqe8PnoWEBJLyk,99161
15
+ tinybird/datafile/common.py,sha256=lN-fNCaxtFWQAd0WN5Q-mgljM7F8sYOBQcU1JJogw1g,105324
16
16
  tinybird/datafile/exceptions.py,sha256=8rw2umdZjtby85QbuRKFO5ETz_eRHwUY5l7eHsy1wnI,556
17
17
  tinybird/datafile/parse_connection.py,sha256=tRyn2Rpr1TeWet5BXmMoQgaotbGdYep1qiTak_OqC5E,1825
18
18
  tinybird/datafile/parse_datasource.py,sha256=ssW8QeFSgglVFi3sDZj_HgkJiTJ2069v2JgqnH3CkDE,1825
19
19
  tinybird/datafile/parse_pipe.py,sha256=xf4m0Tw44QWJzHzAm7Z7FwUoUUtr7noMYjU1NiWnX0k,3880
20
- tinybird/tb/__cli__.py,sha256=0prVU9s5VBR_0ep0okT2MF0GMOH7JG72BdEplvxz_Mg,247
20
+ tinybird/tb/__cli__.py,sha256=TzjK12wvsWtuLOWcJjdir-py6u6IeFKVGg35GyNA3z4,247
21
21
  tinybird/tb/check_pypi.py,sha256=Gp0HkHHDFMSDL6nxKlOY51z7z1Uv-2LRexNTZSHHGmM,552
22
22
  tinybird/tb/cli.py,sha256=FdDFEIayjmsZEVsVSSvRiVYn_FHOVg_zWQzchnzfWho,1008
23
23
  tinybird/tb/client.py,sha256=pJbdkWMXGAqKseNAvdsRRnl_c7I-DCMB0dWCQnG82nU,54146
@@ -25,13 +25,13 @@ tinybird/tb/config.py,sha256=mhMTGnMB5KcxGoh3dewIr2Jjsa6pHE183gCPAQWyp6o,3973
25
25
  tinybird/tb/modules/build.py,sha256=efD-vamK1NPaDo9R86Hn8be2DYoW0Hh5bZiH7knK5dk,7790
26
26
  tinybird/tb/modules/build_common.py,sha256=dthlaDn_CuwZnedQcUi7iIdDoHWfSbbbGQwiDgNcmC0,13062
27
27
  tinybird/tb/modules/cicd.py,sha256=0KLKccha9IP749QvlXBmzdWv1On3mFwMY4DUcJlBxiE,7326
28
- tinybird/tb/modules/cli.py,sha256=baeNPOu72L4aGI6ELsY8e2_eCq8ILOhTfd4qysz3KXk,16910
28
+ tinybird/tb/modules/cli.py,sha256=qZIp8Ez4sunQ3_lIj1QGc3yOp8-SYfNHASHpE_7Ji6Y,16978
29
29
  tinybird/tb/modules/common.py,sha256=tj6DR2yOqMMQ0PILwFGXmMogxdrbQCgj36HdSM611rs,82657
30
30
  tinybird/tb/modules/config.py,sha256=gK7rgaWTDd4ZKCrNEg_Uemr26EQjqWt6TjyQKujxOws,11462
31
31
  tinybird/tb/modules/connection.py,sha256=-MY56NUAai6EMC4-wpi7bT0_nz_SA8QzTmHkV7HB1IQ,17810
32
32
  tinybird/tb/modules/copy.py,sha256=dPZkcIDvxjJrlQUIvToO0vsEEEs4EYumbNV77-BzNoU,4404
33
33
  tinybird/tb/modules/create.py,sha256=pJxHXG69c9Z_21s-7VuJ3RZOF_nJU51LEwiAkvI3dZY,23251
34
- tinybird/tb/modules/datasource.py,sha256=cxq0VVjjidxq-v_JSIIAH7L90XNRctgNKsHRoQ_42OI,41632
34
+ tinybird/tb/modules/datasource.py,sha256=pae-ENeHYIF1HHYRSOziFC-2FPLUFa0KS60YpdlKCS8,41725
35
35
  tinybird/tb/modules/deployment.py,sha256=EDEVOqFk5fp0fvvs2jV0mT5POFd-i_8uZIUREwzAbEk,13199
36
36
  tinybird/tb/modules/deployment_common.py,sha256=CswUMfp5dW0NgiuP_ZGORqvqooHFYU2ZJaxOcpRFDT0,17866
37
37
  tinybird/tb/modules/deprecations.py,sha256=rrszC1f_JJeJ8mUxGoCxckQTJFBCR8wREf4XXXN-PRc,4507
@@ -63,28 +63,30 @@ tinybird/tb/modules/sink.py,sha256=dK2s__my0ePIUYrqBzhPSgdWN9rbpvP1G4dT7DJzz80,3
63
63
  tinybird/tb/modules/table.py,sha256=4XrtjM-N0zfNtxVkbvLDQQazno1EPXnxTyo7llivfXk,11035
64
64
  tinybird/tb/modules/telemetry.py,sha256=T9gtsQffWqG_4hRBaUJPzOfMkPwz7mH-R6Bn1XRYViA,11482
65
65
  tinybird/tb/modules/test.py,sha256=O2-mS4uMU6nPi7yWPpWzshAgOlYKiGS-tkM12pXQGMI,1906
66
- tinybird/tb/modules/test_common.py,sha256=LRgR2m2AgO5BYfdhboNL6ywIrRCffXsjzSwQE7A1W2Y,12459
66
+ tinybird/tb/modules/test_common.py,sha256=YZwAdSfYVXdvArfTc9tH-2QBOhb_XbnJ3eKvyXTJuEM,12717
67
67
  tinybird/tb/modules/token.py,sha256=DkXW9FNCLGBisXewfk195jTJ6B1Iz7zq3cEEac48aAs,12731
68
68
  tinybird/tb/modules/watch.py,sha256=No0bK1M1_3CYuMaIgylxf7vYFJ72lTJe3brz6xQ-mJo,8819
69
69
  tinybird/tb/modules/workspace.py,sha256=Q_8HcxMsNg8QG9aBlwcWS2umrDP5IkTIHqqz3sfmGuc,11341
70
70
  tinybird/tb/modules/workspace_members.py,sha256=5JdkJgfuEwbq-t6vxkBhYwgsiTDxF790wsa6Xfif9nk,8608
71
71
  tinybird/tb/modules/agent/__init__.py,sha256=i3oe3vDIWWPaicdCM0zs7D7BJ1W0k7th93ooskHAV00,54
72
- tinybird/tb/modules/agent/agent.py,sha256=Ye2v5sFDDT1tL54XTcpOid9V8s9EG8hpuTCLNQyxCHk,24502
72
+ tinybird/tb/modules/agent/agent.py,sha256=cHpPZiUeW525i9PxspGoilTc-1HPAHCCzxiYdcdJQ7c,28214
73
73
  tinybird/tb/modules/agent/animations.py,sha256=4WOC5_2BracttmMCrV0H91tXfWcUzQHBUaIJc5FA7tE,3490
74
- tinybird/tb/modules/agent/banner.py,sha256=7f97PeCPW-oW9mReQn3D0by8mnDhoc0VbfebEPRPI7c,3070
74
+ tinybird/tb/modules/agent/banner.py,sha256=2UpuuIqWxS0Ltab6i_FE4dkNxlJCdgKGCMtbwQGiLA8,7185
75
+ tinybird/tb/modules/agent/command_agent.py,sha256=Q4M5qV9j3aETrXswoWZ02ZWQZhJm42HGHO1ymJrfDnw,2665
75
76
  tinybird/tb/modules/agent/memory.py,sha256=O6Kumn9AyKxcTkhI45yjAUZ3ZIAibLOcNWoiEuLYeqY,3245
76
77
  tinybird/tb/modules/agent/models.py,sha256=LW1D27gjcd_jwFmghEzteCgToDfodX2B6B5S8BYbysw,735
77
- tinybird/tb/modules/agent/prompts.py,sha256=dFpbcKFb8u8O4elwV__RcKmjvTIG84mB4fzGnwVKtzg,28124
78
- tinybird/tb/modules/agent/utils.py,sha256=1X1cjP607StB0NFuT2CsPO7fBU05eI7LGeib-mkFVms,29390
78
+ tinybird/tb/modules/agent/prompts.py,sha256=NEIk3OK0xXm3QS1Iox4T1MMo19hLhfaICMEwzqVGW7E,31069
79
+ tinybird/tb/modules/agent/testing_agent.py,sha256=mH7ccuWeTnkpENCmwym9ubDFncMXQoSo0jq2pRxJVDY,2553
80
+ tinybird/tb/modules/agent/utils.py,sha256=VYuoodb05Ucq0b3qgIAf2KhOTTRD85_Pfab0xKwbg40,29610
79
81
  tinybird/tb/modules/agent/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
80
82
  tinybird/tb/modules/agent/tools/analyze.py,sha256=CR5LXg4fou-zYEksqnjpJ0icvxJVoKnTctoI1NRvqCM,3873
81
83
  tinybird/tb/modules/agent/tools/append.py,sha256=XA8ZeqxZcRL_0ZCd5FyggpWeH53mwTMby4lHV8wQa7c,6039
82
84
  tinybird/tb/modules/agent/tools/build.py,sha256=Hm-xDAP9ckMiKquT-DmDg5H0yxZefLOaWKANyoVSaEQ,846
83
- tinybird/tb/modules/agent/tools/create_datafile.py,sha256=-Luv7GrZ3tEdEFaTFiN2_b2WEkTVl2Tp437qsvNEwZU,6902
85
+ tinybird/tb/modules/agent/tools/create_datafile.py,sha256=M2DKTSDnAMs22oSVfBagjXQOTJejf5Kp-RHd15LDKqQ,6931
84
86
  tinybird/tb/modules/agent/tools/deploy.py,sha256=2hKj6LMYiDea6ventsBjE6ArECGIryTdo3X-LYo5oZI,1248
85
87
  tinybird/tb/modules/agent/tools/deploy_check.py,sha256=2Wr9hQfKPlhqhumOv5TNl_xFctvdq_DHZ2dI2h_LggY,1048
86
88
  tinybird/tb/modules/agent/tools/diff_resource.py,sha256=_9xHcDzCTKk_E1wKQbuktVqV6U9sA0kqYaBxWvtliX0,2613
87
- tinybird/tb/modules/agent/tools/execute_query.py,sha256=h8be9_lhyMy7UZoCWrExigP-cbq3lLnlLHD9SzDGBCM,2703
89
+ tinybird/tb/modules/agent/tools/execute_query.py,sha256=egUhKqRDF9C9458-rtu-yr-OeyvS0UrEUQwUgGd0Gts,3170
88
90
  tinybird/tb/modules/agent/tools/explore.py,sha256=ihALc_kBcsjrKT3hZyicqyIowB0g_K3AtNNi-5uz9-8,412
89
91
  tinybird/tb/modules/agent/tools/get_endpoint_stats.py,sha256=LiEK6ToyPDW2aI8ijclzuwdYAcFmwH-TyjqdFEzQWAc,1689
90
92
  tinybird/tb/modules/agent/tools/get_openapi_definition.py,sha256=mjIVVXtgvTs5LzOR8Bp4jB1XhLVMysHrHXawkErFdt8,2282
@@ -92,7 +94,8 @@ tinybird/tb/modules/agent/tools/mock.py,sha256=4JJ_45uWkbDhTGeKpwDv8L07ewsbF8u7q
92
94
  tinybird/tb/modules/agent/tools/plan.py,sha256=2KHLNkr2f1RfkbAR4mCVsv94LGosXd8-ky7v6BB1OtQ,985
93
95
  tinybird/tb/modules/agent/tools/preview_datafile.py,sha256=Gbao_FxhXstnUnngVQxztpizjugyfx1rOXTkw7Yabls,858
94
96
  tinybird/tb/modules/agent/tools/request_endpoint.py,sha256=xNYIEFI9sfq3SjevYqJaHH1EP6uEX5HHrbKS6qo3Imo,2852
95
- tinybird/tb/modules/agent/tools/test.py,sha256=0gXTfx6Yj1ZPDNDkKJj0RRhA6RQr6UtTM43KsHrdR_Y,4813
97
+ tinybird/tb/modules/agent/tools/run_command.py,sha256=321w5dYOnapMf-TFIkgizErllwB5Poc0RYOjBAQeMPc,1576
98
+ tinybird/tb/modules/agent/tools/test.py,sha256=CbGak_coopCTtqHoPWy-BwgLMIyEyeO34NTNkv18au4,6041
96
99
  tinybird/tb/modules/datafile/build.py,sha256=NFKBrusFLU0WJNCXePAFWiEDuTaXpwc0lHlOQWEJ43s,51117
97
100
  tinybird/tb/modules/datafile/build_common.py,sha256=2yNdxe49IMA9wNvl25NemY2Iaz8L66snjOdT64dm1is,4511
98
101
  tinybird/tb/modules/datafile/build_datasource.py,sha256=Ra8pVQBDafbFRUKlhpgohhTsRyp_ADKZJVG8Gd69idY,17227
@@ -113,8 +116,8 @@ tinybird/tb_cli_modules/config.py,sha256=IsgdtFRnUrkY8-Zo32lmk6O7u3bHie1QCxLwgp4
113
116
  tinybird/tb_cli_modules/exceptions.py,sha256=pmucP4kTF4irIt7dXiG-FcnI-o3mvDusPmch1L8RCWk,3367
114
117
  tinybird/tb_cli_modules/regions.py,sha256=QjsL5H6Kg-qr0aYVLrvb1STeJ5Sx_sjvbOYO0LrEGMk,166
115
118
  tinybird/tb_cli_modules/telemetry.py,sha256=Hh2Io8ZPROSunbOLuMvuIFU4TqwWPmQTqal4WS09K1A,10449
116
- tinybird-0.0.1.dev262.dist-info/METADATA,sha256=1iT43WWQIeJ-m391cjSMdLIL5aHzrSHDXlIs1us6bfM,1733
117
- tinybird-0.0.1.dev262.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
118
- tinybird-0.0.1.dev262.dist-info/entry_points.txt,sha256=LwdHU6TfKx4Qs7BqqtaczEZbImgU7Abe9Lp920zb_fo,43
119
- tinybird-0.0.1.dev262.dist-info/top_level.txt,sha256=VqqqEmkAy7UNaD8-V51FCoMMWXjLUlR0IstvK7tJYVY,54
120
- tinybird-0.0.1.dev262.dist-info/RECORD,,
119
+ tinybird-0.0.1.dev263.dist-info/METADATA,sha256=CPGH7Rh1ytBwX3FCTvtbnd4WFYcslMn-MVrbdOqAEF8,1733
120
+ tinybird-0.0.1.dev263.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
121
+ tinybird-0.0.1.dev263.dist-info/entry_points.txt,sha256=LwdHU6TfKx4Qs7BqqtaczEZbImgU7Abe9Lp920zb_fo,43
122
+ tinybird-0.0.1.dev263.dist-info/top_level.txt,sha256=VqqqEmkAy7UNaD8-V51FCoMMWXjLUlR0IstvK7tJYVY,54
123
+ tinybird-0.0.1.dev263.dist-info/RECORD,,