Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/tinybird_sdk/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
)
from .branches import (
TinybirdBranch,
CreateBranchOptions,
BranchApiConfig,
BranchApiError,
create_branch,
Expand Down
28 changes: 22 additions & 6 deletions src/tinybird_sdk/api/branches.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from __future__ import annotations

import time
from dataclasses import asdict
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from typing import Any
from urllib.parse import urlencode

from .fetcher import tinybird_fetch
LAST_PARTITION = "last_partition"
ALL_PARTITIONS = "all_partitions"


@dataclass(frozen=True, slots=True)
Expand All @@ -23,6 +24,12 @@ class TinybirdBranch:
token: str | None = None


@dataclass(frozen=True, slots=True)
class CreateBranchOptions:
last_partition: bool = False
all_partitions: bool = False


class BranchApiError(Exception):
def __init__(self, message: str, status: int, body: Any = None):
super().__init__(message)
Expand Down Expand Up @@ -64,9 +71,16 @@ def _poll_job(config: BranchApiConfig, job_id: str, max_attempts: int = 120, int
raise BranchApiError(f"Job '{job_id}' timed out after {max_attempts} attempts", 408)


def create_branch(config: BranchApiConfig | dict[str, Any], name: str) -> TinybirdBranch:
def create_branch(
config: BranchApiConfig | dict[str, Any], name: str, options: CreateBranchOptions | None = None
) -> TinybirdBranch:
normalized = config if isinstance(config, BranchApiConfig) else BranchApiConfig(**config)
url = f"{normalized.base_url.rstrip('/')}/v1/environments?{urlencode({'name': name})}"
params = {"name": name}
if options and options.last_partition:
params["data"] = LAST_PARTITION
elif options and options.all_partitions:
params["data"] = ALL_PARTITIONS
url = f"{normalized.base_url.rstrip('/')}/v1/environments?{urlencode(params)}"
response = tinybird_fetch(url, method="POST", headers=_headers(normalized.token))

if not response.ok:
Expand Down Expand Up @@ -144,14 +158,16 @@ def branch_exists(config: BranchApiConfig | dict[str, Any], name: str) -> bool:
return any(branch.name == name for branch in branches)


def get_or_create_branch(config: BranchApiConfig | dict[str, Any], name: str) -> dict[str, Any]:
def get_or_create_branch(
config: BranchApiConfig | dict[str, Any], name: str, options: CreateBranchOptions | None = None
) -> dict[str, Any]:
normalized = config if isinstance(config, BranchApiConfig) else BranchApiConfig(**config)
try:
branch = get_branch(normalized, name)
return {**asdict(branch), "was_created": False}
except BranchApiError as error:
if error.status == 404:
branch = create_branch(normalized, name)
branch = create_branch(normalized, name, options=options)
return {**asdict(branch), "was_created": True}
raise

Expand Down
10 changes: 9 additions & 1 deletion src/tinybird_sdk/cli/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import time
from typing import Any

from ...api.branches import get_or_create_branch
from ...api.branches import CreateBranchOptions, get_or_create_branch
from ...api.build import build_to_tinybird
from ...api.dashboard import get_branch_dashboard_url, get_local_dashboard_url
from ...api.local import LocalNotRunningError, get_local_tokens, get_local_workspace_name, get_or_create_local_workspace
Expand Down Expand Up @@ -119,9 +119,17 @@ def run_build(options: BuildCommandOptions | dict[str, Any] | None = None) -> Bu

if not normalized.token_override:
try:
branch_options = None
branch_value = config.get("branch_data_on_create")
if branch_value and config.get("dev_mode") != "local":
branch_options = CreateBranchOptions(
last_partition=(branch_value == "last_partition"),
all_partitions=(branch_value == "all_partitions"),
)
branch = get_or_create_branch(
{"base_url": config["base_url"], "token": config["token"]},
config["tinybird_branch"],
options=branch_options,
)
if not branch.get("token"):
return BuildCommandResult(
Expand Down
15 changes: 13 additions & 2 deletions src/tinybird_sdk/cli/commands/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import time
from typing import Any

from ...api.branches import create_branch, delete_branch, get_branch
from ...api.branches import CreateBranchOptions, create_branch, delete_branch, get_branch
from ...api.build import build_to_tinybird
from ...api.deploy import deploy_to_main
from ...api.local import LocalNotRunningError, get_local_tokens, get_or_create_local_workspace
Expand Down Expand Up @@ -131,7 +131,18 @@ def run_preview(options: PreviewCommandOptions | dict[str, Any] | None = None) -
except Exception:
pass

branch = create_branch({"base_url": config["base_url"], "token": config["token"]}, preview_branch_name)
branch_options = None
branch_value = config.get("branch_data_on_create")
if branch_value and config.get("dev_mode") != "local":
branch_options = CreateBranchOptions(
last_partition=(branch_value == "last_partition"),
all_partitions=(branch_value == "all_partitions"),
)
branch = create_branch(
{"base_url": config["base_url"], "token": config["token"]},
preview_branch_name,
options=branch_options,
)
except Exception as error:
return PreviewCommandResult(
success=False,
Expand Down
38 changes: 36 additions & 2 deletions src/tinybird_sdk/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
from typing import Any

from .config_loader import load_config_file
from .config_types import DevMode, TinybirdConfig
from .config_types import (
BRANCH_DATA_ON_CREATE_VALUES,
BranchDataOnCreateMode,
DevMode,
TinybirdConfig,
)
from .git import get_current_git_branch, get_tinybird_branch_name, is_main_branch

DEFAULT_BASE_URL = "https://api.tinybird.co"
Expand All @@ -34,6 +39,26 @@ class ResolvedConfig:
tinybird_branch: str | None
is_main_branch: bool
dev_mode: DevMode
branch_data_on_create: str | None


def _resolve_branch_data_on_create(raw: dict[str, Any]) -> str | None:
value = raw.get("branch_data_on_create")
if value is None:
return BranchDataOnCreateMode.LAST_PARTITION.value
if not isinstance(value, str):
raise ValueError("branch_data_on_create must be a string.")

mode = value.strip().lower()
if not mode:
return BranchDataOnCreateMode.LAST_PARTITION.value
if mode not in BRANCH_DATA_ON_CREATE_VALUES:
raise ValueError(
f"Invalid branch_data_on_create '{value}'. Allowed values are: {', '.join(BRANCH_DATA_ON_CREATE_VALUES)}."
)
if mode == BranchDataOnCreateMode.ALL_PARTITIONS.value:
raise ValueError("branch_data_on_create 'all_partitions' is currently disabled.")
return mode


def load_env_files(directory: str) -> None:
Expand Down Expand Up @@ -172,6 +197,14 @@ def _resolve_config(config: TinybirdConfig, config_path: str) -> ResolvedConfig:
or DEFAULT_BASE_URL
)

branch_data_on_create = _resolve_branch_data_on_create(asdict(config))
dev_mode = config.dev_mode or "branch"
if branch_data_on_create and dev_mode == "local":
print(
"Warning: branch_data_on_create is set in tinybird.config.json but dev_mode='local'. "
"Branch data settings only apply to cloud branches."
)

return ResolvedConfig(
include=include,
token=token,
Expand All @@ -181,7 +214,8 @@ def _resolve_config(config: TinybirdConfig, config_path: str) -> ResolvedConfig:
git_branch=get_current_git_branch(),
tinybird_branch=get_tinybird_branch_name(),
is_main_branch=is_main_branch(),
dev_mode=config.dev_mode or "branch",
dev_mode=dev_mode,
branch_data_on_create=branch_data_on_create,
)


Expand Down
9 changes: 9 additions & 0 deletions src/tinybird_sdk/cli/config_types.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
from __future__ import annotations

from dataclasses import dataclass, field
from enum import StrEnum
from typing import Literal

DevMode = Literal["branch", "local"]
BranchDataOnCreate = Literal["last_partition", "all_partitions"]
BRANCH_DATA_ON_CREATE_VALUES: tuple[str, ...] = ("last_partition", "all_partitions")


class BranchDataOnCreateMode(StrEnum):
LAST_PARTITION = "last_partition"
ALL_PARTITIONS = "all_partitions"


@dataclass(frozen=True, slots=True)
Expand All @@ -13,3 +21,4 @@ class TinybirdConfig:
token: str | None = None
base_url: str | None = None
dev_mode: DevMode | None = None
branch_data_on_create: str | None = None
69 changes: 69 additions & 0 deletions tests/test_api_branches_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from typing import Any
from urllib.parse import parse_qs, urlparse

import pytest

import tinybird_sdk.api.branches as branches_module
from tinybird_sdk.api.branches import CreateBranchOptions, create_branch


class _FakeResponse:
def __init__(self, status_code: int, payload: dict[str, Any]):
self.status_code = status_code
self._payload = payload
self.text = ""

@property
def ok(self) -> bool:
return 200 <= self.status_code < 300

def json(self) -> dict[str, Any]:
return self._payload


def test_create_branch_uses_last_partition_data_query(monkeypatch: pytest.MonkeyPatch) -> None:
called_urls: list[str] = []

def fake_fetch(url: str, **_kwargs: Any) -> _FakeResponse:
called_urls.append(url)
if "/v1/environments?" in url:
return _FakeResponse(200, {"job": {"id": "job-1"}})
if "/v0/jobs/" in url:
return _FakeResponse(200, {"status": "done"})
return _FakeResponse(200, {"id": "b1", "name": "x", "created_at": "2024-01-01T00:00:00Z", "token": "p.test"})

monkeypatch.setattr(branches_module, "tinybird_fetch", fake_fetch)
create_branch(
{"base_url": "https://api.tinybird.co", "token": "p.test"},
"x",
options=CreateBranchOptions(last_partition=True),
)

parsed = urlparse(called_urls[0])
query = parse_qs(parsed.query)
assert parsed.path == "/v1/environments"
assert query == {"name": ["x"], "data": ["last_partition"]}


def test_create_branch_without_options_keeps_default_query(monkeypatch: pytest.MonkeyPatch) -> None:
called_urls: list[str] = []

def fake_fetch(url: str, **_kwargs: Any) -> _FakeResponse:
called_urls.append(url)
if "/v1/environments?" in url:
return _FakeResponse(200, {"job": {"id": "job-1"}})
if "/v0/jobs/" in url:
return _FakeResponse(200, {"status": "done"})
return _FakeResponse(200, {"id": "b1", "name": "x", "created_at": "2024-01-01T00:00:00Z", "token": "p.test"})

monkeypatch.setattr(branches_module, "tinybird_fetch", fake_fetch)
create_branch({"base_url": "https://api.tinybird.co", "token": "p.test"}, "x")

parsed = urlparse(called_urls[0])
query = parse_qs(parsed.query)
assert parsed.path == "/v1/environments"
assert query == {"name": ["x"]}
assert "data" not in query
assert "ignore_datasources" not in query
56 changes: 56 additions & 0 deletions tests/test_cli_branch_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

import json
from pathlib import Path

import pytest

from tinybird_sdk.cli.config import _resolve_branch_data_on_create, load_config


def test_branch_data_on_create_last_partition() -> None:
assert _resolve_branch_data_on_create({"branch_data_on_create": "last_partition"}) == "last_partition"


def test_branch_data_on_create_missing_returns_none() -> None:
assert _resolve_branch_data_on_create({}) == "last_partition"


def test_branch_data_on_create_empty_defaults_to_last_partition() -> None:
assert _resolve_branch_data_on_create({"branch_data_on_create": " "}) == "last_partition"


def test_branch_data_on_create_all_partitions_disabled() -> None:
with pytest.raises(ValueError, match="disabled"):
_resolve_branch_data_on_create({"branch_data_on_create": "all_partitions"})


def test_branch_data_on_create_invalid_value() -> None:
with pytest.raises(ValueError, match="Invalid branch_data_on_create"):
_resolve_branch_data_on_create({"branch_data_on_create": "invalid"})


def test_branch_data_on_create_non_string() -> None:
with pytest.raises(ValueError, match="must be a string"):
_resolve_branch_data_on_create({"branch_data_on_create": 1})


def test_load_config_warns_when_local_mode_uses_branch_data(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
project = tmp_path / "project"
project.mkdir()
(project / "tinybird.config.json").write_text(
json.dumps(
{
"include": ["lib/datasources.py"],
"token": "p.test",
"base_url": "https://api.tinybird.co",
"dev_mode": "local",
"branch_data_on_create": "last_partition",
}
),
encoding="utf-8",
)

load_config(str(project))
captured = capsys.readouterr()
assert "branch_data_on_create is set" in captured.out
Loading