From 778339485ee1356345dfdffe7e6741b78527f56a Mon Sep 17 00:00:00 2001 From: ottomansky Date: Mon, 11 May 2026 23:59:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(0.32.0):=20kbagent=20storage=20truncate-ta?= =?UTF-8?q?ble=20=E2=80=94=20row-level=20truncation=20that=20preserves=20s?= =?UTF-8?q?chema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a typed wrapper for the Storage API row-truncate endpoint (DELETE /v2/storage/[branch/{id}/]tables/{id}/rows?allowTruncate=1). Closes the only confirmed FIIA-migration gap (Coverage Matrix Row 25; R5 per-phase reload invariant). - Client (src/keboola_agent_cli/client.py): truncate_table(table_id, branch_id=None) primitive. Live-validated against project 1143 on connection.europe-west3.gcp.keboola.com — the endpoint is uniformly async-via-job on every branch and REJECTS async=true with HTTP 400 ("async: This field was not expected."), a deliberate departure from sibling destructive endpoints (delete_table, delete_bucket). The endpoint always returns HTTP 202 + a queued tableRowsDelete job that the existing _wait_for_storage_job helper polls to completion. - Service (src/keboola_agent_cli/services/storage_service.py): truncate_tables(alias, table_ids[], dry_run, branch_id) mirrors delete_tables's batch-tolerant pattern. Captures rows_before via get_table_detail with a defensive int() guard that handles None, missing rowsCount, non-numeric strings, lists, and dicts. Per-target KeboolaApiError accumulates into failed[] without aborting the batch. Envelope: {truncated:[{table_id,rows_before,rows_after, branch_id}], failed:[{id,error}], dry_run, project_alias, would_truncate?} matching delete-table sibling naming. - Command (src/keboola_agent_cli/commands/storage.py): storage_truncate_table under rich_help_panel=_TABLES, immediately after delete-table. Same --yes / --dry-run / --branch contract, ConfigError → exit 5, failed[] → exit 1. - Hint registry (hints/definitions/storage.py): storage.truncate-table registered with both client and service paths. - Permission registry (permissions.py): storage.truncate-table classified as 'destructive' alongside delete-table, delete-column, delete-bucket, swap-tables. - Tests (tests/test_storage_truncate.py): 21 unit tests (5 client via pytest-httpx, 10 service via MagicMock, 6 CLI via CliRunner) covering URL/query-params, branch_id prefix, async-poll roundtrip, URL encoding, 4xx propagation, happy path, defensive rowsCount guard (non-numeric + missing), batch partial failure, dry-run, all exit codes, active-branch fallback. - E2E (tests/test_e2e.py): _test_truncate_table_roundtrip at step 11.1 snapshots schema → dry-runs → truncates → verifies preservation → restores. Live-validated against project 1143: rows 5→0, PK / columns / descriptions intact, restore round-trip clean. - 7 silent-drift surfaces synced per convention #17: context.py, CLAUDE.md ## All CLI Commands, keboola-expert.md (Rule 6 VERSION GATE + Tool Selection Matrix + inline gotcha), SKILL.md (auto-regenerated), commands-reference.md, gotchas.md (new (since v0.32.0) section), changelog.py. - Version bump 0.31.0 → 0.32.0 (minor, new feature on top of upstream 0.31.0 which shipped project edit --new-alias + sql_resplit). Propagated via make version-sync to plugin.json + marketplace.json + uv.lock. Independent review iterations 1+2+3 (security + references + code quality) all converged to zero material findings. --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 1 + plugins/kbagent/.claude-plugin/plugin.json | 2 +- plugins/kbagent/agents/keboola-expert.md | 20 + plugins/kbagent/skills/kbagent/SKILL.md | 1 + .../kbagent/references/commands-reference.md | 1 + .../skills/kbagent/references/gotchas.md | 43 ++ pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 5 + src/keboola_agent_cli/client.py | 35 ++ src/keboola_agent_cli/commands/context.py | 6 + src/keboola_agent_cli/commands/storage.py | 117 ++++ .../hints/definitions/storage.py | 51 ++ src/keboola_agent_cli/permissions.py | 1 + .../services/storage_service.py | 97 +++ tests/test_e2e.py | 122 ++++ tests/test_storage_truncate.py | 587 ++++++++++++++++++ uv.lock | 2 +- 18 files changed, 1091 insertions(+), 4 deletions(-) create mode 100644 tests/test_storage_truncate.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f81c1f1b..1ecbdff2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.31.0", + "version": "0.32.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" diff --git a/CLAUDE.md b/CLAUDE.md index be8c7ab1..e5f2eed3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -308,6 +308,7 @@ kbagent storage create-table --project NAME --bucket-id ID --name NAME --column kbagent storage upload-table --project NAME --table-id ID --file PATH [--incremental] [--branch ID] kbagent storage download-table --project NAME --table-id ID [--output FILE] [--columns COL ...] [--limit N] [--branch ID] kbagent storage delete-table --project NAME --table-id ID [--table-id ...] [--force] [--dry-run] [--yes] [--branch ID] +kbagent storage truncate-table --project NAME --table-id ID [--table-id ...] [--dry-run] [--yes] [--branch ID] kbagent storage delete-column --project NAME --table-id ID --column COL [--column ...] [--force] [--dry-run] [--yes] [--branch ID] kbagent storage delete-bucket --project NAME --bucket-id ID [--bucket-id ...] [--force] [--dry-run] [--yes] [--branch ID] kbagent storage swap-tables --project NAME --table-id ID --target-table-id ID --branch ID [--dry-run] [--yes] diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 9f24f6c0..a33f341d 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.31.0", + "version": "0.32.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", diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index c0658955..80e705ff 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -78,6 +78,7 @@ a critical failure. `config row-delete`, `config oauth-url` need 0.30.0+, `project edit --new-alias` (cascading rename across config.json + nested sync dir; warns on lineage cache rebuild) needs 0.31.0+, + `storage truncate-table` needs 0.32.0+, `storage retype` is a future composite), you MUST refuse the task and return a handoff message to the parent: `"Cannot proceed safely on kbagent . Missing: . @@ -112,6 +113,7 @@ a critical failure. | Retype table columns | fetch types via `workspace query`, draft types YAML, write new transformation that produces typed output table, then `kbagent storage swap-tables` (0.28.0+) to flip the typed copy into the original name in a dev branch | `kbagent --hint client create_table_definition` if the future `storage retype` composite (§14.3) is not yet present | `POST /v2/storage/buckets/.../tables-definition` (REST) followed by manual config rewrites | | Create typed table with native types | `kbagent storage create-table --column pk:VARCHAR(40) --column amount:NUMBER(18,2) --not-null pk --default amount=0` (0.25.0+) | `tool call create_table` (accepts the same `definition.length` shape via MCP) | re-creating via raw REST to `/v2/storage/...tables-definition` | | Promote typed rebuild back into the original name | `kbagent storage swap-tables --project P --table-id in.c-foo.data --target-table-id in.c-foo.data_change_log --branch --yes` (0.28.0+) -- async storage job (`tableSwap`); client polls to completion before returning. Service refuses without a branch | -- | renaming or deleting + re-uploading (loses history; downstream configs need to be rewritten) | +| Re-seed a table without losing its schema / PK / dependents | `kbagent storage truncate-table --project P --table-id in.c-foo.data [--branch ID] [--dry-run] [--yes]` (0.32.0+) -- DELETE `/tables/{id}/rows?allowTruncate=1`; endpoint is uniformly async on every branch (returns a queued `tableRowsDelete` job; client polls via `_wait_for_storage_job`). Do NOT pass `async=true` -- the API rejects it. Batch via repeated `--table-id`. Returns `{truncated[], failed[], dry_run, project_alias}` with `truncated[]` entries carrying `{table_id, rows_before, rows_after, branch_id}`. Permission class: `destructive` | `tool call delete_table_rows` if the upstream MCP exposes it | drop + recreate the table (loses descriptions, PK, sharing edges, and breaks every downstream config reference); deleting rows via raw SQL in a workspace (bypasses the Storage API audit trail) | | Debug a failed job | `kbagent job detail --project P --job-id J --json` + `kbagent job run ... --log-tail-lines 200` | `kbagent workspace from-transformation` for SQL repro | "I think the issue is..." without reading logs | | Ad-hoc SQL / row-count / type audit | `kbagent workspace create` + `kbagent workspace load` + `kbagent workspace query --sql "..."` | `kbagent workspace from-transformation` for existing transform debugging | querying Keboola Storage directly via Snowflake credentials outside the workspace abstraction | | Inspect dev branch | `kbagent branch list --project P`, `kbagent branch use --project P --branch ID` | `tool call get_branch` | acting on `main` when a dev branch exists | @@ -232,6 +234,24 @@ success, not a failure. verification payload but do not treat it as a failure signal. Production writes never materialize anything. +- **`storage truncate-table` is row-only; schema and dependents are + preserved** (0.32.0+): the underlying call is + `DELETE /v2/storage/[branch/{id}/]tables/{id}/rows?allowTruncate=1`. + The endpoint is **uniformly async** on every branch -- it returns + HTTP 202 with a queued storage job (`operationName: tableRowsDelete`) + that the client polls to completion via `_wait_for_storage_job`, + same machinery as `delete_table`. Production branches finish the + job in under a second; dev branches may take longer. **Do not pass + `async=true`** -- the Storage API rejects it with HTTP 400 + ("async: This field was not expected.") for this endpoint, even + though sibling destructive endpoints (`delete_table`, `delete_bucket`) + require it. Aliases, sharing edges, primary keys, descriptions, and + downstream config references all survive -- only the rows are + removed. The Storage API requires the `allowTruncate=1` opt-in + whenever no row filter is sent; kbagent always passes it. Prefer + this over `delete-table` for any "re-seed" pattern; reach for + `delete-table` only when the table itself is being retired. + - **`project invite` "already invited / already member" is a no-op, not a failure** (0.29.0+): Re-inviting a user the project already knows returns HTTP 400 from the Manage API. kbagent normalises both "...already been invited..." and diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 7000b366..9f6e7111 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -157,6 +157,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Upload a CSV file into a storage table | `kbagent storage upload-table --project PROJECT --table-id TABLE-ID --file FILE` | | Export a storage table to a local CSV file | `kbagent storage download-table --project PROJECT --table-id TABLE-ID` | | Delete one or more storage tables | `kbagent storage delete-table --project PROJECT --table-id TABLE-ID` | +| Truncate (delete all rows from) one or more storage tables | `kbagent storage truncate-table --project PROJECT --table-id TABLE-ID` | | Delete one or more columns from a storage table | `kbagent storage delete-column --project PROJECT --table-id TABLE-ID --column COLUMN` | | Swap two storage tables in a development branch | `kbagent storage swap-tables --project PROJECT --table-id TABLE-ID --target-table-id TARGET-TABLE-ID` | | Delete one or more storage buckets | `kbagent storage delete-bucket --project PROJECT --bucket-id BUCKET-ID` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index eabb9d73..34be9dc9 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -88,6 +88,7 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the - `storage upload-table --project NAME --table-id ID --file PATH [--incremental] [--branch ID]` -- upload CSV (branch-aware) - `storage download-table --project NAME --table-id ID [--output FILE] [--columns COL ...] [--limit N] [--branch ID]` -- export table to CSV (branch-aware) - `storage delete-table --project NAME --table-id ID [--table-id ...] [--force] [--dry-run] [--yes] [--branch ID]` -- delete tables, --force cascade-deletes aliased tables (branch-aware) +- `storage truncate-table --project NAME --table-id ID [--table-id ...] [--dry-run] [--yes] [--branch ID]` (since v0.32.0) -- delete all rows while preserving table schema, primary key, descriptions, sharing edges, and downstream dependents. Batch via repeated `--table-id`. Endpoint is uniformly async-via-job on every branch (returns a queued `tableRowsDelete` job; client polls via `_wait_for_storage_job` before returning). Idempotent (truncating an empty table is a no-op). Use when re-seeding a table without losing the schema contract - `storage delete-column --project NAME --table-id ID --column COL [--column ...] [--force] [--dry-run] [--yes] [--branch ID]` -- delete columns from a table (branch-aware) - `storage delete-bucket --project NAME --bucket-id ID [--bucket-id ...] [--force] [--dry-run] [--yes] [--branch ID]` -- delete buckets (branch-aware) - `storage swap-tables --project NAME --table-id ID --target-table-id ID --branch ID [--dry-run] [--yes]` (since v0.28.0) -- swap two storage tables in a dev branch (POST `/tables/{id}/swap`). Both tables exchange physical positions; aliases are NOT transferred (they keep pointing at the same physical position and therefore expose the OTHER table's data after the swap). Service refuses without a branch (active branch via `branch use` works too). Use to flip a typed rebuild ("data_change_log") into the original name ("data") without touching downstream config references diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 9f1ec964..62d6fd1d 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -69,6 +69,49 @@ swaps it back into the original name. After merging the branch the original table now carries the typed schema with no downstream config rewrite required. + +## `storage truncate-table` preserves schema; endpoint is uniformly async-via-job (since v0.32.0) + +- `kbagent storage truncate-table --project P --table-id T [--branch ID] + [--dry-run] [--yes]` calls + `DELETE /v2/storage/[branch/{id}/]tables/{id}/rows?allowTruncate=1` + on the Storage API. The `allowTruncate=1` flag is a safety opt-in + the API requires whenever no row filter is sent -- omitting it + returns HTTP 400. kbagent always passes it; do the same in any + `--hint client` script. +- **Do NOT pass `async=true` on this endpoint.** Sibling destructive + endpoints (`delete_table`, `delete_bucket`) require `async=true`, + but the row-delete endpoint **rejects** it with HTTP 400 + (`"async: This field was not expected."` -- verified live + 2026-05-11 on connection.europe-west3.gcp.keboola.com). The endpoint + is inherently async on every branch: it always returns HTTP 202 + with a queued storage job (`operationName: tableRowsDelete`) that + the client polls via `_wait_for_storage_job` -- same machinery as + `delete_table`, just without the `async=true` query param. +- **Sub-second on production, longer on dev branches.** Same poll + loop in both cases; only wall-clock latency differs. From the + caller's perspective the call always blocks until rows_after=0 + is authoritative on return. +- **Idempotent.** Truncating an empty table is a no-op success + (`rows_before=0`, `rows_after=0`, `failed=[]`). Safe to retry; safe + to run as a pre-load step that may or may not have data to clear. +- **What survives:** column definitions, types, primary key, + descriptions, sharing edges, and every downstream config reference + (aliases, input/output mappings, transformation refs). What does + not survive: the rows. Pick `truncate-table` whenever the schema + contract must hold; pick `delete-table` only when retiring the + table itself. +- **Propagation.** The Storage API removes the rows immediately on + the warehouse side -- consumers of an aliased / shared bucket see + zero rows on the next query, no quiesce window. A downstream + transformation that started reading the table *just before* + truncate may see partial state mid-job. Plan re-seed steps so the + truncate completes before any downstream job picks it up. +- **Permission classification.** `storage.truncate-table` is + `destructive` -- alongside `delete-table`, `delete-column`, + `delete-bucket`, `swap-tables`. Schema preservation does not + downgrade the row-data destruction. + ## `data-app create --auth public` writes the canonical noneProxyAuthorization shape (since v0.29.0; fixes v0.27.0 silent HTTP 503) - **What changed.** v0.27.0's `--auth public` wrote NO `authorization` diff --git a/pyproject.toml b/pyproject.toml index 025bebf4..7d1d1a05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.31.0" +version = "0.32.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index ce8bac16..23290c87 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,11 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.32.0": [ + 'New: `kbagent storage truncate-table --project NAME --table-id ID [--table-id ...] [--dry-run] [--yes] [--branch ID]` -- row-level truncation that drops every row from one or more storage tables while preserving the table definition (columns, types, primary key, descriptions, sharing edges, and every downstream config reference). Closes the only confirmed FIIA-migration gap (Coverage Matrix Row 25; R5 per-phase reload invariant). Wraps `DELETE /v2/storage/[branch/{id}/]tables/{id}/rows?allowTruncate=1`. Notable departure from sibling destructive endpoints: the row-delete endpoint is inherently async on every branch and rejects `async=true` as an unknown field (verified live 2026-05-11: HTTP 400 `"async: This field was not expected."`). The client therefore omits `async=true` and lets the endpoint return its natural HTTP 202 + queued `tableRowsDelete` job, which `_wait_for_storage_job` polls to completion -- same machinery as `delete_table`, just without the query-param dance. Multi-target: per-table errors accumulate without aborting the batch (one missing table does not block the rest). JSON envelope mirrors `delete-tables`\'s naming with a richer per-target receipt: `{truncated: [{table_id, rows_before, rows_after, branch_id}], failed: [{id, error}], dry_run, project_alias, would_truncate?}`. `--dry-run` captures `rows_before` via `get_table_detail` without truncating. Idempotent (truncating an empty table is a no-op success). Permission classification: `destructive` (gated behind `--allow-destructive` / `cli:destructive` policies) alongside `delete-table` / `delete-column` / `delete-bucket` / `swap-tables` -- schema preservation does not downgrade row-data deletion. Use this over `delete-table` whenever the schema contract must survive (sharing edges, aliases, dependent transformations, primary keys, column descriptions).', + "Tests: `tests/test_storage_truncate.py` adds 21 unit tests across three layers -- HTTP shape (5: URL+query-params with allowTruncate=1, branch_id URL prefix, async-poll roundtrip, URL encoding of dotted table IDs, 4xx propagation via `pytest_httpx`), service business logic (10: happy path, branch_id carried into truncated[] entries, non-numeric `rowsCount` defaults to 0, missing `rowsCount` defaults to 0, batch partial failure with NOT_FOUND on second target, dry-run skips `truncate_table`, branch_id propagation to both `get_table_detail` and `truncate_table`, unknown-project `ConfigError`, try/finally `close()` on API error, empty-list short-circuit), and CLI integration (6: JSON happy path with `--yes`, `--dry-run` JSON shape, `--branch` flag override, active-branch fallback, exit 1 on `failed[]`, exit 5 on `ConfigError`). E2E coverage in `tests/test_e2e.py::TestFullE2E` adds step 11.1 `_test_truncate_table_roundtrip`: snapshots schema (columns + primary key + identity) on the 8-row test table, dry-runs the truncate (verifies `would_truncate.rows_before` matches), applies it (verifies `rows_after=0`), re-verifies schema integrity (columns + PK + identity unchanged), then restores the 5+3 CSV pair so downstream hops see the original row count. Live-API smoke against project 1143 on `connection.europe-west3.gcp.keboola.com` 2026-05-11 confirmed the full flow including the async-only endpoint discovery.", + "Plugin docs: synced across all 7 silent-drift surfaces (CLAUDE.md #17). `commands/context.py` AGENT_CONTEXT gains the storage-Lifecycle entry. `CLAUDE.md` `## All CLI Commands` lists the new signature. `keboola-expert.md` Rule 6 VERSION GATE adds `storage truncate-table needs 0.32.0+`, the Tool Selection Matrix adds a `Re-seed a table without losing its schema / PK / dependents` row, and a new inline gotcha clarifies the uniformly-async behavior + the `async=true` rejection. `SKILL.md` auto-regenerated via `make skill-gen`. `commands-reference.md` adds the bullet between `delete-table` and `delete-column`. `gotchas.md` adds a `(since v0.32.0)` section covering the `allowTruncate=1` opt-in, the live-API discovery that `async=true` is rejected, the uniform async-via-job behavior, idempotence, propagation timing, and permission classification. Hint registry adds two-step entry under `storage.truncate-table` so `--hint client` and `--hint service` emit reusable snippets.", + ], "0.31.0": [ "New: `kbagent project edit --new-alias NEW [--dry-run]` -- rename the alias of an existing project connection without going through `project remove` + `project add` (which forces token re-entry). Cascades the rename through everything that persists the alias on disk: the `config.json` `projects` dict key (`pop(old)` + insert under `new`) AND the `default_project` field if it matched the old alias. When a nested-layout sync workspace is present at `//.keboola/manifest.json`, the directory itself is also renamed to `//` -- mirrors the `kbagent config rename` precedent (`-2`-suffix collision handling, git-mv with shutil.move fallback). Skips the disk step when no sync workspace is present. Combined with `--url` and/or `--token` in a single invocation those mutations target the NEW alias post-rename, so `kbagent project edit --project foo --new-alias bar --token NEW` is one atomic operation with the expected ordering. Backed by the new `ConfigStore.rename_project(old, new)` method (atomic dict-key swap + `default_project` update saved as one transaction) and a fail-closed `ProjectService._rename_project_alias()` helper that validates collision before touching any state. Validation: empty `new_alias`, whitespace-only `new_alias`, and `new_alias` that already exists are all rejected with `ConfigError` exit code 5.", "New: `--dry-run` previews the rename (collision detection, planned disk-rename method `git_mv` vs `shutil_move`, lineage-cache warning) without mutating any state. Validation errors (`..` path-traversal, collision, invalid format) raise the same `ConfigError` exit-5 codes as the live path -- callers can rely on `--dry-run` as a 1:1 pre-flight. Token re-verification is also skipped in dry-run mode (no API hit). Result dict carries `dry_run: True` and a `planned` sub-dict. Backed by `_plan_project_alias_rename()` and `_plan_nested_sync_dir()` helpers in `services/project_service.py` -- pure read-only mirrors of the live `_rename_project_alias` / `_rename_nested_sync_dir`. Addresses PR #266 review NIT (UX consideration: even non-classically-destructive ops benefit from a dry-run pre-flight).", diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index 59eb7eb4..e0a36ef2 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -1686,6 +1686,41 @@ def delete_table( response = self._request("DELETE", f"{prefix}/tables/{safe_id}", params=params) return self._wait_for_storage_job(response.json()) + def truncate_table( + self, + table_id: str, + branch_id: int | None = None, + ) -> dict[str, Any]: + """Truncate a storage table (delete all rows; preserve schema). + + The Storage API requires the ``allowTruncate=1`` safety opt-in to + confirm the caller intends to remove every row when no filter + clauses are sent. The endpoint is inherently asynchronous on + every branch -- it always returns ``HTTP 202`` with a queued + storage job (``operationName: tableRowsDelete``), which + ``_wait_for_storage_job`` polls to completion. Passing + ``async=true`` is rejected by the API as an unknown field, so + we do NOT send it (this is a deliberate departure from + ``delete_table``'s contract -- see the truncate-table gotcha + in plugins/.../gotchas.md for the live-API evidence). + + The table definition (columns, types, primary key, descriptions, + sharing edges, and dependents) is preserved -- only the rows + are removed. + + Args: + table_id: Full table ID (e.g. "in.c-bucket.table"). + branch_id: If set, target a specific dev branch. + + Returns: + Completed storage job dict. + """ + prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage" + safe_id = quote(table_id, safe="") + params: dict[str, str] = {"allowTruncate": "1"} + response = self._request("DELETE", f"{prefix}/tables/{safe_id}/rows", params=params) + return self._wait_for_storage_job(response.json()) + def delete_column( self, table_id: str, diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 41e18bc4..ffd60909 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -358,6 +358,12 @@ kbagent storage delete-table --project NAME --table-id ID [--table-id ...] [--force] [--dry-run] [--yes] [--branch ID] Delete one or more tables. Batch: repeat --table-id. --force to cascade-delete aliased tables. --dry-run to preview. Branch-aware. + kbagent storage truncate-table --project NAME --table-id ID [--table-id ...] [--dry-run] [--yes] [--branch ID] + Truncate one or more tables (delete all rows; preserve schema, primary key, descriptions, sharing edges, and dependents). + Batch: repeat --table-id. Endpoint is async-via-job on every branch (the client polls to completion before returning; + do not pass async=true -- the API rejects it). Idempotent (truncating an empty table is a no-op). Use this when re-seeding + a table without losing the schema contract. + kbagent storage delete-column --project NAME --table-id ID --column COL [--column ...] [--force] [--dry-run] [--yes] [--branch ID] Delete one or more columns from a table. Batch: repeat --column. --force when column is referenced by aliases. --dry-run to preview. Branch-aware. diff --git a/src/keboola_agent_cli/commands/storage.py b/src/keboola_agent_cli/commands/storage.py index 9a204888..1766af51 100644 --- a/src/keboola_agent_cli/commands/storage.py +++ b/src/keboola_agent_cli/commands/storage.py @@ -1019,6 +1019,123 @@ def storage_delete_table( raise typer.Exit(code=1) +@storage_app.command("truncate-table", rich_help_panel=_TABLES) +def storage_truncate_table( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias", + ), + table_id: list[str] = typer.Option( + ..., + "--table-id", + help="Table ID to truncate (e.g. 'in.c-bucket.table'). Can be repeated.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would be truncated without executing", + ), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Skip confirmation prompt", + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Dev branch ID (defaults to active branch if set via 'branch use')", + ), +) -> None: + """Truncate (delete all rows from) one or more storage tables. + + Preserves the table definition: columns, types, primary key, + descriptions, sharing edges, and dependents are unaffected -- only + rows are removed. Idempotent (truncating an empty table is a no-op). + + The Storage API truncate endpoint is asynchronous: it returns a + queued storage job which the client polls to completion before + surfacing the result. Both production and dev branches behave the + same way; the only difference is wall-clock latency (sub-second + on production, longer on busy dev branches). + + Use this when re-seeding a table without losing the schema contract. + To destroy the table itself, use ``storage delete-table``. + """ + if should_hint(ctx): + emit_hint( + ctx, + "storage.truncate-table", + project=project, + table_id=table_id, + dry_run=dry_run, + branch=branch, + ) + + formatter = get_formatter(ctx) + service = get_service(ctx, "storage_service") + config_store: ConfigStore = ctx.obj["config_store"] + _, effective_branch = resolve_branch(config_store, formatter, project, branch) + + if dry_run: + try: + result = service.truncate_tables( + alias=project, + table_ids=table_id, + dry_run=True, + branch_id=effective_branch, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + if formatter.json_mode: + formatter.output(result) + else: + for entry in result.get("would_truncate", []): + formatter.console.print( + f"[bold blue]Would truncate:[/bold blue] {entry['table_id']} " + f"(rows_before={entry['rows_before']})" + ) + return + + confirm_msg = ( + f"Truncate {len(table_id)} table(s) in project '{project}'? " + "All rows will be deleted; schema and dependents are preserved." + ) + if not yes and not formatter.json_mode and not typer.confirm(confirm_msg): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + try: + result = service.truncate_tables( + alias=project, + table_ids=table_id, + branch_id=effective_branch, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + if formatter.json_mode: + formatter.output(result) + else: + for entry in result["truncated"]: + formatter.console.print( + f"[bold green]Truncated:[/bold green] {entry['table_id']} " + f"({entry['rows_before']} -> 0 rows)" + ) + for f_item in result["failed"]: + formatter.console.print( + f"[bold red]Failed:[/bold red] {f_item['id']}: {f_item['error']}" + ) + + if result["failed"]: + raise typer.Exit(code=1) + + @storage_app.command("delete-column", rich_help_panel=_TABLES) def storage_delete_column( ctx: typer.Context, diff --git a/src/keboola_agent_cli/hints/definitions/storage.py b/src/keboola_agent_cli/hints/definitions/storage.py index 86e8622f..f46db744 100644 --- a/src/keboola_agent_cli/hints/definitions/storage.py +++ b/src/keboola_agent_cli/hints/definitions/storage.py @@ -368,6 +368,57 @@ ) ) +# ── storage truncate-table ──────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="storage.truncate-table", + description="Truncate (delete all rows from) one or more tables; preserves schema", + steps=[ + HintStep( + comment="Capture rows_before for the receipt", + client=ClientCall( + method="get_table_detail", + args={ + "table_id": "{table_id}", + "branch_id": "{branch}", + }, + result_var="table", + ), + service=None, + ), + HintStep( + comment="Truncate the table (preserves columns, PK, descriptions, dependents)", + client=ClientCall( + method="truncate_table", + args={ + "table_id": "{table_id}", + "branch_id": "{branch}", + }, + result_var="result", + ), + service=ServiceCall( + service_class="StorageService", + service_module="storage_service", + method="truncate_tables", + args={ + "alias": "{project}", + "table_ids": "{table_id}", + "dry_run": "{dry_run}", + "branch_id": "{branch}", + }, + ), + ), + ], + notes=[ + "Client layer truncates one table at a time. Loop for batch.", + "Endpoint: DELETE /v2/storage/[branch/{id}/]tables/{id}/rows?allowTruncate=1.", + "Endpoint is uniformly async on every branch -- returns a queued job that _wait_for_storage_job polls to completion. Do NOT pass async=true (the API rejects it).", + "Table schema, primary key, descriptions, and dependents are preserved.", + ], + ) +) + # ── storage delete-column ───────────────────────────────────────── HintRegistry.register( diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 095b5154..649a05d9 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -142,6 +142,7 @@ "storage.delete-bucket": "destructive", "storage.file-delete": "destructive", "storage.swap-tables": "destructive", + "storage.truncate-table": "destructive", # Storage descriptions "storage.describe-bucket": "write", "storage.describe-table": "write", diff --git a/src/keboola_agent_cli/services/storage_service.py b/src/keboola_agent_cli/services/storage_service.py index 93ed1b46..8b395707 100644 --- a/src/keboola_agent_cli/services/storage_service.py +++ b/src/keboola_agent_cli/services/storage_service.py @@ -1112,6 +1112,103 @@ def delete_tables( "project_alias": alias, } + def truncate_tables( + self, + alias: str, + table_ids: list[str], + dry_run: bool = False, + branch_id: int | None = None, + ) -> dict[str, Any]: + """Truncate one or more storage tables (delete all rows; preserve schema). + + Batch-tolerant: per-table errors accumulate; one missing table does + not abort the batch. ``rows_before`` is captured from + ``get_table_detail`` so callers can confirm the operation had a + non-trivial effect. The table definition (columns, types, primary + key, descriptions, sharing edges, dependents) is preserved. + + Args: + alias: Project alias. + table_ids: List of table IDs to truncate. + dry_run: If True, capture rows_before but do NOT truncate. + branch_id: If set, target a specific dev branch. + + Returns: + Dict with 'truncated', 'failed', 'dry_run', 'project_alias', + and (when dry_run) 'would_truncate'. Each ``truncated[]`` entry + carries ``{table_id, rows_before, rows_after, branch_id}``. + The Storage API truncate endpoint is uniformly async-via-job + on every branch (verified live 2026-05-11 on + connection.europe-west3.gcp.keboola.com); the client polls + the queued job to completion before returning, so + ``rows_after`` is always 0 on success. + """ + from ..errors import KeboolaApiError + + projects = self.resolve_projects([alias]) + project = projects[alias] + + truncated: list[dict[str, Any]] = [] + failed: list[dict[str, str]] = [] + would_truncate: list[dict[str, Any]] = [] + + client = self._client_factory(project.stack_url, project.token) + try: + for tid in table_ids: + try: + table = client.get_table_detail(tid, branch_id=branch_id) + except KeboolaApiError as exc: + failed.append({"id": tid, "error": exc.message}) + continue + + # rowsCount is a Storage API integer field but the API does + # not guarantee it is always present or coercible (legacy + # tables, alias views over recently-truncated sources). + # Treat any non-int as 0 rather than letting ValueError tear + # down the whole batch. + raw_rows = table.get("rowsCount") + try: + rows_before = int(raw_rows) if raw_rows is not None else 0 + except (ValueError, TypeError): + rows_before = 0 + + if dry_run: + would_truncate.append( + { + "table_id": tid, + "rows_before": rows_before, + "branch_id": branch_id, + } + ) + continue + + try: + client.truncate_table(tid, branch_id=branch_id) + except KeboolaApiError as exc: + failed.append({"id": tid, "error": exc.message}) + continue + + truncated.append( + { + "table_id": tid, + "rows_before": rows_before, + "rows_after": 0, + "branch_id": branch_id, + } + ) + finally: + client.close() + + result: dict[str, Any] = { + "truncated": truncated, + "failed": failed, + "dry_run": dry_run, + "project_alias": alias, + } + if dry_run: + result["would_truncate"] = would_truncate + return result + def delete_columns( self, alias: str, diff --git a/tests/test_e2e.py b/tests/test_e2e.py index fc20de3d..48e43715 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -390,6 +390,13 @@ def test_full_cli_e2e(self) -> None: ) self._test_upload_incremental(table_id) + _step( + 11.1, + "storage truncate-table", + "drop all rows, verify schema preserved, restore data", + ) + self._test_truncate_table_roundtrip(table_id) + _step(12, "storage tables + table-detail") self._test_table_listing(bucket_id, table_id) @@ -784,6 +791,121 @@ def _test_upload_incremental(self, table_id: str) -> None: rows = list(reader) assert len(rows) == 8, f"Expected 8 rows after incremental upload, got {len(rows)}" + def _test_truncate_table_roundtrip(self, table_id: str) -> None: + """Drop all rows, verify schema and downstream invariants, then restore. + + Asserts the contract that distinguishes ``truncate-table`` from + ``delete-table``: column definitions, primary key, and table identity + survive; only the rows go to zero. After verification, re-uploads the + same 5+3 CSV pair so the table returns to its prior 8-row state for + downstream test hops. + """ + # Snapshot the pre-truncate schema for the post-truncate diff. + before = self._run_ok( + "storage", + "table-detail", + "--project", + self.alias, + "--table-id", + table_id, + )["data"] + assert before["rows_count"] > 0, ( + f"truncate roundtrip needs a non-empty table; got rows_count={before['rows_count']}" + ) + before_columns = sorted(c["name"] for c in before["column_details"]) + before_pk = list(before.get("primary_key") or []) + + # Dry-run: receipt must show rows_before > 0 but never touch the table. + dry = self._run_ok( + "storage", + "truncate-table", + "--project", + self.alias, + "--table-id", + table_id, + "--dry-run", + )["data"] + assert dry["dry_run"] is True + assert dry["would_truncate"][0]["table_id"] == table_id + assert dry["would_truncate"][0]["rows_before"] == before["rows_count"] + + # Apply: rows must report as 0. The Storage API endpoint is + # uniformly async-via-job; the client polls to completion before + # returning, so rows_after=0 is authoritative at this point. + applied = self._run_ok( + "storage", + "truncate-table", + "--project", + self.alias, + "--table-id", + table_id, + "--yes", + )["data"] + assert applied["dry_run"] is False + assert applied["truncated"][0]["table_id"] == table_id + assert applied["truncated"][0]["rows_before"] == before["rows_count"] + assert applied["truncated"][0]["rows_after"] == 0 + assert applied["failed"] == [] + + # Verify: rowsCount=0, columns unchanged, primary key unchanged, + # table identity unchanged. + after = self._run_ok( + "storage", + "table-detail", + "--project", + self.alias, + "--table-id", + table_id, + )["data"] + assert after["rows_count"] == 0, ( + f"expected rows_count=0 after truncate, got {after['rows_count']}" + ) + after_columns = sorted(c["name"] for c in after["column_details"]) + assert after_columns == before_columns, ( + f"truncate changed columns: before={before_columns} after={after_columns}" + ) + assert list(after.get("primary_key") or []) == before_pk, "truncate changed primary key" + assert after["table_id"] == before["table_id"], "table identity changed" + + # Restore: re-upload the same 5-row base + 3-row incremental so the + # downstream hops (download, unload, workspace load) see the same + # row count they would have otherwise. + base_csv = _create_test_csv(self.data_dir, rows=5) + self._run_ok( + "storage", + "upload-table", + "--project", + self.alias, + "--table-id", + table_id, + "--file", + str(base_csv), + ) + incr_csv = _create_incremental_csv(self.data_dir, start=6, rows=3) + self._run_ok( + "storage", + "upload-table", + "--project", + self.alias, + "--table-id", + table_id, + "--file", + str(incr_csv), + "--incremental", + ) + + restored = self._run_ok( + "storage", + "table-detail", + "--project", + self.alias, + "--table-id", + table_id, + )["data"] + assert restored["rows_count"] == before["rows_count"], ( + f"restore failed: expected {before['rows_count']} rows, got {restored['rows_count']}" + ) + def _test_table_listing(self, bucket_id: str, table_id: str) -> None: """Verify table appears in listings and detail is correct.""" # tables diff --git a/tests/test_storage_truncate.py b/tests/test_storage_truncate.py new file mode 100644 index 00000000..1fdcea3e --- /dev/null +++ b/tests/test_storage_truncate.py @@ -0,0 +1,587 @@ +"""Tests for storage truncate-table: client, service, and CLI.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.client import KeboolaClient +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.models import AppConfig, ProjectConfig +from keboola_agent_cli.services.storage_service import StorageService + +runner = CliRunner() + +TEST_TOKEN = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + + +def _make_store(tmp_path: Path) -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir(exist_ok=True) + store = ConfigStore(config_dir=config_dir) + config = AppConfig( + projects={ + "test": ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ) + }, + ) + store.save(config) + return store + + +def _make_service(store: ConfigStore, mock_client: MagicMock) -> StorageService: + return StorageService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + +# --------------------------------------------------------------------------- +# Client layer +# --------------------------------------------------------------------------- + + +class TestTruncateTableClient: + """Tests for KeboolaClient.truncate_table() - HTTP layer.""" + + def test_correct_url_and_query_params(self, httpx_mock) -> None: + """DELETE /v2/storage/tables/{id}/rows?allowTruncate=1. + + The Storage API requires the allowTruncate=1 safety opt-in but + REJECTS async=true on this endpoint (verified live 2026-05-11 + on connection.europe-west3.gcp.keboola.com); the endpoint is + inherently async and returns a queued job (HTTP 202). For unit + coverage, this test returns status=success directly so + _wait_for_storage_job's fast-path is exercised. + """ + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tables/in.c-foo.data/rows?allowTruncate=1", + method="DELETE", + json={ + "id": 386488069, + "status": "success", + "operationName": "tableRowsDelete", + }, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ) + result = client.truncate_table(table_id="in.c-foo.data") + client.close() + + assert result["status"] == "success" + assert result["operationName"] == "tableRowsDelete" + + def test_branch_prefix_in_url(self, httpx_mock) -> None: + """branch_id=42 routes through /v2/storage/branch/42/tables/.../rows.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/branch/42/tables/in.c-foo.data/rows?allowTruncate=1", + method="DELETE", + json={"id": 1, "status": "success", "operationName": "tableRowsDelete"}, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ) + client.truncate_table(table_id="in.c-foo.data", branch_id=42) + client.close() + + def test_polls_async_job_to_completion(self, httpx_mock) -> None: + """Initial status=waiting triggers GET /v2/storage/jobs/{id} until success. + + Dev-branch path: the helper polls until the job reaches a terminal + state, then returns the final job dict. + """ + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/branch/77/tables/in.c-foo.data/rows?allowTruncate=1", + method="DELETE", + json={"id": 555, "status": "waiting", "operationName": "tableRowsDelete"}, + status_code=200, + ) + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/jobs/555", + method="GET", + json={"id": 555, "status": "success", "operationName": "tableRowsDelete"}, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ) + result = client.truncate_table(table_id="in.c-foo.data", branch_id=77) + client.close() + + assert result["status"] == "success" + assert result["id"] == 555 + + def test_url_encoding_for_special_characters(self, httpx_mock) -> None: + """Table IDs with dots and dashes are URL-encoded in the path.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tables/in.c-bucket-with-dashes.tbl/rows?allowTruncate=1", + method="DELETE", + json={"id": 1, "status": "success"}, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ) + client.truncate_table(table_id="in.c-bucket-with-dashes.tbl") + client.close() + + def test_api_error_propagates(self, httpx_mock) -> None: + """Storage API 4xx propagates as KeboolaApiError.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tables/in.c-foo.x/rows?allowTruncate=1", + method="DELETE", + json={"error": "Table in.c-foo.x not found"}, + status_code=404, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ) + with pytest.raises(KeboolaApiError): + client.truncate_table(table_id="in.c-foo.x") + client.close() + + +# --------------------------------------------------------------------------- +# Service layer +# --------------------------------------------------------------------------- + + +class TestTruncateTableService: + """Tests for StorageService.truncate_tables().""" + + def test_single_table_success(self, tmp_path: Path) -> None: + """Happy path: capture rows_before, truncate, report rows_after=0.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + mock_client.get_table_detail.return_value = { + "id": "in.c-foo.data", + "rowsCount": 100, + } + mock_client.truncate_table.return_value = {"status": "success"} + service = _make_service(store, mock_client) + + result = service.truncate_tables(alias="test", table_ids=["in.c-foo.data"]) + + assert result["failed"] == [] + assert result["dry_run"] is False + assert result["project_alias"] == "test" + assert len(result["truncated"]) == 1 + entry = result["truncated"][0] + assert entry["table_id"] == "in.c-foo.data" + assert entry["rows_before"] == 100 + assert entry["rows_after"] == 0 + assert entry["branch_id"] is None + mock_client.truncate_table.assert_called_once_with("in.c-foo.data", branch_id=None) + + def test_branch_id_carried_on_entry(self, tmp_path: Path) -> None: + """Each truncated[] entry records the branch_id used for the call.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + mock_client.get_table_detail.return_value = {"rowsCount": 50} + mock_client.truncate_table.return_value = {"status": "success"} + service = _make_service(store, mock_client) + + result = service.truncate_tables(alias="test", table_ids=["in.c-foo.data"], branch_id=42) + + assert result["truncated"][0]["branch_id"] == 42 + mock_client.truncate_table.assert_called_once_with("in.c-foo.data", branch_id=42) + + def test_rows_count_non_numeric_defaults_to_zero(self, tmp_path: Path) -> None: + """Defensive: a non-int rowsCount must not crash the batch.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + mock_client.get_table_detail.return_value = {"rowsCount": "not-a-number"} + mock_client.truncate_table.return_value = {"status": "success"} + service = _make_service(store, mock_client) + + result = service.truncate_tables(alias="test", table_ids=["in.c-foo.data"]) + + assert result["failed"] == [] + assert result["truncated"][0]["rows_before"] == 0 + assert result["truncated"][0]["rows_after"] == 0 + + def test_rows_count_missing_defaults_to_zero(self, tmp_path: Path) -> None: + """Defensive: missing rowsCount key → rows_before=0, not KeyError.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + mock_client.get_table_detail.return_value = {"id": "in.c-foo.data"} + mock_client.truncate_table.return_value = {"status": "success"} + service = _make_service(store, mock_client) + + result = service.truncate_tables(alias="test", table_ids=["in.c-foo.data"]) + + assert result["truncated"][0]["rows_before"] == 0 + + def test_batch_partial_failure(self, tmp_path: Path) -> None: + """One missing table does not abort the batch.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + # First detail succeeds; second raises NOT_FOUND. + mock_client.get_table_detail.side_effect = [ + {"rowsCount": 42}, + KeboolaApiError("Table not found", status_code=404, error_code="NOT_FOUND"), + ] + mock_client.truncate_table.return_value = {"status": "success"} + service = _make_service(store, mock_client) + + result = service.truncate_tables( + alias="test", + table_ids=["in.c-foo.data", "in.c-foo.missing"], + ) + + assert len(result["truncated"]) == 1 + assert result["truncated"][0]["table_id"] == "in.c-foo.data" + assert len(result["failed"]) == 1 + assert result["failed"][0]["id"] == "in.c-foo.missing" + assert "not found" in result["failed"][0]["error"].lower() + # truncate_table must NOT have been called for the missing table. + mock_client.truncate_table.assert_called_once() + + def test_dry_run_skips_truncate(self, tmp_path: Path) -> None: + """dry_run captures rows_before via get_table_detail but never truncates.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + mock_client.get_table_detail.return_value = {"rowsCount": 999} + service = _make_service(store, mock_client) + + result = service.truncate_tables( + alias="test", + table_ids=["in.c-foo.data"], + dry_run=True, + ) + + assert result["dry_run"] is True + assert result["truncated"] == [] + assert result["failed"] == [] + assert len(result["would_truncate"]) == 1 + wt = result["would_truncate"][0] + assert wt["table_id"] == "in.c-foo.data" + assert wt["rows_before"] == 999 + assert wt["branch_id"] is None + mock_client.truncate_table.assert_not_called() + + def test_branch_id_propagates_to_client(self, tmp_path: Path) -> None: + """branch_id flows into both get_table_detail and truncate_table.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + mock_client.get_table_detail.return_value = {"rowsCount": 1} + mock_client.truncate_table.return_value = {"status": "success"} + service = _make_service(store, mock_client) + + service.truncate_tables(alias="test", table_ids=["in.c-foo.data"], branch_id=99) + + mock_client.get_table_detail.assert_called_once_with("in.c-foo.data", branch_id=99) + mock_client.truncate_table.assert_called_once_with("in.c-foo.data", branch_id=99) + + def test_unknown_project(self, tmp_path: Path) -> None: + """Unknown alias surfaces as ConfigError from resolve_projects().""" + store = _make_store(tmp_path) + mock_client = MagicMock() + service = _make_service(store, mock_client) + + with pytest.raises(ConfigError): + service.truncate_tables(alias="nonexistent", table_ids=["in.c-foo.data"]) + + def test_client_closed_even_when_truncate_raises(self, tmp_path: Path) -> None: + """try/finally contract: client.close() runs even on API failure.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + mock_client.get_table_detail.return_value = {"rowsCount": 1} + mock_client.truncate_table.side_effect = KeboolaApiError( + "Permission denied", status_code=403, error_code="STORAGE_FORBIDDEN" + ) + service = _make_service(store, mock_client) + + result = service.truncate_tables(alias="test", table_ids=["in.c-foo.data"]) + + # API errors do not propagate -- they accumulate in failed[]. + assert result["truncated"] == [] + assert result["failed"][0]["id"] == "in.c-foo.data" + # Regression guard: close() always runs. + mock_client.close.assert_called_once() + + def test_empty_table_ids_list(self, tmp_path: Path) -> None: + """Empty input → empty envelope, no client calls.""" + store = _make_store(tmp_path) + mock_client = MagicMock() + service = _make_service(store, mock_client) + + result = service.truncate_tables(alias="test", table_ids=[]) + + assert result["truncated"] == [] + assert result["failed"] == [] + assert result["dry_run"] is False + mock_client.get_table_detail.assert_not_called() + mock_client.truncate_table.assert_not_called() + + +# --------------------------------------------------------------------------- +# CLI layer +# --------------------------------------------------------------------------- + + +class TestTruncateTableCLI: + """CLI tests for `kbagent storage truncate-table`.""" + + def _project_with_active_branch(self, store: ConfigStore, branch_id: int) -> None: + config = store.load() + config.projects["test"].active_branch_id = branch_id + store.save(config) + + def test_json_happy_path(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.truncate_tables.return_value = { + "truncated": [ + { + "table_id": "in.c-foo.data", + "rows_before": 1230, + "rows_after": 0, + "branch_id": None, + } + ], + "failed": [], + "dry_run": False, + "project_alias": "test", + } + result = runner.invoke( + app, + [ + "--json", + "storage", + "truncate-table", + "--project", + "test", + "--table-id", + "in.c-foo.data", + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output)["data"] + assert data["truncated"][0]["table_id"] == "in.c-foo.data" + assert data["truncated"][0]["rows_before"] == 1230 + assert data["truncated"][0]["rows_after"] == 0 + svc.truncate_tables.assert_called_once_with( + alias="test", + table_ids=["in.c-foo.data"], + branch_id=None, + ) + + def test_dry_run_json(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.truncate_tables.return_value = { + "truncated": [], + "failed": [], + "would_truncate": [ + {"table_id": "in.c-foo.data", "rows_before": 7, "branch_id": None} + ], + "dry_run": True, + "project_alias": "test", + } + result = runner.invoke( + app, + [ + "--json", + "storage", + "truncate-table", + "--project", + "test", + "--table-id", + "in.c-foo.data", + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output)["data"] + assert data["dry_run"] is True + assert data["would_truncate"][0]["rows_before"] == 7 + call_kwargs = svc.truncate_tables.call_args.kwargs + assert call_kwargs["dry_run"] is True + + def test_branch_flag_passes_through(self, tmp_path: Path) -> None: + """--branch 42 overrides any active branch and reaches the service.""" + store = _make_store(tmp_path) + self._project_with_active_branch(store, 100) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.truncate_tables.return_value = { + "truncated": [ + { + "table_id": "in.c-foo.data", + "rows_before": 1, + "rows_after": 0, + "branch_id": 42, + } + ], + "failed": [], + "dry_run": False, + "project_alias": "test", + } + result = runner.invoke( + app, + [ + "--json", + "storage", + "truncate-table", + "--project", + "test", + "--table-id", + "in.c-foo.data", + "--branch", + "42", + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + call_kwargs = svc.truncate_tables.call_args.kwargs + assert call_kwargs["branch_id"] == 42 + + def test_active_branch_used_when_no_flag(self, tmp_path: Path) -> None: + """When no --branch is passed, the project's active_branch_id is used. + + Destructive writes (including truncate-table) honor the active branch + unlike pure-read storage commands which skip it. + """ + store = _make_store(tmp_path) + self._project_with_active_branch(store, 15931) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.truncate_tables.return_value = { + "truncated": [ + { + "table_id": "in.c-foo.data", + "rows_before": 0, + "rows_after": 0, + "branch_id": 15931, + } + ], + "failed": [], + "dry_run": False, + "project_alias": "test", + } + result = runner.invoke( + app, + [ + "--json", + "storage", + "truncate-table", + "--project", + "test", + "--table-id", + "in.c-foo.data", + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + call_kwargs = svc.truncate_tables.call_args.kwargs + assert call_kwargs["branch_id"] == 15931 + + def test_failed_truncation_exits_1(self, tmp_path: Path) -> None: + """A non-empty failed[] returns exit code 1.""" + store = _make_store(tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.truncate_tables.return_value = { + "truncated": [], + "failed": [{"id": "in.c-foo.missing", "error": "Table not found"}], + "dry_run": False, + "project_alias": "test", + } + result = runner.invoke( + app, + [ + "--json", + "storage", + "truncate-table", + "--project", + "test", + "--table-id", + "in.c-foo.missing", + "--yes", + ], + ) + + assert result.exit_code == 1 + data = json.loads(result.output)["data"] + assert data["failed"][0]["id"] == "in.c-foo.missing" + + def test_config_error_exits_5(self, tmp_path: Path) -> None: + """ConfigError from the service surfaces as exit 5 with CONFIG_ERROR.""" + store = _make_store(tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.truncate_tables.side_effect = ConfigError("Unknown project alias") + result = runner.invoke( + app, + [ + "--json", + "storage", + "truncate-table", + "--project", + "test", + "--table-id", + "in.c-foo.data", + "--yes", + ], + ) + + assert result.exit_code == 5 + payload = json.loads(result.output) + assert payload["status"] == "error" diff --git a/uv.lock b/uv.lock index ef1b3695..521fd517 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.31.0" +version = "0.32.0" source = { editable = "." } dependencies = [ { name = "httpx" },