tinybird 0.0.1.dev26__py3-none-any.whl → 0.0.1.dev28__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.

Files changed (36) hide show
  1. tinybird/config.py +1 -1
  2. tinybird/datatypes.py +46 -57
  3. tinybird/git_settings.py +4 -4
  4. tinybird/prompts.py +647 -0
  5. tinybird/sql.py +9 -0
  6. tinybird/sql_toolset.py +17 -3
  7. tinybird/syncasync.py +1 -1
  8. tinybird/tb/__cli__.py +2 -2
  9. tinybird/tb/cli.py +2 -0
  10. tinybird/tb/modules/build.py +44 -16
  11. tinybird/tb/modules/build_server.py +75 -0
  12. tinybird/tb/modules/cli.py +22 -0
  13. tinybird/tb/modules/common.py +2 -2
  14. tinybird/tb/modules/config.py +13 -14
  15. tinybird/tb/modules/create.py +145 -134
  16. tinybird/tb/modules/datafile/build.py +28 -0
  17. tinybird/tb/modules/datafile/common.py +1 -0
  18. tinybird/tb/modules/datafile/fixture.py +10 -6
  19. tinybird/tb/modules/datafile/parse_pipe.py +2 -0
  20. tinybird/tb/modules/datasource.py +1 -1
  21. tinybird/tb/modules/deploy.py +254 -0
  22. tinybird/tb/modules/llm.py +32 -16
  23. tinybird/tb/modules/llm_utils.py +24 -0
  24. tinybird/tb/modules/local.py +2 -2
  25. tinybird/tb/modules/login.py +8 -6
  26. tinybird/tb/modules/mock.py +11 -6
  27. tinybird/tb/modules/test.py +69 -47
  28. tinybird/tb/modules/watch.py +1 -1
  29. tinybird/tb_cli_modules/common.py +2 -2
  30. tinybird/tb_cli_modules/config.py +5 -5
  31. tinybird/tornado_template.py +1 -3
  32. {tinybird-0.0.1.dev26.dist-info → tinybird-0.0.1.dev28.dist-info}/METADATA +1 -1
  33. {tinybird-0.0.1.dev26.dist-info → tinybird-0.0.1.dev28.dist-info}/RECORD +36 -33
  34. {tinybird-0.0.1.dev26.dist-info → tinybird-0.0.1.dev28.dist-info}/WHEEL +0 -0
  35. {tinybird-0.0.1.dev26.dist-info → tinybird-0.0.1.dev28.dist-info}/entry_points.txt +0 -0
  36. {tinybird-0.0.1.dev26.dist-info → tinybird-0.0.1.dev28.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,254 @@
1
+ import glob
2
+ import json
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ import click
9
+ import requests
10
+
11
+ from tinybird.tb.modules.cli import cli
12
+ from tinybird.tb.modules.common import echo_safe_humanfriendly_tables_format_smart_table
13
+ from tinybird.tb.modules.config import CLIConfig
14
+ from tinybird.tb.modules.feedback_manager import FeedbackManager
15
+
16
+
17
+ def project_files(project_path: Path) -> List[str]:
18
+ project_file_extensions = ("datasource", "pipe")
19
+ project_files = []
20
+ for extension in project_file_extensions:
21
+ for project_file in glob.glob(f"{project_path}/**/*.{extension}", recursive=True):
22
+ logging.debug(f"Found project file: {project_file}")
23
+ project_files.append(project_file)
24
+ return project_files
25
+
26
+
27
+ def promote_deployment(host: str, headers: dict) -> None:
28
+ TINYBIRD_API_URL = host + "/v1/deployments"
29
+ r = requests.get(TINYBIRD_API_URL, headers=headers)
30
+ result = r.json()
31
+ logging.debug(json.dumps(result, indent=2))
32
+
33
+ deployments = result.get("deployments")
34
+ if not deployments:
35
+ click.echo(FeedbackManager.error(message="No deployments found"))
36
+ return
37
+
38
+ if len(deployments) < 2:
39
+ click.echo(FeedbackManager.error(message="Only one deployment found"))
40
+ return
41
+
42
+ last_deployment, candidate_deployment = deployments[0], deployments[1]
43
+
44
+ if candidate_deployment.get("status") != "data_ready":
45
+ click.echo(FeedbackManager.error(message="Current deployment is not ready"))
46
+ return
47
+
48
+ if candidate_deployment.get("live"):
49
+ click.echo(FeedbackManager.error(message="Candidate deployment is already live"))
50
+ else:
51
+ click.echo(FeedbackManager.success(message="Promoting deployment"))
52
+
53
+ TINYBIRD_API_URL = host + f"/v1/deployments/{candidate_deployment.get('id')}/set-live"
54
+ r = requests.post(TINYBIRD_API_URL, headers=headers)
55
+ result = r.json()
56
+ logging.debug(json.dumps(result, indent=2))
57
+
58
+ click.echo(FeedbackManager.success(message="Removing old deployment"))
59
+
60
+ TINYBIRD_API_URL = host + f"/v1/deployments/{last_deployment.get('id')}"
61
+ r = requests.delete(TINYBIRD_API_URL, headers=headers)
62
+ result = r.json()
63
+ logging.debug(json.dumps(result, indent=2))
64
+
65
+ click.echo(FeedbackManager.success(message="Deployment promoted successfully"))
66
+
67
+
68
+ def rollback_deployment(host: str, headers: dict) -> None:
69
+ TINYBIRD_API_URL = host + "/v1/deployments"
70
+ r = requests.get(TINYBIRD_API_URL, headers=headers)
71
+ result = r.json()
72
+ logging.debug(json.dumps(result, indent=2))
73
+
74
+ deployments = result.get("deployments")
75
+ if not deployments:
76
+ click.echo(FeedbackManager.error(message="No deployments found"))
77
+ return
78
+
79
+ if len(deployments) < 2:
80
+ click.echo(FeedbackManager.error(message="Only one deployment found"))
81
+ return
82
+
83
+ previous_deployment, current_deployment = deployments[0], deployments[1]
84
+
85
+ if previous_deployment.get("status") != "data_ready":
86
+ click.echo(FeedbackManager.error(message="Previous deployment is not ready"))
87
+ return
88
+
89
+ if previous_deployment.get("live"):
90
+ click.echo(FeedbackManager.error(message="Previous deployment is already live"))
91
+ else:
92
+ click.echo(FeedbackManager.success(message="Promoting previous deployment"))
93
+
94
+ TINYBIRD_API_URL = host + f"/v1/deployments/{previous_deployment.get('id')}/set-live"
95
+ r = requests.post(TINYBIRD_API_URL, headers=headers)
96
+ result = r.json()
97
+ logging.debug(json.dumps(result, indent=2))
98
+
99
+ click.echo(FeedbackManager.success(message="Removing current deployment"))
100
+
101
+ TINYBIRD_API_URL = host + f"/v1/deployments/{current_deployment.get('id')}"
102
+ r = requests.delete(TINYBIRD_API_URL, headers=headers)
103
+ result = r.json()
104
+ logging.debug(json.dumps(result, indent=2))
105
+
106
+ click.echo(FeedbackManager.success(message="Deployment rolled back successfully"))
107
+
108
+
109
+ @cli.command()
110
+ @click.argument("project_path", type=click.Path(exists=True), default=Path.cwd())
111
+ @click.option(
112
+ "--wait/--no-wait",
113
+ is_flag=True,
114
+ default=False,
115
+ help="Wait for deploy to finish. Disabled by default.",
116
+ )
117
+ @click.option(
118
+ "--auto/--no-auto",
119
+ is_flag=True,
120
+ default=False,
121
+ help="Auto-promote the deployment. Only works if --wait is enabled. Disabled by default.",
122
+ )
123
+ def deploy(project_path: Path, wait: bool, auto: bool) -> None:
124
+ """
125
+ Validate and deploy the project server side.
126
+ """
127
+ # TODO: This code is duplicated in build_server.py
128
+ # Should be refactored to be shared
129
+ MULTIPART_BOUNDARY_DATA_PROJECT = "data_project://"
130
+ DATAFILE_TYPE_TO_CONTENT_TYPE = {
131
+ ".datasource": "text/plain",
132
+ ".pipe": "text/plain",
133
+ }
134
+
135
+ config = CLIConfig.get_project_config(str(project_path))
136
+ TINYBIRD_API_URL = (config.get_host() or "") + "/v1/deploy"
137
+ TINYBIRD_API_KEY = config.get_token()
138
+
139
+ files = [
140
+ ("context://", ("cli-version", "1.0.0", "text/plain")),
141
+ ]
142
+ fds = []
143
+ for file_path in project_files(project_path):
144
+ relative_path = str(Path(file_path).relative_to(project_path))
145
+ fd = open(file_path, "rb")
146
+ fds.append(fd)
147
+ content_type = DATAFILE_TYPE_TO_CONTENT_TYPE.get(Path(file_path).suffix, "application/unknown")
148
+ files.append((MULTIPART_BOUNDARY_DATA_PROJECT, (relative_path, fd.read().decode("utf-8"), content_type)))
149
+
150
+ deployment = None
151
+ try:
152
+ HEADERS = {"Authorization": f"Bearer {TINYBIRD_API_KEY}"}
153
+
154
+ r = requests.post(TINYBIRD_API_URL, files=files, headers=HEADERS)
155
+ result = r.json()
156
+ logging.debug(json.dumps(result, indent=2))
157
+
158
+ deploy_result = result.get("result")
159
+ if deploy_result == "success":
160
+ click.echo(FeedbackManager.success(message="Deploy submitted successfully"))
161
+ deployment = result.get("deployment")
162
+ elif deploy_result == "failed":
163
+ click.echo(FeedbackManager.error(message="Deploy failed"))
164
+ deploy_errors = result.get("errors")
165
+ for deploy_error in deploy_errors:
166
+ if deploy_error.get("filename", None):
167
+ click.echo(
168
+ FeedbackManager.error(message=f"{deploy_error.get('filename')}\n\n{deploy_error.get('error')}")
169
+ )
170
+ else:
171
+ click.echo(FeedbackManager.error(message=f"{deploy_error.get('error')}"))
172
+ else:
173
+ click.echo(FeedbackManager.error(message=f"Unknown build result {deploy_result}"))
174
+ finally:
175
+ for fd in fds:
176
+ fd.close()
177
+
178
+ if deployment and wait:
179
+ while deployment.get("status") != "data_ready":
180
+ time.sleep(5)
181
+ TINYBIRD_API_URL = (config.get_host() or "") + f"/v1/deployments/{deployment.get('id')}"
182
+ r = requests.get(TINYBIRD_API_URL, headers=HEADERS)
183
+ result = r.json()
184
+ deployment = result.get("deployment")
185
+ if deployment.get("status") == "failed":
186
+ click.echo(FeedbackManager.error(message="Deployment failed"))
187
+ return
188
+
189
+ click.echo(FeedbackManager.success(message="Deployment is ready"))
190
+
191
+ if auto:
192
+ promote_deployment((config.get_host() or ""), HEADERS)
193
+
194
+
195
+ @cli.group(name="releases")
196
+ def releases_group() -> None:
197
+ """
198
+ Release commands.
199
+ """
200
+ pass
201
+
202
+
203
+ @releases_group.command(name="list")
204
+ @click.argument("project_path", type=click.Path(exists=True), default=Path.cwd())
205
+ def release_list(project_path: Path) -> None:
206
+ """
207
+ List all the releases you have in the project.
208
+ """
209
+ config = CLIConfig.get_project_config(str(project_path))
210
+
211
+ TINYBIRD_API_KEY = config.get_token()
212
+ HEADERS = {"Authorization": f"Bearer {TINYBIRD_API_KEY}"}
213
+ TINYBIRD_API_URL = (config.get_host() or "") + "/v1/deployments"
214
+
215
+ r = requests.get(TINYBIRD_API_URL, headers=HEADERS)
216
+ result = r.json()
217
+ logging.debug(json.dumps(result, indent=2))
218
+
219
+ columns = ["id", "status", "created_at", "live"]
220
+ table = []
221
+ for deployment in result.get("deployments"):
222
+ table.append(
223
+ [deployment.get("id"), deployment.get("status"), deployment.get("created_at"), deployment.get("live")]
224
+ )
225
+
226
+ echo_safe_humanfriendly_tables_format_smart_table(table, column_names=columns)
227
+
228
+
229
+ @releases_group.command(name="promote")
230
+ @click.argument("project_path", type=click.Path(exists=True), default=Path.cwd())
231
+ def release_promote(project_path: Path) -> None:
232
+ """
233
+ Promote last deploy to ready and remove old one.
234
+ """
235
+ config = CLIConfig.get_project_config(str(project_path))
236
+
237
+ TINYBIRD_API_KEY = config.get_token()
238
+ HEADERS = {"Authorization": f"Bearer {TINYBIRD_API_KEY}"}
239
+
240
+ promote_deployment((config.get_host() or ""), HEADERS)
241
+
242
+
243
+ @releases_group.command(name="rollback")
244
+ @click.argument("project_path", type=click.Path(exists=True), default=Path.cwd())
245
+ def release_rollback(project_path: Path) -> None:
246
+ """
247
+ Rollback to the previous release.
248
+ """
249
+ config = CLIConfig.get_project_config(str(project_path))
250
+
251
+ TINYBIRD_API_KEY = config.get_token()
252
+ HEADERS = {"Authorization": f"Bearer {TINYBIRD_API_KEY}"}
253
+
254
+ rollback_deployment((config.get_host() or ""), HEADERS)
@@ -1,10 +1,8 @@
1
- import asyncio
2
1
  import json
3
2
  import urllib.parse
4
3
  from copy import deepcopy
5
- from typing import Awaitable, Callable, List, Optional
4
+ from typing import List
6
5
 
7
- from openai import OpenAI
8
6
  from pydantic import BaseModel
9
7
 
10
8
  from tinybird.client import TinyB
@@ -31,25 +29,43 @@ class TestExpectations(BaseModel):
31
29
 
32
30
 
33
31
  class LLM:
34
- def __init__(self, user_token: str, client: TinyB, api_key: Optional[str] = None):
32
+ def __init__(
33
+ self,
34
+ user_token: str,
35
+ client: TinyB,
36
+ ):
35
37
  self.user_client = deepcopy(client)
36
38
  self.user_client.token = user_token
37
39
 
38
- self.openai = OpenAI(api_key=api_key) if api_key else None
40
+ async def ask(self, prompt: str, system_prompt: str = "", model: str = "o1-mini") -> str:
41
+ """
42
+ Calls the model with the given prompt and returns the response.
39
43
 
40
- async def _execute(self, action_fn: Callable[[], Awaitable[str]], checker_fn: Callable[[str], bool]):
41
- is_valid = False
42
- times = 0
44
+ Args:
45
+ prompt (str): The user prompt to send to the model.
43
46
 
44
- while not is_valid and times < 5:
45
- result = await action_fn()
46
- if asyncio.iscoroutinefunction(checker_fn):
47
- is_valid = await checker_fn(result)
48
- else:
49
- is_valid = checker_fn(result)
50
- times += 1
47
+ Returns:
48
+ str: The response from the language model.
49
+ """
50
+ messages = []
51
51
 
52
- return result
52
+ if system_prompt:
53
+ messages.append({"role": "user", "content": system_prompt})
54
+
55
+ if prompt:
56
+ messages.append({"role": "user", "content": prompt})
57
+
58
+ data = {
59
+ "model": model,
60
+ "messages": messages,
61
+ }
62
+ response = await self.user_client._req(
63
+ "/v0/llm",
64
+ method="POST",
65
+ data=json.dumps(data),
66
+ headers={"Content-Type": "application/json"},
67
+ )
68
+ return response.get("result", "")
53
69
 
54
70
  async def create_project(self, prompt: str) -> DataProject:
55
71
  try:
@@ -0,0 +1,24 @@
1
+ import re
2
+ from typing import List
3
+
4
+
5
+ def extract_xml(text: str, tag: str) -> str:
6
+ """
7
+ Extracts the content of the specified XML tag from the given text. Used for parsing structured responses
8
+
9
+ Args:
10
+ text (str): The text containing the XML.
11
+ tag (str): The XML tag to extract content from.
12
+
13
+ Returns:
14
+ str: The content of the specified XML tag, or an empty string if the tag is not found.
15
+ """
16
+ match = re.search(f"<{tag}>(.*?)</{tag}>", text, re.DOTALL)
17
+ return match.group(1) if match else ""
18
+
19
+
20
+ def parse_xml(text: str, tag: str) -> List[str]:
21
+ """
22
+ Parses the text for the specified XML tag and returns a list of the contents of each tag.
23
+ """
24
+ return re.findall(f"<{tag}.*?>(.*?)</{tag}>", text, re.DOTALL)
@@ -65,7 +65,7 @@ def start_tinybird_local(
65
65
  if health == "healthy":
66
66
  break
67
67
  if health == "unhealthy":
68
- raise CLIException("Tinybird Local is unhealthy. Please try running `tb local restart` in a few seconds.")
68
+ raise CLIException("Tinybird Local is unhealthy. Try running `tb local restart` in a few seconds.")
69
69
 
70
70
  time.sleep(5)
71
71
 
@@ -82,7 +82,7 @@ def get_docker_client():
82
82
  client.ping()
83
83
  return client
84
84
  except Exception:
85
- raise CLIException("Docker is not running or installed. Please ensure Docker is installed and running.")
85
+ raise CLIException("Docker is not running or installed. Make sure Docker is installed and running.")
86
86
 
87
87
 
88
88
  def stop_tinybird_local(docker_client):
@@ -110,15 +110,15 @@ def start_server(auth_callback):
110
110
  @cli.command()
111
111
  @click.option(
112
112
  "--host",
113
- help="Set custom host if it's different than https://api.tinybird.co. Check https://www.tinybird.co/docs/api-reference/overview#regions-and-endpoints for the available list of regions",
113
+ help="Set custom host if it's different than https://api.tinybird.co. See https://www.tinybird.co/docs/api-reference/overview#regions-and-endpoints for the available list of regions.",
114
114
  )
115
115
  @click.option(
116
116
  "--workspace",
117
- help="Set the workspace to authenticate to. If not set, the default workspace will be used.",
117
+ help="Set the workspace to authenticate to. If unset, the default workspace will be used.",
118
118
  )
119
119
  @coro
120
120
  async def login(host: str, workspace: str):
121
- """Authenticate via browser."""
121
+ """Authenticate using the browser."""
122
122
  auth_event = threading.Event()
123
123
  auth_code = [None] # Using a list to store the code, as it's mutable
124
124
  host = host or "https://api.tinybird.co"
@@ -127,7 +127,7 @@ async def login(host: str, workspace: str):
127
127
  auth_code[0] = code
128
128
  auth_event.set()
129
129
 
130
- click.echo("Opening browser for authentication...")
130
+ click.echo(FeedbackManager.highlight(message="» Opening browser for authentication..."))
131
131
 
132
132
  # Start the local server in a separate thread
133
133
  server_thread = threading.Thread(target=start_server, args=(auth_callback,))
@@ -169,7 +169,9 @@ async def login(host: str, workspace: str):
169
169
  cli_config[k] = ws[k]
170
170
 
171
171
  cli_config.persist_to_file()
172
-
173
- click.echo(FeedbackManager.success(message=" Authentication successful!"))
172
+ click.echo(FeedbackManager.info(message="\nWorkspace: %s" % ws["name"]))
173
+ click.echo(FeedbackManager.info(message="User: %s" % ws["user_email"]))
174
+ click.echo(FeedbackManager.info(message="Host: %s" % host))
175
+ click.echo(FeedbackManager.success(message="\n✓ Authentication successful!"))
174
176
  else:
175
177
  click.echo(FeedbackManager.error(message="Authentication failed or timed out."))
@@ -4,12 +4,14 @@ from pathlib import Path
4
4
 
5
5
  import click
6
6
 
7
+ from tinybird.prompts import mock_prompt
7
8
  from tinybird.tb.modules.cli import cli
8
9
  from tinybird.tb.modules.common import CLIException, check_user_token_with_client, coro
9
10
  from tinybird.tb.modules.config import CLIConfig
10
11
  from tinybird.tb.modules.datafile.fixture import build_fixture_name, persist_fixture
11
12
  from tinybird.tb.modules.feedback_manager import FeedbackManager
12
13
  from tinybird.tb.modules.llm import LLM
14
+ from tinybird.tb.modules.llm_utils import extract_xml
13
15
  from tinybird.tb.modules.local_common import get_tinybird_local_client
14
16
 
15
17
 
@@ -18,14 +20,15 @@ from tinybird.tb.modules.local_common import get_tinybird_local_client
18
20
  @click.option("--rows", type=int, default=10, help="Number of events to send")
19
21
  @click.option("--prompt", type=str, default="", help="Extra context to use for data generation")
20
22
  @click.option("--folder", type=str, default=".", help="Folder where datafiles will be placed")
21
- @click.pass_context
22
23
  @coro
23
- async def mock(ctx: click.Context, datasource: str, rows: int, prompt: str, folder: str) -> None:
24
+ async def mock(datasource: str, rows: int, prompt: str, folder: str) -> None:
24
25
  """Load sample data into a Data Source.
25
26
 
26
27
  Args:
27
- ctx: Click context object
28
- datasource_file: Path to the datasource file to load sample data into
28
+ datasource: Path to the datasource file to load sample data into
29
+ rows: Number of events to send
30
+ prompt: Extra context to use for data generation
31
+ folder: Folder where datafiles will be placed
29
32
  """
30
33
 
31
34
  try:
@@ -62,13 +65,15 @@ async def mock(ctx: click.Context, datasource: str, rows: int, prompt: str, fold
62
65
  user_client.token = user_token
63
66
  llm = LLM(user_token=user_token, client=user_client)
64
67
  tb_client = await get_tinybird_local_client(os.path.abspath(folder))
65
- sql = await llm.generate_sql_sample_data(datasource_content, rows=rows, prompt=prompt)
68
+ prompt = f"<datasource_schema>{datasource_content}</datasource_schema>\n<user_input>{prompt}</user_input>"
69
+ response = await llm.ask(prompt, system_prompt=mock_prompt(rows))
70
+ sql = extract_xml(response, "sql")
66
71
  if os.environ.get("TB_DEBUG", "") != "":
67
72
  logging.debug(sql)
68
73
  result = await tb_client.query(f"{sql} FORMAT JSON")
69
74
  data = result.get("data", [])[:rows]
70
75
  fixture_name = build_fixture_name(datasource_path.absolute().as_posix(), datasource_name, datasource_content)
71
- persist_fixture(fixture_name, data)
76
+ persist_fixture(fixture_name, data, folder)
72
77
  click.echo(FeedbackManager.success(message=f"✓ /fixtures/{fixture_name}.ndjson created with {rows} rows"))
73
78
 
74
79
  except Exception as e:
@@ -12,12 +12,14 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
12
12
  import click
13
13
  import yaml
14
14
 
15
+ from tinybird.prompts import test_create_prompt
15
16
  from tinybird.tb.modules.cli import cli
16
17
  from tinybird.tb.modules.common import coro
17
18
  from tinybird.tb.modules.config import CLIConfig
18
19
  from tinybird.tb.modules.exceptions import CLIException
19
20
  from tinybird.tb.modules.feedback_manager import FeedbackManager
20
21
  from tinybird.tb.modules.llm import LLM
22
+ from tinybird.tb.modules.llm_utils import extract_xml, parse_xml
21
23
  from tinybird.tb.modules.local_common import get_tinybird_local_client
22
24
 
23
25
  yaml.SafeDumper.org_represent_str = yaml.SafeDumper.represent_str # type: ignore[attr-defined]
@@ -55,9 +57,9 @@ def test(ctx: click.Context) -> None:
55
57
 
56
58
  @test.command(
57
59
  name="create",
58
- help="Create a test for an existing endpoint",
60
+ help="Create a test for an existing pipe",
59
61
  )
60
- @click.argument("pipe", type=str)
62
+ @click.argument("name_or_filename", type=str)
61
63
  @click.option(
62
64
  "--folder",
63
65
  default=".",
@@ -66,64 +68,84 @@ def test(ctx: click.Context) -> None:
66
68
  )
67
69
  @click.option("--prompt", type=str, default=None, help="Prompt to be used to create the test")
68
70
  @coro
69
- async def test_create(pipe: str, prompt: Optional[str], folder: str) -> None:
71
+ async def test_create(name_or_filename: str, prompt: Optional[str], folder: str) -> None:
70
72
  """
71
- Create a test for an existing endpoint
73
+ Create a test for an existing pipe
72
74
  """
73
-
75
+ root_path = Path(folder)
74
76
  try:
75
- pipe_path = Path(pipe)
76
- pipe_name = pipe
77
- if pipe_path.suffix == ".pipe":
78
- pipe_name = pipe_path.stem
79
- else:
80
- pipe_path = Path("endpoints", f"{pipe}.pipe")
77
+ if ".pipe" in name_or_filename:
78
+ pipe_path = Path(name_or_filename)
81
79
  if not pipe_path.exists():
82
- pipe_path = Path("pipes", f"{pipe}.pipe")
80
+ raise CLIException(FeedbackManager.error(message=f"Pipe {name_or_filename} not found"))
81
+ else:
82
+ pipe_folders = ("endpoints", "copies", "materializations", "sinks", "pipes")
83
+ pipe_path = next(
84
+ (
85
+ root_path / folder / f"{name_or_filename}.pipe"
86
+ for folder in pipe_folders
87
+ if (root_path / folder / f"{name_or_filename}.pipe").exists()
88
+ ),
89
+ None,
90
+ )
91
+ if not pipe_path:
92
+ raise CLIException(FeedbackManager.error(message=f"Pipe {name_or_filename} not found"))
83
93
 
94
+ pipe_name = pipe_path.stem
84
95
  click.echo(FeedbackManager.highlight(message=f"\n» Creating tests for {pipe_name} endpoint..."))
85
- pipe_path = Path(folder) / pipe_path
96
+ pipe_path = root_path / pipe_path
86
97
  pipe_content = pipe_path.read_text()
87
98
 
88
99
  client = await get_tinybird_local_client(os.path.abspath(folder))
89
100
  pipe_nodes = await client._req(f"/v0/pipes/{pipe_name}")
90
- pipe_params = set([param["name"] for node in pipe_nodes["nodes"] for param in node["params"]])
101
+ parameters = set([param["name"] for node in pipe_nodes["nodes"] for param in node["params"]])
91
102
 
103
+ system_prompt = test_create_prompt.format(
104
+ name=pipe_name,
105
+ content=pipe_content,
106
+ parameters=parameters or "No parameters",
107
+ )
92
108
  config = CLIConfig.get_project_config(folder)
93
109
  user_token = config.get_user_token()
94
110
  llm = LLM(user_token=user_token, client=config.get_client())
95
111
 
96
- test_expectations = await llm.create_tests(
97
- pipe_content=pipe_content, pipe_params=pipe_params, prompt=prompt or ""
98
- )
99
- valid_test_expectations: List[Dict[str, Any]] = []
100
- for test in test_expectations.tests:
101
- valid_test = test.model_dump()
102
- test_params = (
103
- valid_test["parameters"] if valid_test["parameters"].startswith("?") else f"?{valid_test['parameters']}"
104
- )
112
+ response = await llm.ask(prompt, system_prompt=system_prompt)
113
+ response = extract_xml(response, "response")
114
+ tests_content = parse_xml(response, "test")
115
+
116
+ tests: List[Dict[str, Any]] = []
117
+ for test_content in tests_content:
118
+ test = {}
119
+ test["name"] = extract_xml(test_content, "name")
120
+ test["description"] = extract_xml(test_content, "description")
121
+ parameters = extract_xml(test_content, "parameters")
122
+ test["parameters"] = parameters.split("?")[1] if "?" in parameters else parameters
123
+ test["expected_result"] = ""
105
124
 
106
125
  response = None
107
126
  try:
108
- response = await client._req_raw(f"/v0/pipes/{pipe_name}.ndjson{test_params}")
127
+ response = await client._req_raw(f"/v0/pipes/{pipe_name}.ndjson?{parameters}")
109
128
  except Exception:
110
- continue
111
-
112
- if response.status_code >= 400:
113
- valid_test["expected_http_status"] = response.status_code
114
- valid_test["expected_result"] = response.json()["error"]
115
- else:
116
- if "expected_http_status" in valid_test:
117
- del valid_test["expected_http_status"]
118
- valid_test["expected_result"] = response.text or ""
119
-
120
- valid_test_expectations.append(valid_test)
129
+ pass
121
130
 
122
- if valid_test_expectations:
123
- generate_test_file(pipe_name, valid_test_expectations, folder, mode="a")
124
- for test in valid_test_expectations:
131
+ if response:
132
+ if response.status_code >= 400:
133
+ test["expected_http_status"] = response.status_code
134
+ test["expected_result"] = response.json()["error"]
135
+ else:
136
+ if "expected_http_status" in test:
137
+ del test["expected_http_status"]
138
+ test["expected_result"] = response.text or ""
139
+
140
+ tests.append(test)
141
+
142
+ if len(tests) > 0:
143
+ generate_test_file(pipe_name, tests, folder, mode="a")
144
+ for test in tests:
125
145
  test_name = test["name"]
126
146
  click.echo(FeedbackManager.info(message=f"✓ {test_name} created"))
147
+ else:
148
+ click.echo(FeedbackManager.info(message="* No tests created"))
127
149
 
128
150
  click.echo(FeedbackManager.success(message="✓ Done!\n"))
129
151
  except Exception as e:
@@ -156,10 +178,10 @@ async def test_update(pipe: str, folder: str) -> None:
156
178
  pipe_tests_path = Path(folder) / pipe_tests_path
157
179
  pipe_tests_content = yaml.safe_load(pipe_tests_path.read_text())
158
180
  for test in pipe_tests_content:
159
- test_params = test["parameters"] if test["parameters"].startswith("?") else f"?{test['parameters']}"
181
+ test_params = test["parameters"].split("?")[1] if "?" in test["parameters"] else test["parameters"]
160
182
  response = None
161
183
  try:
162
- response = await client._req_raw(f"/v0/pipes/{pipe_name}.ndjson{test_params}")
184
+ response = await client._req_raw(f"/v0/pipes/{pipe_name}.ndjson?{test_params}")
163
185
  except Exception:
164
186
  continue
165
187
 
@@ -194,12 +216,12 @@ async def test_update(pipe: str, folder: str) -> None:
194
216
  help="Folder where tests will be placed",
195
217
  )
196
218
  @coro
197
- async def test_run(name: Tuple[str, ...], folder: str) -> None:
219
+ async def run_tests(name: Tuple[str, ...], folder: str) -> None:
198
220
  click.echo(FeedbackManager.highlight(message="\n» Running tests"))
199
221
  client = await get_tinybird_local_client(os.path.abspath(folder))
200
222
  paths = [Path(n) for n in name]
201
223
  endpoints = [f"./tests/{p.stem}.yaml" for p in paths]
202
- file_list: Iterable[str] = endpoints if len(endpoints) > 0 else glob.glob("./tests/**/*.y*ml", recursive=True)
224
+ test_files: Iterable[str] = endpoints if len(endpoints) > 0 else glob.glob("./tests/**/*.y*ml", recursive=True)
203
225
 
204
226
  async def run_test(test_file):
205
227
  test_file_path = Path(test_file)
@@ -207,11 +229,10 @@ async def test_run(name: Tuple[str, ...], folder: str) -> None:
207
229
  test_file_content = yaml.safe_load(test_file_path.read_text())
208
230
  for test in test_file_content:
209
231
  try:
210
- test_params = test["parameters"] if test["parameters"].startswith("?") else f"?{test['parameters']}"
211
-
232
+ test_params = test["parameters"].split("?")[1] if "?" in test["parameters"] else test["parameters"]
212
233
  response = None
213
234
  try:
214
- response = await client._req_raw(f"/v0/pipes/{test_file_path.stem}.ndjson{test_params}")
235
+ response = await client._req_raw(f"/v0/pipes/{test_file_path.stem}.ndjson?{test_params}")
215
236
  except Exception:
216
237
  raise Exception("Expected to not fail but got an error")
217
238
 
@@ -239,8 +260,9 @@ async def test_run(name: Tuple[str, ...], folder: str) -> None:
239
260
  return True
240
261
 
241
262
  failed_tests_count = 0
242
- test_count = len(file_list)
243
- for test_file in file_list:
263
+ test_count = len(test_files)
264
+
265
+ for test_file in test_files:
244
266
  if not await run_test(test_file):
245
267
  failed_tests_count += 1
246
268
 
@@ -23,7 +23,7 @@ class FileChangeHandler(FileSystemEventHandler):
23
23
 
24
24
  def should_process(self, event: Any) -> Optional[str]:
25
25
  if event.is_directory:
26
- return False
26
+ return None
27
27
 
28
28
  def should_process_path(path: str) -> bool:
29
29
  if not os.path.exists(path):