From e7f53bb34ec2f80fd2da40eef11e562e4746ea96 Mon Sep 17 00:00:00 2001 From: Tommy Healy Date: Thu, 1 Oct 2026 17:10:01 +0200 Subject: [PATCH 1/2] Wire native pull/login commands to CLI entry point `pull` and `login` now dispatch to the SDK's native run_pull/run_login implementations instead of shelling out to the upstream Forward `tb` package. Both already persist standalone (datafiles written directly to disk; login writes TINYBIRD_TOKEN/TINYBIRD_URL to .env.local and updates tinybird.config.json/tinybird.json when present), with no dependency on tb being installed. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++ src/tinybird_sdk/cli/index.py | 42 +++++++++++- tests/test_cli_entrypoint.py | 112 +++++++++++++++++++++++++++++++- tests/test_login_persistence.py | 88 +++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 tests/test_login_persistence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1ecb0..1be883a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- `tinybird pull` and `tinybird login` now run the SDK's own native implementations instead of delegating to the installed Forward `tb` package, so these commands work without `tb` installed alongside the SDK. + ## [0.4.0] - 2026-06-29 ### Added diff --git a/src/tinybird_sdk/cli/index.py b/src/tinybird_sdk/cli/index.py index 1183961..a2fb3f1 100644 --- a/src/tinybird_sdk/cli/index.py +++ b/src/tinybird_sdk/cli/index.py @@ -7,9 +7,13 @@ from .commands.generate import run_generate from .commands.init import run_init +from .commands.login import run_login from .commands.migrate import run_migrate +from .commands.pull import run_pull from .output import output +_SDK_OWNED_COMMANDS = {"init", "generate", "migrate", "pull", "login"} + def _print_json(payload: object) -> None: print(json.dumps(payload, indent=2, default=str)) @@ -81,6 +85,14 @@ def create_cli() -> argparse.ArgumentParser: ) migrate_cmd.add_argument("--json", action="store_true", help="Print migration result as JSON") + pull_cmd = sub.add_parser("pull", help="Pull datafiles for existing resources") + pull_cmd.add_argument( + "-o", "--output-dir", default=".", help="Target folder for pulled datafiles" + ) + pull_cmd.add_argument("--force", action="store_true", help="Overwrite existing files") + + sub.add_parser("login", help="Authenticate with Tinybird via the browser") + return parser @@ -88,7 +100,7 @@ def main(argv: list[str] | None = None) -> int: normalized_argv = list(argv) if argv is not None else list(sys.argv[1:]) # SDK-owned commands stay local; all other commands are delegated to Tinybird CLI. - if not normalized_argv or normalized_argv[0] not in {"init", "generate", "migrate"}: + if not normalized_argv or normalized_argv[0] not in _SDK_OWNED_COMMANDS: return _run_installed_tinybird_cli(normalized_argv) parser = create_cli() @@ -142,6 +154,34 @@ def main(argv: list[str] | None = None) -> int: print(f"Completed in {output.format_duration(generate_result.duration_ms)}") return 0 + if args.command == "pull": + pull_result = run_pull({"output_dir": args.output_dir, "overwrite": args.force}) + if not pull_result.success: + output.error(pull_result.error or "Pull failed") + return 1 + + file_count = len(pull_result.files or []) + print(f"Pulled {file_count} datafiles") + if pull_result.output_dir: + print(f"Written to: {pull_result.output_dir}") + print(f"Completed in {output.format_duration(pull_result.duration_ms)}") + return 0 + + if args.command == "login": + login_result = run_login({}) + if not login_result.success: + output.error(login_result.error or "Login failed") + return 1 + + output.success("✓ Logged in to Tinybird") + if login_result.workspace_name: + output.info(f"Workspace: {login_result.workspace_name}") + if login_result.user_email: + output.info(f"User: {login_result.user_email}") + if login_result.base_url: + output.info(f"Host: {login_result.base_url}") + return 0 + migrate_result = run_migrate( { "cwd": args.cwd, diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index fe2c493..435c9fa 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -62,7 +62,7 @@ def test_cli_entrypoint_delegates_non_sdk_commands(monkeypatch: pytest.MonkeyPat monkeypatch.setattr( cli_index, "_run_installed_tinybird_cli", - lambda argv: 7 if argv == ["build", "--dry-run"] else 1, + lambda argv: 7 if argv == ["deploy", "--check"] else 1, ) monkeypatch.setattr( cli_index, @@ -74,7 +74,7 @@ def test_cli_entrypoint_delegates_non_sdk_commands(monkeypatch: pytest.MonkeyPat "run_migrate", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("migrate should not run")), ) - assert cli_index.main(["build", "--dry-run"]) == 7 + assert cli_index.main(["deploy", "--check"]) == 7 def test_cli_entrypoint_delegates_empty_argv(monkeypatch: pytest.MonkeyPatch) -> None: @@ -210,3 +210,111 @@ def test_cli_entrypoint_migrate_failure_returns_error(monkeypatch: pytest.Monkey lambda *_args, **_kwargs: {"success": False, "errors": ["boom"]}, ) assert cli_index.main(["migrate", "legacy.datasource"]) == 1 + + +def _deny_delegation(monkeypatch: pytest.MonkeyPatch, command: str) -> None: + monkeypatch.setattr( + cli_index, + "_run_installed_tinybird_cli", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError(f"should not delegate {command}") + ), + ) + + +def test_cli_entrypoint_runs_pull_locally( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _deny_delegation(monkeypatch, "pull") + calls: list[dict] = [] + + def fake_run_pull(options: dict) -> SimpleNamespace: + calls.append(options) + return SimpleNamespace( + success=True, + error=None, + duration_ms=7, + output_dir="./tinybird-datafiles", + files=[SimpleNamespace(), SimpleNamespace()], + stats=None, + ) + + monkeypatch.setattr(cli_index, "run_pull", fake_run_pull) + + assert cli_index.main(["pull", "--output-dir", "./tinybird-datafiles", "--force"]) == 0 + assert calls == [{"output_dir": "./tinybird-datafiles", "overwrite": True}] + out = capsys.readouterr().out + assert "Pulled 2 datafiles" in out + assert "Written to: ./tinybird-datafiles" in out + + +def test_cli_entrypoint_pull_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + _deny_delegation(monkeypatch, "pull") + calls: list[dict] = [] + + def fake_run_pull(options: dict) -> SimpleNamespace: + calls.append(options) + return SimpleNamespace( + success=True, error=None, duration_ms=1, output_dir=".", files=[], stats=None + ) + + monkeypatch.setattr(cli_index, "run_pull", fake_run_pull) + + assert cli_index.main(["pull"]) == 0 + assert calls == [{"output_dir": ".", "overwrite": False}] + + +def test_cli_entrypoint_pull_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: + _mute_output(monkeypatch) + _deny_delegation(monkeypatch, "pull") + monkeypatch.setattr( + cli_index, + "run_pull", + lambda *_args, **_kwargs: SimpleNamespace( + success=False, error="File already exists", duration_ms=1, output_dir=None, files=None + ), + ) + assert cli_index.main(["pull"]) == 1 + + +def test_cli_entrypoint_runs_login_locally( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _deny_delegation(monkeypatch, "login") + + def fake_run_login(options: dict) -> SimpleNamespace: + return SimpleNamespace( + success=True, + error=None, + token="p.test", + base_url="https://api.tinybird.co", + workspace_name="my_workspace", + user_email="user@example.com", + ) + + monkeypatch.setattr(cli_index, "run_login", fake_run_login) + + assert cli_index.main(["login"]) == 0 + out = capsys.readouterr().out + assert "Logged in to Tinybird" in out + assert "Workspace: my_workspace" in out + assert "User: user@example.com" in out + assert "Host: https://api.tinybird.co" in out + + +def test_cli_entrypoint_login_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: + _mute_output(monkeypatch) + _deny_delegation(monkeypatch, "login") + monkeypatch.setattr( + cli_index, + "run_login", + lambda *_args, **_kwargs: SimpleNamespace( + success=False, + error="Login timed out", + token=None, + base_url=None, + workspace_name=None, + user_email=None, + ), + ) + assert cli_index.main(["login"]) == 1 diff --git a/tests/test_login_persistence.py b/tests/test_login_persistence.py new file mode 100644 index 0000000..d527633 --- /dev/null +++ b/tests/test_login_persistence.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import tinybird_sdk.cli.commands.login as login_module +from tinybird_sdk.cli.auth import AuthResult +from tinybird_sdk.cli.commands.login import run_login + + +def test_run_login_persists_token_and_base_url_to_env_local( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + login_module, + "browser_login", + lambda *_args, **_kwargs: AuthResult( + success=True, + token="p.test-token", + base_url="https://api.tinybird.co", + workspace_name="my_workspace", + user_email="user@example.com", + ), + ) + + result = run_login({"cwd": str(tmp_path)}) + + assert result.success + assert result.token == "p.test-token" + assert result.workspace_name == "my_workspace" + + env_local = tmp_path / ".env.local" + assert env_local.exists() + content = env_local.read_text(encoding="utf-8") + assert "TINYBIRD_TOKEN=p.test-token" in content + assert "TINYBIRD_URL=https://api.tinybird.co" in content + + +def test_run_login_updates_existing_json_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "tinybird.config.json" + config_path.write_text(json.dumps({"base_url": "https://old.example.com"}), encoding="utf-8") + + monkeypatch.setattr( + login_module, + "browser_login", + lambda *_args, **_kwargs: AuthResult( + success=True, token="p.new-token", base_url="https://api.tinybird.co" + ), + ) + + result = run_login({"cwd": str(tmp_path)}) + + assert result.success + updated = json.loads(config_path.read_text(encoding="utf-8")) + assert updated["token"] == "${TINYBIRD_TOKEN}" + assert updated["base_url"] == "https://api.tinybird.co" + + +def test_run_login_does_not_persist_when_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + login_module, + "browser_login", + lambda *_args, **_kwargs: AuthResult(success=True, token="p.test-token"), + ) + + result = run_login({"cwd": str(tmp_path), "persist": False}) + + assert result.success + assert not (tmp_path / ".env.local").exists() + + +def test_run_login_propagates_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + login_module, + "browser_login", + lambda *_args, **_kwargs: AuthResult(success=False, error="Authentication timed out"), + ) + + result = run_login({}) + + assert not result.success + assert result.error == "Authentication timed out" From 22f48657916219f6d7bc7efc8381f3cd88203422 Mon Sep 17 00:00:00 2001 From: Tommy Healy Date: Fri, 2 Oct 2026 11:19:36 +0200 Subject: [PATCH 2/2] Revert native pull/login wiring; keep delegating to the installed Tinybird CLI pull and login already work correctly via the real tb CLI delegation path (main's SDK_OWNED_COMMANDS policy). Reimplementing them against the raw API duplicates logic the installed CLI already owns and can drift from its actual behavior (the installed CLI's login flow, token storage, etc). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 -- src/tinybird_sdk/cli/index.py | 42 +----------- tests/test_cli_entrypoint.py | 112 +------------------------------- tests/test_login_persistence.py | 88 ------------------------- 4 files changed, 3 insertions(+), 245 deletions(-) delete mode 100644 tests/test_login_persistence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be883a..0c1ecb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Changed - -- `tinybird pull` and `tinybird login` now run the SDK's own native implementations instead of delegating to the installed Forward `tb` package, so these commands work without `tb` installed alongside the SDK. - ## [0.4.0] - 2026-06-29 ### Added diff --git a/src/tinybird_sdk/cli/index.py b/src/tinybird_sdk/cli/index.py index a2fb3f1..1183961 100644 --- a/src/tinybird_sdk/cli/index.py +++ b/src/tinybird_sdk/cli/index.py @@ -7,13 +7,9 @@ from .commands.generate import run_generate from .commands.init import run_init -from .commands.login import run_login from .commands.migrate import run_migrate -from .commands.pull import run_pull from .output import output -_SDK_OWNED_COMMANDS = {"init", "generate", "migrate", "pull", "login"} - def _print_json(payload: object) -> None: print(json.dumps(payload, indent=2, default=str)) @@ -85,14 +81,6 @@ def create_cli() -> argparse.ArgumentParser: ) migrate_cmd.add_argument("--json", action="store_true", help="Print migration result as JSON") - pull_cmd = sub.add_parser("pull", help="Pull datafiles for existing resources") - pull_cmd.add_argument( - "-o", "--output-dir", default=".", help="Target folder for pulled datafiles" - ) - pull_cmd.add_argument("--force", action="store_true", help="Overwrite existing files") - - sub.add_parser("login", help="Authenticate with Tinybird via the browser") - return parser @@ -100,7 +88,7 @@ def main(argv: list[str] | None = None) -> int: normalized_argv = list(argv) if argv is not None else list(sys.argv[1:]) # SDK-owned commands stay local; all other commands are delegated to Tinybird CLI. - if not normalized_argv or normalized_argv[0] not in _SDK_OWNED_COMMANDS: + if not normalized_argv or normalized_argv[0] not in {"init", "generate", "migrate"}: return _run_installed_tinybird_cli(normalized_argv) parser = create_cli() @@ -154,34 +142,6 @@ def main(argv: list[str] | None = None) -> int: print(f"Completed in {output.format_duration(generate_result.duration_ms)}") return 0 - if args.command == "pull": - pull_result = run_pull({"output_dir": args.output_dir, "overwrite": args.force}) - if not pull_result.success: - output.error(pull_result.error or "Pull failed") - return 1 - - file_count = len(pull_result.files or []) - print(f"Pulled {file_count} datafiles") - if pull_result.output_dir: - print(f"Written to: {pull_result.output_dir}") - print(f"Completed in {output.format_duration(pull_result.duration_ms)}") - return 0 - - if args.command == "login": - login_result = run_login({}) - if not login_result.success: - output.error(login_result.error or "Login failed") - return 1 - - output.success("✓ Logged in to Tinybird") - if login_result.workspace_name: - output.info(f"Workspace: {login_result.workspace_name}") - if login_result.user_email: - output.info(f"User: {login_result.user_email}") - if login_result.base_url: - output.info(f"Host: {login_result.base_url}") - return 0 - migrate_result = run_migrate( { "cwd": args.cwd, diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 435c9fa..fe2c493 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -62,7 +62,7 @@ def test_cli_entrypoint_delegates_non_sdk_commands(monkeypatch: pytest.MonkeyPat monkeypatch.setattr( cli_index, "_run_installed_tinybird_cli", - lambda argv: 7 if argv == ["deploy", "--check"] else 1, + lambda argv: 7 if argv == ["build", "--dry-run"] else 1, ) monkeypatch.setattr( cli_index, @@ -74,7 +74,7 @@ def test_cli_entrypoint_delegates_non_sdk_commands(monkeypatch: pytest.MonkeyPat "run_migrate", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("migrate should not run")), ) - assert cli_index.main(["deploy", "--check"]) == 7 + assert cli_index.main(["build", "--dry-run"]) == 7 def test_cli_entrypoint_delegates_empty_argv(monkeypatch: pytest.MonkeyPatch) -> None: @@ -210,111 +210,3 @@ def test_cli_entrypoint_migrate_failure_returns_error(monkeypatch: pytest.Monkey lambda *_args, **_kwargs: {"success": False, "errors": ["boom"]}, ) assert cli_index.main(["migrate", "legacy.datasource"]) == 1 - - -def _deny_delegation(monkeypatch: pytest.MonkeyPatch, command: str) -> None: - monkeypatch.setattr( - cli_index, - "_run_installed_tinybird_cli", - lambda *_args, **_kwargs: (_ for _ in ()).throw( - AssertionError(f"should not delegate {command}") - ), - ) - - -def test_cli_entrypoint_runs_pull_locally( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - _deny_delegation(monkeypatch, "pull") - calls: list[dict] = [] - - def fake_run_pull(options: dict) -> SimpleNamespace: - calls.append(options) - return SimpleNamespace( - success=True, - error=None, - duration_ms=7, - output_dir="./tinybird-datafiles", - files=[SimpleNamespace(), SimpleNamespace()], - stats=None, - ) - - monkeypatch.setattr(cli_index, "run_pull", fake_run_pull) - - assert cli_index.main(["pull", "--output-dir", "./tinybird-datafiles", "--force"]) == 0 - assert calls == [{"output_dir": "./tinybird-datafiles", "overwrite": True}] - out = capsys.readouterr().out - assert "Pulled 2 datafiles" in out - assert "Written to: ./tinybird-datafiles" in out - - -def test_cli_entrypoint_pull_defaults(monkeypatch: pytest.MonkeyPatch) -> None: - _deny_delegation(monkeypatch, "pull") - calls: list[dict] = [] - - def fake_run_pull(options: dict) -> SimpleNamespace: - calls.append(options) - return SimpleNamespace( - success=True, error=None, duration_ms=1, output_dir=".", files=[], stats=None - ) - - monkeypatch.setattr(cli_index, "run_pull", fake_run_pull) - - assert cli_index.main(["pull"]) == 0 - assert calls == [{"output_dir": ".", "overwrite": False}] - - -def test_cli_entrypoint_pull_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: - _mute_output(monkeypatch) - _deny_delegation(monkeypatch, "pull") - monkeypatch.setattr( - cli_index, - "run_pull", - lambda *_args, **_kwargs: SimpleNamespace( - success=False, error="File already exists", duration_ms=1, output_dir=None, files=None - ), - ) - assert cli_index.main(["pull"]) == 1 - - -def test_cli_entrypoint_runs_login_locally( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - _deny_delegation(monkeypatch, "login") - - def fake_run_login(options: dict) -> SimpleNamespace: - return SimpleNamespace( - success=True, - error=None, - token="p.test", - base_url="https://api.tinybird.co", - workspace_name="my_workspace", - user_email="user@example.com", - ) - - monkeypatch.setattr(cli_index, "run_login", fake_run_login) - - assert cli_index.main(["login"]) == 0 - out = capsys.readouterr().out - assert "Logged in to Tinybird" in out - assert "Workspace: my_workspace" in out - assert "User: user@example.com" in out - assert "Host: https://api.tinybird.co" in out - - -def test_cli_entrypoint_login_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: - _mute_output(monkeypatch) - _deny_delegation(monkeypatch, "login") - monkeypatch.setattr( - cli_index, - "run_login", - lambda *_args, **_kwargs: SimpleNamespace( - success=False, - error="Login timed out", - token=None, - base_url=None, - workspace_name=None, - user_email=None, - ), - ) - assert cli_index.main(["login"]) == 1 diff --git a/tests/test_login_persistence.py b/tests/test_login_persistence.py deleted file mode 100644 index d527633..0000000 --- a/tests/test_login_persistence.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -import tinybird_sdk.cli.commands.login as login_module -from tinybird_sdk.cli.auth import AuthResult -from tinybird_sdk.cli.commands.login import run_login - - -def test_run_login_persists_token_and_base_url_to_env_local( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - login_module, - "browser_login", - lambda *_args, **_kwargs: AuthResult( - success=True, - token="p.test-token", - base_url="https://api.tinybird.co", - workspace_name="my_workspace", - user_email="user@example.com", - ), - ) - - result = run_login({"cwd": str(tmp_path)}) - - assert result.success - assert result.token == "p.test-token" - assert result.workspace_name == "my_workspace" - - env_local = tmp_path / ".env.local" - assert env_local.exists() - content = env_local.read_text(encoding="utf-8") - assert "TINYBIRD_TOKEN=p.test-token" in content - assert "TINYBIRD_URL=https://api.tinybird.co" in content - - -def test_run_login_updates_existing_json_config( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_path = tmp_path / "tinybird.config.json" - config_path.write_text(json.dumps({"base_url": "https://old.example.com"}), encoding="utf-8") - - monkeypatch.setattr( - login_module, - "browser_login", - lambda *_args, **_kwargs: AuthResult( - success=True, token="p.new-token", base_url="https://api.tinybird.co" - ), - ) - - result = run_login({"cwd": str(tmp_path)}) - - assert result.success - updated = json.loads(config_path.read_text(encoding="utf-8")) - assert updated["token"] == "${TINYBIRD_TOKEN}" - assert updated["base_url"] == "https://api.tinybird.co" - - -def test_run_login_does_not_persist_when_disabled( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - login_module, - "browser_login", - lambda *_args, **_kwargs: AuthResult(success=True, token="p.test-token"), - ) - - result = run_login({"cwd": str(tmp_path), "persist": False}) - - assert result.success - assert not (tmp_path / ".env.local").exists() - - -def test_run_login_propagates_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - login_module, - "browser_login", - lambda *_args, **_kwargs: AuthResult(success=False, error="Authentication timed out"), - ) - - result = run_login({}) - - assert not result.success - assert result.error == "Authentication timed out"