From a76607af7e8fd22864e94927f55973462ed425cb Mon Sep 17 00:00:00 2001 From: Tommy Healy Date: Thu, 1 Oct 2026 17:06:32 +0200 Subject: [PATCH 1/2] Wire native build/deploy/dev commands to CLI entry point `tinybird build`, `tinybird deploy`, and `tinybird dev` previously delegated to the upstream Forward `tb` package. Wire them to the existing native implementations (run_build/run_deploy/run_dev) so the SDK's CLI works standalone, matching `tb build --local/--branch/--dry-run`, `tb deploy --check/--allow-destructive-operations`, and `tb dev`. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++ src/tinybird_sdk/cli/index.py | 106 +++++++++++++++++++++++++- tests/test_cli_entrypoint.py | 135 +++++++++++++++++++++++++++++++++- 3 files changed, 243 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1ecb0..2ec9239 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 build`, `tinybird deploy`, and `tinybird dev` 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..6be295b 100644 --- a/src/tinybird_sdk/cli/index.py +++ b/src/tinybird_sdk/cli/index.py @@ -5,10 +5,15 @@ import json import sys +from .commands.build import run_build +from .commands.deploy import run_deploy +from .commands.dev import run_dev from .commands.generate import run_generate from .commands.init import run_init from .commands.migrate import run_migrate -from .output import output +from .output import BranchDisplayInfo, output + +_SDK_OWNED_COMMANDS = {"init", "generate", "migrate", "build", "deploy", "dev"} def _print_json(payload: object) -> None: @@ -81,6 +86,53 @@ def create_cli() -> argparse.ArgumentParser: ) migrate_cmd.add_argument("--json", action="store_true", help="Print migration result as JSON") + build_cmd = sub.add_parser("build", help="Build and deploy Tinybird resources to a branch") + build_cmd.add_argument( + "--dry-run", action="store_true", help="Validate resources without deploying" + ) + build_mode = build_cmd.add_mutually_exclusive_group() + build_mode.add_argument( + "--local", + action="store_const", + dest="dev_mode", + const="local", + help="Build against Tinybird Local", + ) + build_mode.add_argument( + "--branch", + action="store_const", + dest="dev_mode", + const="branch", + help="Build against a cloud branch", + ) + + deploy_cmd = sub.add_parser("deploy", help="Deploy Tinybird resources to the main workspace") + deploy_cmd.add_argument( + "--check", action="store_true", help="Validate the deployment without applying it" + ) + deploy_cmd.add_argument( + "--allow-destructive-operations", + action="store_true", + help="Allow deployments that include destructive operations", + ) + + dev_cmd = sub.add_parser("dev", help="Build Tinybird resources and watch for changes") + dev_mode = dev_cmd.add_mutually_exclusive_group() + dev_mode.add_argument( + "--local", + action="store_const", + dest="dev_mode", + const="local", + help="Build against Tinybird Local", + ) + dev_mode.add_argument( + "--branch", + action="store_const", + dest="dev_mode", + const="branch", + help="Build against a cloud branch", + ) + return parser @@ -88,7 +140,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 +194,56 @@ def main(argv: list[str] | None = None) -> int: print(f"Completed in {output.format_duration(generate_result.duration_ms)}") return 0 + if args.command == "build": + build_result = run_build( + { + "dry_run": args.dry_run, + "dev_mode_override": args.dev_mode, + } + ) + if not build_result.success: + output.show_build_failure() + if build_result.error: + output.error(build_result.error) + return 1 + + output.show_build_success(build_result.duration_ms) + if build_result.branch_info: + output.show_branch_info( + BranchDisplayInfo( + mode="local" if build_result.branch_info.is_local else "branch", + git_branch=build_result.branch_info.git_branch, + tinybird_branch=build_result.branch_info.tinybird_branch, + created=build_result.branch_info.was_created, + ) + ) + if build_result.branch_info.dashboard_url: + output.info(f"Dashboard: {build_result.branch_info.dashboard_url}") + return 0 + + if args.command == "deploy": + deploy_result = run_deploy( + { + "check": args.check, + "allow_destructive_operations": args.allow_destructive_operations, + } + ) + if not deploy_result.success: + output.show_deploy_failure() + if deploy_result.error: + output.error(deploy_result.error) + return 1 + + output.show_deploy_success(deploy_result.duration_ms) + return 0 + + if args.command == "dev": + dev_result = run_dev({"dev_mode_override": args.dev_mode}) + if not dev_result.get("success"): + output.error(dev_result.get("error") or "Dev failed") + return 1 + 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..410e6a7 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 == ["pull", "--force"] 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(["pull", "--force"]) == 7 def test_cli_entrypoint_delegates_empty_argv(monkeypatch: pytest.MonkeyPatch) -> None: @@ -210,3 +210,134 @@ 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_build_locally( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _deny_delegation(monkeypatch, "build") + calls: list[dict] = [] + + def fake_run_build(options: dict) -> SimpleNamespace: + calls.append(options) + return SimpleNamespace( + success=True, + error=None, + duration_ms=42, + build=None, + deploy=None, + branch_info=SimpleNamespace( + git_branch="feature-x", + tinybird_branch="feature_x", + was_created=True, + dashboard_url="https://app.tinybird.co/feature_x", + is_local=False, + ), + ) + + monkeypatch.setattr(cli_index, "run_build", fake_run_build) + + assert cli_index.main(["build", "--branch"]) == 0 + assert calls == [{"dry_run": False, "dev_mode_override": "branch"}] + out = capsys.readouterr().out + assert "Build completed" in out + assert "Tinybird branch: feature_x" in out + assert "Dashboard: https://app.tinybird.co/feature_x" in out + + +def test_cli_entrypoint_build_dry_run_passes_flags(monkeypatch: pytest.MonkeyPatch) -> None: + _deny_delegation(monkeypatch, "build") + calls: list[dict] = [] + + def fake_run_build(options: dict) -> SimpleNamespace: + calls.append(options) + return SimpleNamespace( + success=True, error=None, duration_ms=1, build=None, deploy=None, branch_info=None + ) + + monkeypatch.setattr(cli_index, "run_build", fake_run_build) + + assert cli_index.main(["build", "--dry-run", "--local"]) == 0 + assert calls == [{"dry_run": True, "dev_mode_override": "local"}] + + +def test_cli_entrypoint_build_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: + _mute_output(monkeypatch) + _deny_delegation(monkeypatch, "build") + monkeypatch.setattr( + cli_index, + "run_build", + lambda *_args, **_kwargs: SimpleNamespace( + success=False, error="boom", duration_ms=1, build=None, deploy=None, branch_info=None + ), + ) + assert cli_index.main(["build"]) == 1 + + +def test_cli_entrypoint_runs_deploy_locally( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _deny_delegation(monkeypatch, "deploy") + calls: list[dict] = [] + + def fake_run_deploy(options: dict) -> SimpleNamespace: + calls.append(options) + return SimpleNamespace(success=True, error=None, duration_ms=99, build=None, deploy=None) + + monkeypatch.setattr(cli_index, "run_deploy", fake_run_deploy) + + assert cli_index.main(["deploy", "--check", "--allow-destructive-operations"]) == 0 + assert calls == [{"check": True, "allow_destructive_operations": True}] + out = capsys.readouterr().out + assert "Deploy completed" in out + + +def test_cli_entrypoint_deploy_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: + _mute_output(monkeypatch) + _deny_delegation(monkeypatch, "deploy") + monkeypatch.setattr( + cli_index, + "run_deploy", + lambda *_args, **_kwargs: SimpleNamespace( + success=False, error="deploy boom", duration_ms=1, build=None, deploy=None + ), + ) + assert cli_index.main(["deploy"]) == 1 + + +def test_cli_entrypoint_runs_dev_locally(monkeypatch: pytest.MonkeyPatch) -> None: + _deny_delegation(monkeypatch, "dev") + calls: list[dict] = [] + + def fake_run_dev(options: dict) -> dict: + calls.append(options) + return {"success": True} + + monkeypatch.setattr(cli_index, "run_dev", fake_run_dev) + + assert cli_index.main(["dev", "--local"]) == 0 + assert calls == [{"dev_mode_override": "local"}] + + +def test_cli_entrypoint_dev_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: + _mute_output(monkeypatch) + _deny_delegation(monkeypatch, "dev") + monkeypatch.setattr( + cli_index, "run_dev", lambda *_args, **_kwargs: {"success": False, "error": "dev boom"} + ) + assert cli_index.main(["dev"]) == 1 + + +def test_cli_entrypoint_build_rejects_local_and_branch_together() -> None: + with pytest.raises(SystemExit): + cli_index.main(["build", "--local", "--branch"]) From 889e85fd60e03c609cbac8994de1a53c7e180d08 Mon Sep 17 00:00:00 2001 From: Tommy Healy Date: Fri, 2 Oct 2026 11:19:08 +0200 Subject: [PATCH 2/2] Revert native build/deploy/dev wiring; keep delegating to the installed Tinybird CLI build/deploy/dev already work correctly via the real tb CLI delegation path (main's SDK_OWNED_COMMANDS policy). Reimplementing them against the raw /v1/build and /v1/deploy endpoints duplicates logic the installed CLI already owns and can drift from its actual behavior. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 -- src/tinybird_sdk/cli/index.py | 106 +------------------------- tests/test_cli_entrypoint.py | 135 +--------------------------------- 3 files changed, 4 insertions(+), 243 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec9239..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 build`, `tinybird deploy`, and `tinybird dev` 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 6be295b..1183961 100644 --- a/src/tinybird_sdk/cli/index.py +++ b/src/tinybird_sdk/cli/index.py @@ -5,15 +5,10 @@ import json import sys -from .commands.build import run_build -from .commands.deploy import run_deploy -from .commands.dev import run_dev from .commands.generate import run_generate from .commands.init import run_init from .commands.migrate import run_migrate -from .output import BranchDisplayInfo, output - -_SDK_OWNED_COMMANDS = {"init", "generate", "migrate", "build", "deploy", "dev"} +from .output import output def _print_json(payload: object) -> None: @@ -86,53 +81,6 @@ def create_cli() -> argparse.ArgumentParser: ) migrate_cmd.add_argument("--json", action="store_true", help="Print migration result as JSON") - build_cmd = sub.add_parser("build", help="Build and deploy Tinybird resources to a branch") - build_cmd.add_argument( - "--dry-run", action="store_true", help="Validate resources without deploying" - ) - build_mode = build_cmd.add_mutually_exclusive_group() - build_mode.add_argument( - "--local", - action="store_const", - dest="dev_mode", - const="local", - help="Build against Tinybird Local", - ) - build_mode.add_argument( - "--branch", - action="store_const", - dest="dev_mode", - const="branch", - help="Build against a cloud branch", - ) - - deploy_cmd = sub.add_parser("deploy", help="Deploy Tinybird resources to the main workspace") - deploy_cmd.add_argument( - "--check", action="store_true", help="Validate the deployment without applying it" - ) - deploy_cmd.add_argument( - "--allow-destructive-operations", - action="store_true", - help="Allow deployments that include destructive operations", - ) - - dev_cmd = sub.add_parser("dev", help="Build Tinybird resources and watch for changes") - dev_mode = dev_cmd.add_mutually_exclusive_group() - dev_mode.add_argument( - "--local", - action="store_const", - dest="dev_mode", - const="local", - help="Build against Tinybird Local", - ) - dev_mode.add_argument( - "--branch", - action="store_const", - dest="dev_mode", - const="branch", - help="Build against a cloud branch", - ) - return parser @@ -140,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() @@ -194,56 +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 == "build": - build_result = run_build( - { - "dry_run": args.dry_run, - "dev_mode_override": args.dev_mode, - } - ) - if not build_result.success: - output.show_build_failure() - if build_result.error: - output.error(build_result.error) - return 1 - - output.show_build_success(build_result.duration_ms) - if build_result.branch_info: - output.show_branch_info( - BranchDisplayInfo( - mode="local" if build_result.branch_info.is_local else "branch", - git_branch=build_result.branch_info.git_branch, - tinybird_branch=build_result.branch_info.tinybird_branch, - created=build_result.branch_info.was_created, - ) - ) - if build_result.branch_info.dashboard_url: - output.info(f"Dashboard: {build_result.branch_info.dashboard_url}") - return 0 - - if args.command == "deploy": - deploy_result = run_deploy( - { - "check": args.check, - "allow_destructive_operations": args.allow_destructive_operations, - } - ) - if not deploy_result.success: - output.show_deploy_failure() - if deploy_result.error: - output.error(deploy_result.error) - return 1 - - output.show_deploy_success(deploy_result.duration_ms) - return 0 - - if args.command == "dev": - dev_result = run_dev({"dev_mode_override": args.dev_mode}) - if not dev_result.get("success"): - output.error(dev_result.get("error") or "Dev failed") - return 1 - return 0 - migrate_result = run_migrate( { "cwd": args.cwd, diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 410e6a7..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 == ["pull", "--force"] 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(["pull", "--force"]) == 7 + assert cli_index.main(["build", "--dry-run"]) == 7 def test_cli_entrypoint_delegates_empty_argv(monkeypatch: pytest.MonkeyPatch) -> None: @@ -210,134 +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_build_locally( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - _deny_delegation(monkeypatch, "build") - calls: list[dict] = [] - - def fake_run_build(options: dict) -> SimpleNamespace: - calls.append(options) - return SimpleNamespace( - success=True, - error=None, - duration_ms=42, - build=None, - deploy=None, - branch_info=SimpleNamespace( - git_branch="feature-x", - tinybird_branch="feature_x", - was_created=True, - dashboard_url="https://app.tinybird.co/feature_x", - is_local=False, - ), - ) - - monkeypatch.setattr(cli_index, "run_build", fake_run_build) - - assert cli_index.main(["build", "--branch"]) == 0 - assert calls == [{"dry_run": False, "dev_mode_override": "branch"}] - out = capsys.readouterr().out - assert "Build completed" in out - assert "Tinybird branch: feature_x" in out - assert "Dashboard: https://app.tinybird.co/feature_x" in out - - -def test_cli_entrypoint_build_dry_run_passes_flags(monkeypatch: pytest.MonkeyPatch) -> None: - _deny_delegation(monkeypatch, "build") - calls: list[dict] = [] - - def fake_run_build(options: dict) -> SimpleNamespace: - calls.append(options) - return SimpleNamespace( - success=True, error=None, duration_ms=1, build=None, deploy=None, branch_info=None - ) - - monkeypatch.setattr(cli_index, "run_build", fake_run_build) - - assert cli_index.main(["build", "--dry-run", "--local"]) == 0 - assert calls == [{"dry_run": True, "dev_mode_override": "local"}] - - -def test_cli_entrypoint_build_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: - _mute_output(monkeypatch) - _deny_delegation(monkeypatch, "build") - monkeypatch.setattr( - cli_index, - "run_build", - lambda *_args, **_kwargs: SimpleNamespace( - success=False, error="boom", duration_ms=1, build=None, deploy=None, branch_info=None - ), - ) - assert cli_index.main(["build"]) == 1 - - -def test_cli_entrypoint_runs_deploy_locally( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - _deny_delegation(monkeypatch, "deploy") - calls: list[dict] = [] - - def fake_run_deploy(options: dict) -> SimpleNamespace: - calls.append(options) - return SimpleNamespace(success=True, error=None, duration_ms=99, build=None, deploy=None) - - monkeypatch.setattr(cli_index, "run_deploy", fake_run_deploy) - - assert cli_index.main(["deploy", "--check", "--allow-destructive-operations"]) == 0 - assert calls == [{"check": True, "allow_destructive_operations": True}] - out = capsys.readouterr().out - assert "Deploy completed" in out - - -def test_cli_entrypoint_deploy_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: - _mute_output(monkeypatch) - _deny_delegation(monkeypatch, "deploy") - monkeypatch.setattr( - cli_index, - "run_deploy", - lambda *_args, **_kwargs: SimpleNamespace( - success=False, error="deploy boom", duration_ms=1, build=None, deploy=None - ), - ) - assert cli_index.main(["deploy"]) == 1 - - -def test_cli_entrypoint_runs_dev_locally(monkeypatch: pytest.MonkeyPatch) -> None: - _deny_delegation(monkeypatch, "dev") - calls: list[dict] = [] - - def fake_run_dev(options: dict) -> dict: - calls.append(options) - return {"success": True} - - monkeypatch.setattr(cli_index, "run_dev", fake_run_dev) - - assert cli_index.main(["dev", "--local"]) == 0 - assert calls == [{"dev_mode_override": "local"}] - - -def test_cli_entrypoint_dev_failure_returns_error(monkeypatch: pytest.MonkeyPatch) -> None: - _mute_output(monkeypatch) - _deny_delegation(monkeypatch, "dev") - monkeypatch.setattr( - cli_index, "run_dev", lambda *_args, **_kwargs: {"success": False, "error": "dev boom"} - ) - assert cli_index.main(["dev"]) == 1 - - -def test_cli_entrypoint_build_rejects_local_and_branch_together() -> None: - with pytest.raises(SystemExit): - cli_index.main(["build", "--local", "--branch"])