Skip to content

Commit ede2035

Browse files
authored
Merge pull request #12 from tinybirdco/wire-sdk-init-command
Wire up SDK-owned init command with --folder support
2 parents 3937c93 + 1a07fc1 commit ede2035

5 files changed

Lines changed: 174 additions & 56 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tinybird-sdk"
3-
version = "0.1.5"
3+
version = "0.1.6"
44
description = "Python SDK for Tinybird Forward"
55
readme = "README.md"
66
authors = [

src/tinybird_sdk/cli/commands/init.py

Lines changed: 134 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
from pathlib import Path
77
from typing import Any
88

9-
from ..config import get_client_path, get_config_path, get_datasources_path, get_pipes_path
10-
from ..utils.package_manager import detect_package_manager_run_cmd
11-
from .login import run_login
9+
from ..config import find_existing_config_path
1210

1311

14-
DATASOURCES_TEMPLATE = '''from tinybird_sdk import define_datasource, t, engine
12+
RESOURCES_TEMPLATE = '''from tinybird_sdk import define_datasource, define_endpoint, engine, node, p, t
1513
1614
15+
# --- Datasources ---
16+
1717
page_views = define_datasource("page_views", {
1818
"description": "Page view tracking data",
1919
"schema": {
@@ -26,10 +26,9 @@
2626
"sorting_key": ["pathname", "timestamp"],
2727
}),
2828
})
29-
'''
3029
31-
PIPES_TEMPLATE = '''from tinybird_sdk import define_endpoint, node, t, p
3230
31+
# --- Endpoints ---
3332
3433
top_pages = define_endpoint("top_pages", {
3534
"description": "Get the most visited pages",
@@ -59,35 +58,78 @@
5958
})
6059
'''
6160

62-
CLIENT_TEMPLATE = '''from tinybird_sdk import Tinybird
63-
from .datasources import page_views
64-
from .pipes import top_pages
6561

62+
def _client_template(resources_import_path: str) -> str:
63+
return f'''import os
6664
67-
tinybird = Tinybird({
68-
"datasources": {"page_views": page_views},
69-
"pipes": {"top_pages": top_pages},
70-
})
65+
from tinybird_sdk import Tinybird
66+
from {resources_import_path} import page_views, top_pages
67+
68+
tinybird = Tinybird(
69+
{{
70+
"datasources": {{"page_views": page_views}},
71+
"pipes": {{"top_pages": top_pages}},
72+
"base_url": os.getenv("TINYBIRD_API_URL", "https://api.tinybird.co"),
73+
"token": os.getenv("TINYBIRD_TOKEN"),
74+
}}
75+
)
76+
'''
77+
78+
79+
def _main_template(client_import_path: str) -> str:
80+
return f'''from datetime import datetime, timezone
81+
82+
from dotenv import load_dotenv
83+
84+
85+
def main():
86+
load_dotenv(".env.local")
87+
88+
from {client_import_path} import tinybird
89+
90+
now = datetime.now(timezone.utc).isoformat(timespec="milliseconds")
91+
92+
# Ingest data using the Events API
93+
tinybird.page_views.ingest(
94+
{{
95+
"timestamp": now,
96+
"session_id": "abc123",
97+
"pathname": "/home",
98+
"referrer": "https://google.com",
99+
}}
100+
)
101+
102+
# Query the endpoint
103+
result = tinybird.top_pages.query(
104+
{{
105+
"start_date": "2026-01-01 00:00:00",
106+
"end_date": now,
107+
"limit": 5,
108+
}}
109+
)
110+
111+
for row in result["data"]:
112+
print(row["pathname"], row["views"])
113+
114+
115+
if __name__ == "__main__":
116+
main()
71117
'''
72118

73119

74120
@dataclass(frozen=True, slots=True)
75121
class InitOptions:
76122
cwd: str | None = None
123+
folder: str | None = None
77124
force: bool = False
78-
skip_login: bool = False
79-
dev_mode: str | None = None
80-
client_path: str | None = None
81125

82126

83127
@dataclass(frozen=True, slots=True)
84128
class InitResult:
85129
success: bool
130+
resources_path: str | None = None
86131
client_path: str | None = None
87-
logged_in: bool | None = None
88-
workspace_name: str | None = None
89-
user_email: str | None = None
90-
existing_datafiles: list[str] | None = None
132+
main_path: str | None = None
91133
error: str | None = None
92134

93135

@@ -106,46 +148,87 @@ def _write_file(path: Path, content: str, force: bool) -> None:
106148
path.write_text(content, encoding="utf-8")
107149

108150

151+
def _run_tinybird_cli_init(argv: list[str]) -> int:
152+
try:
153+
from tinybird.tb.cli import cli as upstream_cli # type: ignore[import-not-found]
154+
except ModuleNotFoundError:
155+
return 1
156+
157+
try:
158+
upstream_cli.main(args=argv, prog_name="tinybird")
159+
return 0
160+
except SystemExit as error:
161+
code = error.code
162+
if code is None:
163+
return 0
164+
return code if isinstance(code, int) else 1
165+
166+
109167
def run_init(options: InitOptions | dict[str, Any] | None = None) -> InitResult:
110168
normalized = options if isinstance(options, InitOptions) else InitOptions(**(options or {}))
111169
cwd = Path(normalized.cwd or os.getcwd()).resolve()
112170

113171
try:
114-
config_path = Path(get_config_path(str(cwd)))
115-
datasources_path = Path(get_datasources_path(str(cwd)))
116-
pipes_path = Path(get_pipes_path(str(cwd)))
117-
client_path = Path(normalized.client_path) if normalized.client_path else Path(get_client_path(str(cwd)))
118-
if not client_path.is_absolute():
119-
client_path = cwd / client_path
120-
121-
existing_datafiles = find_existing_datafiles(str(cwd))
122-
123-
include = [str(datasources_path.relative_to(cwd)), str(pipes_path.relative_to(cwd))]
124-
include.extend(existing_datafiles)
125-
126-
config_payload = {
127-
"include": include,
128-
"token": "${TINYBIRD_TOKEN}",
129-
"base_url": "${TINYBIRD_URL}",
130-
"dev_mode": normalized.dev_mode or "branch",
131-
}
132-
133-
_write_file(config_path, json.dumps(config_payload, indent=2) + "\n", normalized.force)
134-
_write_file(datasources_path, DATASOURCES_TEMPLATE, normalized.force)
135-
_write_file(pipes_path, PIPES_TEMPLATE, normalized.force)
136-
_write_file(client_path, CLIENT_TEMPLATE, normalized.force)
137-
138-
login_result = None
139-
if not normalized.skip_login:
140-
login_result = run_login({"cwd": str(cwd)})
172+
# 1. Delegate to tinybird CLI for interactive flow (dev mode, CI/CD, skills, login)
173+
cli_argv = ["init", "--type", "python"]
174+
if normalized.folder:
175+
cli_argv.extend(["--folder", normalized.folder])
176+
_run_tinybird_cli_init(cli_argv)
177+
178+
# 2. Determine the target folder for SDK template files
179+
# Priority: --folder flag > folder from tinybird.config.json > default
180+
folder: Path | None = None
181+
if normalized.folder:
182+
folder = Path(normalized.folder)
183+
if not folder.is_absolute():
184+
folder = cwd / folder
185+
else:
186+
# Read the folder the tinybird CLI configured (from the interactive prompt)
187+
config_path_check = find_existing_config_path(str(cwd))
188+
if config_path_check and config_path_check.endswith(".json"):
189+
with open(config_path_check, "r", encoding="utf-8") as fp:
190+
cli_config = json.load(fp)
191+
include = cli_config.get("include", [])
192+
if include:
193+
folder = cwd / Path(include[0])
194+
195+
if folder is None:
196+
src = cwd / "src"
197+
folder = (src / "lib") if src.is_dir() else (cwd / "lib")
198+
199+
resources_path = folder / "tinybird_resources.py"
200+
client_path = folder / "client.py"
201+
main_path = cwd / "main.py"
202+
203+
# Compute import paths based on folder relative to cwd
204+
relative_folder = str(folder.relative_to(cwd)).replace(os.sep, ".")
205+
resources_import = f"{relative_folder}.tinybird_resources"
206+
client_import = f"{relative_folder}.client"
207+
208+
_write_file(resources_path, RESOURCES_TEMPLATE, normalized.force)
209+
_write_file(client_path, _client_template(resources_import), normalized.force)
210+
# Always overwrite main.py — the default from `uv init` is a placeholder
211+
_write_file(main_path, _main_template(client_import), force=True)
212+
213+
# 3. Add the resources file to tinybird.config.json include list
214+
config_path = find_existing_config_path(str(cwd))
215+
if config_path and config_path.endswith(".json"):
216+
relative_resources = str(resources_path.relative_to(cwd))
217+
with open(config_path, "r", encoding="utf-8") as fp:
218+
config_data = json.load(fp)
219+
include = config_data.get("include", [])
220+
if relative_resources not in include:
221+
include.append(relative_resources)
222+
config_data["include"] = include
223+
with open(config_path, "w", encoding="utf-8") as fp:
224+
json.dump(config_data, fp, indent=2)
225+
fp.write("\n")
141226

142227
return InitResult(
143228
success=True,
229+
resources_path=str(resources_path.relative_to(cwd)),
144230
client_path=str(client_path.relative_to(cwd)),
145-
logged_in=login_result.success if login_result else False,
146-
workspace_name=login_result.workspace_name if login_result else None,
147-
user_email=login_result.user_email if login_result else None,
148-
existing_datafiles=existing_datafiles,
231+
main_path=str(main_path.relative_to(cwd)),
149232
)
150233
except Exception as error:
151234
return InitResult(success=False, error=str(error))

src/tinybird_sdk/cli/index.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77

88
from .commands.generate import run_generate
9+
from .commands.init import run_init
910
from .commands.migrate import run_migrate
1011
from .output import output
1112

@@ -41,9 +42,13 @@ def _run_installed_tinybird_cli(argv: list[str]) -> int:
4142

4243

4344
def create_cli() -> argparse.ArgumentParser:
44-
parser = argparse.ArgumentParser(prog="tinybird", description="Tinybird Python SDK generate command")
45+
parser = argparse.ArgumentParser(prog="tinybird", description="Tinybird Python SDK CLI")
4546
sub = parser.add_subparsers(dest="command", required=True)
4647

48+
init_cmd = sub.add_parser("init", help="Initialize a new Tinybird project with Python SDK templates")
49+
init_cmd.add_argument("--folder", help="Target folder for generated Python files")
50+
init_cmd.add_argument("--force", action="store_true", help="Overwrite existing files")
51+
4752
generate_cmd = sub.add_parser("generate", help="Generate Tinybird datafiles from Python definitions")
4853
generate_cmd.add_argument("--json", action="store_true")
4954
generate_cmd.add_argument("-o", "--output-dir")
@@ -69,12 +74,30 @@ def main(argv: list[str] | None = None) -> int:
6974
normalized_argv = list(argv) if argv is not None else list(sys.argv[1:])
7075

7176
# SDK-owned commands stay local; all other commands are delegated to Tinybird CLI.
72-
if not normalized_argv or normalized_argv[0] not in {"generate", "migrate"}:
77+
if not normalized_argv or normalized_argv[0] not in {"init", "generate", "migrate"}:
7378
return _run_installed_tinybird_cli(normalized_argv)
7479

7580
parser = create_cli()
7681
args = parser.parse_args(normalized_argv)
7782

83+
if args.command == "init":
84+
result = run_init({
85+
"folder": args.folder,
86+
"force": args.force,
87+
})
88+
if not result.success:
89+
output.error(result.error or "Init failed")
90+
return 1
91+
92+
output.success("\n✓ Python SDK files created:")
93+
if result.resources_path:
94+
output.info(f" {result.resources_path}")
95+
if result.client_path:
96+
output.info(f" {result.client_path}")
97+
if result.main_path:
98+
output.info(f" {result.main_path}")
99+
return 0
100+
78101
if args.command == "generate":
79102
result = run_generate({"output_dir": args.output_dir})
80103
if not result.success:

tests/test_cli_workflows.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,20 @@
1515

1616

1717
def test_init_build_and_deploy_workflow(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
18-
init_result = run_init({"cwd": str(tmp_path), "skip_login": True})
18+
monkeypatch.setattr(
19+
"tinybird_sdk.cli.commands.init._run_tinybird_cli_init",
20+
lambda _argv: 0,
21+
)
22+
init_result = run_init({"cwd": str(tmp_path)})
1923
assert init_result.success is True
24+
assert init_result.resources_path is not None
25+
assert (tmp_path / init_result.resources_path).exists()
26+
27+
# Create config that the tinybird CLI would have created
28+
(tmp_path / "tinybird.config.json").write_text(
29+
f'{{"include":["{init_result.resources_path}"],"token":"${{TINYBIRD_TOKEN}}","base_url":"${{TINYBIRD_URL}}"}}\n',
30+
encoding="utf-8",
31+
)
2032

2133
monkeypatch.setenv("TINYBIRD_TOKEN", "p.workspace")
2234
monkeypatch.setenv("TINYBIRD_URL", "https://api.tinybird.co")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)