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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "tinybird-sdk"
version = "0.1.9"
version = "0.1.10"
description = "Python SDK for Tinybird Forward"
readme = "README.md"
authors = [
Expand Down
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
30 changes: 22 additions & 8 deletions src/tinybird_sdk/api/branches.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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"


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


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


class BranchApiError(Exception):
def __init__(self, message: str, status: int, body: Any = None):
super().__init__(message)
Expand Down Expand Up @@ -64,9 +69,14 @@ 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
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,19 +154,23 @@ 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


def clear_branch(config: BranchApiConfig | dict[str, Any], name: str) -> TinybirdBranch:
def clear_branch(
config: BranchApiConfig | dict[str, Any], name: str, options: CreateBranchOptions | None = None
) -> TinybirdBranch:
normalized = config if isinstance(config, BranchApiConfig) else BranchApiConfig(**config)
delete_branch(normalized, name)
return create_branch(normalized, name)
return create_branch(normalized, name, options=options)
9 changes: 8 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,16 @@ 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_mode")
if branch_value and config.get("dev_mode") != "local":
branch_options = CreateBranchOptions(
last_partition=(branch_value == "last_partition"),
)
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
13 changes: 11 additions & 2 deletions src/tinybird_sdk/cli/commands/clear.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 clear_branch
from ...api.branches import CreateBranchOptions, clear_branch
from ...api.local import clear_local_workspace, get_local_tokens, get_local_workspace_name
from ..config import load_config_async

Expand Down Expand Up @@ -54,7 +54,16 @@ def run_clear(options: ClearCommandOptions | dict[str, Any] | None = None) -> Cl
duration_ms=int(time.time() * 1000) - start,
)

clear_branch({"base_url": config["base_url"], "token": config["token"]}, config["tinybird_branch"])
branch_options = None
branch_value = config.get("branch_data_mode")
if branch_value and config.get("dev_mode") != "local":
branch_options = CreateBranchOptions(last_partition=(branch_value == "last_partition"))

clear_branch(
{"base_url": config["base_url"], "token": config["token"]},
config["tinybird_branch"],
options=branch_options,
)
return ClearResult(
success=True,
branch=config["tinybird_branch"],
Expand Down
14 changes: 12 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,17 @@ 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_mode")
if branch_value and config.get("dev_mode") != "local":
branch_options = CreateBranchOptions(
last_partition=(branch_value == "last_partition"),
)
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
39 changes: 37 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_MODE_VALUES,
BranchDataModeEnum,
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,27 @@ class ResolvedConfig:
tinybird_branch: str | None
is_main_branch: bool
dev_mode: DevMode
branch_data_mode: str | None


def _resolve_branch_data_mode(raw: dict[str, Any]) -> tuple[str | None, bool]:
if "branch_data_on_create" in raw:
raise ValueError("`branch_data_on_create` has been renamed to `branch_data_mode`.")

value = raw.get("branch_data_mode")
if value is None:
return BranchDataModeEnum.LAST_PARTITION.value, False
if not isinstance(value, str):
raise ValueError("branch_data_mode must be a string.")

mode = value.strip().lower()
if not mode:
return BranchDataModeEnum.LAST_PARTITION.value, False
if mode not in BRANCH_DATA_MODE_VALUES:
raise ValueError(
f"Invalid branch_data_mode '{value}'. Allowed values are: {', '.join(BRANCH_DATA_MODE_VALUES)}."
)
return mode, True


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

branch_data_mode, branch_data_mode_explicit = _resolve_branch_data_mode(asdict(config))
dev_mode = config.dev_mode or "branch"
if branch_data_mode_explicit and branch_data_mode and dev_mode == "local":
print(
"Warning: branch_data_mode 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 +215,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_mode=branch_data_mode,
)


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

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

DevMode = Literal["branch", "local"]
BranchDataMode = Literal["last_partition"]
BRANCH_DATA_MODE_VALUES: tuple[str, ...] = ("last_partition",)


class BranchDataModeEnum(StrEnum):
LAST_PARTITION = "last_partition"


@dataclass(frozen=True, slots=True)
Expand All @@ -13,3 +20,4 @@ class TinybirdConfig:
token: str | None = None
base_url: str | None = None
dev_mode: DevMode | None = None
branch_data_mode: str | None = None
8 changes: 7 additions & 1 deletion src/tinybird_sdk/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Any

from ..api.api import TinybirdApi, TinybirdApiError
from ..api.branches import get_or_create_branch
from ..api.branches import CreateBranchOptions, get_or_create_branch
from ..cli.config import load_config_async
from .preview import get_preview_branch_name, is_preview_environment
from .tokens import TokensNamespace
Expand Down Expand Up @@ -146,12 +146,18 @@ def _resolve_branch_context(self) -> ClientContext:
)

branch_name = config["tinybird_branch"]
branch_options = None
branch_value = config.get("branch_data_mode")
if branch_value and config.get("dev_mode") != "local":
branch_options = CreateBranchOptions(last_partition=(branch_value == "last_partition"))

branch = get_or_create_branch(
{
"base_url": self._config["base_url"],
"token": self._config["token"],
},
branch_name,
options=branch_options,
)

if not branch.get("token"):
Expand Down
91 changes: 91 additions & 0 deletions tests/test_api_branches_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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, clear_branch, 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


def test_clear_branch_forwards_create_options(monkeypatch: pytest.MonkeyPatch) -> None:
captured_options: list[CreateBranchOptions | None] = []

monkeypatch.setattr(branches_module, "delete_branch", lambda *_args, **_kwargs: None)

def fake_create_branch(_config: dict[str, Any], _name: str, options: CreateBranchOptions | None = None) -> Any:
captured_options.append(options)
return {"id": "b1", "name": "x", "created_at": "2024-01-01T00:00:00Z", "token": "p.test"}

monkeypatch.setattr(branches_module, "create_branch", fake_create_branch)

clear_branch(
{"base_url": "https://api.tinybird.co", "token": "p.test"},
"x",
options=CreateBranchOptions(last_partition=True),
)

assert len(captured_options) == 1
assert captured_options[0] is not None
assert captured_options[0].last_partition is True
Loading