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 .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "kbagent",
"version": "0.66.0",
"version": "0.67.0",
"source": "./plugins/kbagent",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"category": "development"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.66.0",
"version": "0.67.0",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-cli"
version = "0.66.0"
version = "0.67.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
14 changes: 14 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.67.0": [
"New (web UI): the `kbagent serve --ui` table detail now has a **Repartition** tab "
"for BigQuery tables. Pick a time or integer-range partitioning layout plus optional "
"clustering fields, and the UI copies the table into the new layout (`create-table "
"--source-table-id`) and atomically swaps it into place (`swap-tables`) -- the same "
"supported repartition flow as the CLI. After the swap it offers to delete the leftover "
"old table. Runs in the branch selected in the top bar; with no dev branch selected it "
"repartitions production behind an explicit confirm.",
"The `serve` create-table endpoint (`POST /storage/tables/{project}`) now forwards the "
"source-copy and BigQuery partition/clustering fields (`source_table_id`, "
"`time_partitioning_*`, `range_partitioning_*`, `clustering_fields`) and makes `columns` "
"optional, matching the CLI. Table detail responses now include the owning bucket's "
"`backend` so the UI can gate BigQuery-only features.",
],
"0.66.0": [
"New: `storage create-table` can copy from an existing table and apply a "
"BigQuery partition/clustering layout. `--source-table-id` (with optional "
Expand Down
45 changes: 43 additions & 2 deletions src/keboola_agent_cli/server/routers/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, model_validator

from ..dependencies import ServiceRegistry, get_registry

Expand All @@ -26,12 +26,43 @@ class CreateBucket(BaseModel):
class CreateTable(BaseModel):
bucket_id: str
name: str
columns: list[str]
# Optional: exactly one of `columns` / `source_table_id` is required, mirroring
# the CLI. Source mode derives the schema from an existing (BigQuery) table.
columns: list[str] | None = None
primary_key: list[str] | None = None
not_null_columns: list[str] | None = None
defaults: list[str] | None = None
branch_id: int | None = None
if_not_exists: bool = False
# Source-copy + BigQuery partition/clustering layout. BigQuery-only; the
# service applies a pre-flight backend guard. Shapes mirror
# `kbagent storage create-table` (see services.storage_service.create_table).
source_table_id: str | None = None
source_branch_id: int | None = None
time_partitioning_type: str | None = None
time_partitioning_field: str | None = None
time_partitioning_expiration_ms: str | None = None
range_partitioning_field: str | None = None
range_partitioning_start: str | None = None
range_partitioning_end: str | None = None
range_partitioning_interval: str | None = None
clustering_fields: list[str] | None = None

@model_validator(mode="after")
def _columns_xor_source(self) -> CreateTable:
"""Enforce the columns/source XOR at the request boundary so a bad body
returns a clean 422 instead of bubbling up as a 500 from the service.

This mirrors the first two checks in ``StorageService.create_table``;
the service stays the source of truth for the full ruleset (not-null /
default placement, partitioning completeness, the BigQuery backend
guard) and for the CLI path, so a small overlap here is deliberate.
"""
if self.columns and self.source_table_id:
raise ValueError("columns and source_table_id are mutually exclusive")
if not self.columns and not self.source_table_id:
raise ValueError("one of columns or source_table_id is required")
return self


class DescribeBucket(BaseModel):
Expand Down Expand Up @@ -267,6 +298,16 @@ def create_table(
not_null_columns=body.not_null_columns,
defaults=body.defaults,
if_not_exists=body.if_not_exists,
source_table_id=body.source_table_id,
source_branch_id=body.source_branch_id,
time_partitioning_type=body.time_partitioning_type,
time_partitioning_field=body.time_partitioning_field,
time_partitioning_expiration_ms=body.time_partitioning_expiration_ms,
range_partitioning_field=body.range_partitioning_field,
range_partitioning_start=body.range_partitioning_start,
range_partitioning_end=body.range_partitioning_end,
range_partitioning_interval=body.range_partitioning_interval,
clustering_fields=body.clustering_fields,
)


Expand Down
3 changes: 3 additions & 0 deletions src/keboola_agent_cli/services/storage_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,9 @@ def get_table_detail(
"name": table.get("name", ""),
"display_name": table.get("displayName", ""),
"bucket_id": table.get("bucket", {}).get("id", ""),
# Storage backend of the owning bucket (e.g. "snowflake", "bigquery").
# The web UI keys BigQuery-only features (repartition) off this.
"backend": table.get("bucket", {}).get("backend", ""),
"description": description,
"columns": columns,
"column_details": column_details,
Expand Down
97 changes: 97 additions & 0 deletions tests/test_server_router_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,103 @@ def test_storage_describe_columns_passes_columns_kwarg(tmp_path: Path) -> None:
)


# ---------------------------------------------------------------------------
# storage.py POST /{p} (create table)
# Service: storage.create_table(source_table_id=..., time_partitioning_*=...,
# clustering_fields=...) -- the source-copy + BigQuery layout params
# must be forwarded so the web "repartition" flow reaches the service.
# ---------------------------------------------------------------------------


def test_storage_create_table_forwards_source_and_partition_kwargs(tmp_path: Path) -> None:
"""Router must forward the source-copy + BigQuery partition/clustering body
fields to StorageService.create_table (the web repartition flow)."""
storage_svc = MagicMock()
storage_svc.create_table.return_value = {"table_id": "in.c-main.events_repart"}
registry = _mock_registry(storage=storage_svc)
app = _make_app_with_registry(tmp_path, registry)

body = {
"bucket_id": "in.c-main",
"name": "events_repart",
"source_table_id": "in.c-main.events",
"branch_id": 123,
"time_partitioning_type": "DAY",
"time_partitioning_field": "created_at",
"clustering_fields": ["tenant_id"],
"primary_key": ["id"],
}
with TestClient(app) as client:
res = client.post(f"/storage/tables/{PROJECT}", headers=AUTH, json=body)

assert res.status_code == 200, res.text
kwargs = storage_svc.create_table.call_args.kwargs
assert kwargs["source_table_id"] == "in.c-main.events"
assert kwargs["time_partitioning_type"] == "DAY"
assert kwargs["time_partitioning_field"] == "created_at"
assert kwargs["clustering_fields"] == ["tenant_id"]
# columns is optional now (source mode); router passes None, not a crash.
assert kwargs["columns"] is None


def test_storage_create_table_columns_optional(tmp_path: Path) -> None:
"""`columns` is no longer required by the request model -- a source-only
body must validate (HTTP 200), not 422."""
storage_svc = MagicMock()
storage_svc.create_table.return_value = {"table_id": "in.c-main.t"}
registry = _mock_registry(storage=storage_svc)
app = _make_app_with_registry(tmp_path, registry)

with TestClient(app) as client:
res = client.post(
f"/storage/tables/{PROJECT}",
headers=AUTH,
json={"bucket_id": "in.c-main", "name": "t", "source_table_id": "in.c-main.src"},
)

assert res.status_code == 200, res.text


def test_storage_create_table_columns_and_source_is_422(tmp_path: Path) -> None:
"""Both columns and source_table_id given -> clean 422 at the request
boundary (not a 500 from the service)."""
storage_svc = MagicMock()
registry = _mock_registry(storage=storage_svc)
app = _make_app_with_registry(tmp_path, registry)

with TestClient(app) as client:
res = client.post(
f"/storage/tables/{PROJECT}",
headers=AUTH,
json={
"bucket_id": "in.c-main",
"name": "t",
"columns": ["id:INTEGER"],
"source_table_id": "in.c-main.src",
},
)

assert res.status_code == 422, res.text
storage_svc.create_table.assert_not_called()


def test_storage_create_table_neither_columns_nor_source_is_422(tmp_path: Path) -> None:
"""Neither columns nor source_table_id -> clean 422, service untouched."""
storage_svc = MagicMock()
registry = _mock_registry(storage=storage_svc)
app = _make_app_with_registry(tmp_path, registry)

with TestClient(app) as client:
res = client.post(
f"/storage/tables/{PROJECT}",
headers=AUTH,
json={"bucket_id": "in.c-main", "name": "t"},
)

assert res.status_code == 422, res.text
storage_svc.create_table.assert_not_called()


# ---------------------------------------------------------------------------
# storage.py GET /{p}/{fid}/file-download
# Service: storage.download_file(output_path=...) (was output_dir= in broken version)
Expand Down
42 changes: 42 additions & 0 deletions tests/test_storage_describe_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,3 +747,45 @@ def test_null_numeric_fields_coerced_to_zero(self, tmp_path: Path) -> None:

assert result["rows_count"] == 0
assert result["data_size_bytes"] == 0

def test_backend_surfaced_from_bucket(self, tmp_path: Path) -> None:
"""The owning bucket's backend is exposed so the web UI can gate
BigQuery-only features (repartition) on it."""
store = _make_store(tmp_path)
mock_client = MagicMock()
mock_client.get_table_detail.return_value = {
"id": "in.c-sales.orders",
"name": "orders",
"displayName": "orders",
"bucket": {"id": "in.c-sales", "backend": "bigquery"},
"columns": ["a"],
"primaryKey": [],
"columnMetadata": {},
"metadata": [],
}
service = _make_service(store, mock_client)

result = service.get_table_detail(alias="prod", table_id="in.c-sales.orders")

assert result["backend"] == "bigquery"

def test_backend_defaults_to_empty_when_absent(self, tmp_path: Path) -> None:
"""A bucket object without a backend key yields an empty string, not a
KeyError -- the UI simply hides the BigQuery-only tab."""
store = _make_store(tmp_path)
mock_client = MagicMock()
mock_client.get_table_detail.return_value = {
"id": "in.c-sales.orders",
"name": "orders",
"displayName": "orders",
"bucket": {"id": "in.c-sales"},
"columns": ["a"],
"primaryKey": [],
"columnMetadata": {},
"metadata": [],
}
service = _make_service(store, mock_client)

result = service.get_table_detail(alias="prod", table_id="in.c-sales.orders")

assert result["backend"] == ""
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading