Skip to content
Merged
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.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ kbagent job terminate --project NAME (--job-id ID [--job-id ID ...] | --status a

kbagent storage buckets [--project NAME] [--branch ID]
kbagent storage bucket-detail --project NAME --bucket-id ID [--branch ID]
kbagent storage tables --project NAME [--bucket-id ID] [--branch ID]
kbagent storage tables [--project NAME ...] [--bucket-id ID] [--branch ID]
kbagent storage table-detail --project NAME --table-id ID [--branch ID]
kbagent storage create-bucket --project NAME --stage STAGE --name NAME [--description D] [--backend B] [--branch ID]
kbagent storage create-table --project NAME --bucket-id ID --name NAME --column COL:TYPE [...] [--primary-key COL] [--branch ID]
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/skills/kbagent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ When working inside a git repository or project directory, run `kbagent init` (o
| Terminate one or more Queue API jobs (use to stop runaway or stuck jobs) | `kbagent job terminate --project PROJECT` |
| List storage buckets with sharing/linked bucket information | `kbagent storage buckets` |
| Show detailed bucket info including Snowflake direct access paths | `kbagent storage bucket-detail --project PROJECT --bucket-id BUCKET-ID` |
| List storage tables from a project | `kbagent storage tables --project PROJECT` |
| List storage tables from one or more projects | `kbagent storage tables` |
| Show detailed table info including columns and types | `kbagent storage table-detail --project PROJECT --table-id TABLE-ID` |
| Create a new storage bucket | `kbagent storage create-bucket --project PROJECT --stage STAGE --name NAME` |
| Create a new storage table with typed columns | `kbagent storage create-table --project PROJECT --bucket-id BUCKET-ID --name NAME --column COLUMN` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro
## Storage
- `storage buckets [--project NAME] [--branch ID]` -- list buckets with sharing/linked info (branch-aware)
- `storage bucket-detail --project NAME --bucket-id ID [--branch ID]` -- bucket detail with Snowflake paths (branch-aware)
- `storage tables --project NAME [--bucket-id ID] [--branch ID]` -- list tables, optionally by bucket (branch-aware)
- `storage tables [--project NAME ...] [--bucket-id ID] [--branch ID]` -- list tables across all connected projects in parallel (multi-project by default, same as `storage buckets`); repeat `--project` to target a subset; `--bucket-id` is applied independently per project (missing buckets become per-project errors); `--branch` requires exactly one `--project`
- `storage table-detail --project NAME --table-id ID [--branch ID]` -- table detail with columns, types, primary key, row count (branch-aware)
- `storage create-bucket --project NAME --stage STAGE --name NAME [--description D] [--backend B] [--branch ID]` -- create bucket (branch-aware)
- `storage create-table --project NAME --bucket-id ID --name NAME --column COL:TYPE [...] [--primary-key COL] [--branch ID]` -- create typed table (branch-aware)
Expand Down
10 changes: 8 additions & 2 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,14 @@
Bucket detail with Snowflake direct access paths. Resolves linked bucket source DB.
Uses production by default; pass --branch to query a dev branch explicitly.

kbagent storage tables --project NAME [--bucket-id BUCKET_ID] [--branch ID]
List storage tables, optionally filtered by bucket.
kbagent storage tables [--project NAME ...] [--bucket-id BUCKET_ID] [--branch ID]
List storage tables from one or more projects (in parallel). Omit --project
to query all connected projects. Repeat --project for a specific subset.
Multi-project by default, matching `storage buckets`, `config list`, `job list`.
Each row is tagged with project_alias; per-project errors accumulate in the
response envelope. --branch is only valid with a single --project.
--bucket-id is applied independently per project; missing buckets are
reported as per-project errors, not fatal.
Uses production by default; pass --branch to query a dev branch explicitly.

kbagent storage table-detail --project NAME --table-id TABLE_ID [--branch ID]
Expand Down
87 changes: 60 additions & 27 deletions src/keboola_agent_cli/commands/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,23 +226,29 @@ def storage_bucket_detail(
@storage_app.command("tables", rich_help_panel=_TABLES)
def storage_tables(
ctx: typer.Context,
project: str = typer.Option(
...,
project: list[str] | None = typer.Option(
None,
"--project",
help="Project alias",
help="Project alias (can be repeated for multiple projects). "
"Omit to query all connected projects in parallel.",
),
bucket_id: str | None = typer.Option(
None,
"--bucket-id",
help="Filter tables by bucket ID",
help="Filter tables by bucket ID (applied independently per project)",
),
branch: int | None = typer.Option(
None,
"--branch",
help="Dev branch ID (defaults to active branch if set via 'branch use')",
),
) -> None:
"""List storage tables from a project.
"""List storage tables from one or more projects.

Queries all connected projects in parallel by default, matching the
behaviour of ``storage buckets``, ``config list``, ``job list``, and other
read commands. Each row in the output is tagged with ``project_alias``
so results from multiple projects can be distinguished.

Branch handling: this read command uses the production endpoint by
default, even when a dev branch is active via `branch use`. The
Expand All @@ -256,14 +262,30 @@ def storage_tables(
formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")
config_store: ConfigStore = ctx.obj["config_store"]
# Read command: ignore implicit active dev branch (empty listing trap).
_, effective_branch = resolve_branch(
config_store, formatter, project, branch, ignore_active_branch=True
)

# --branch requires exactly one --project (branch ID is per-project).
# Mirrors the validation used by `storage buckets` and `config list`.
if branch is not None and (not project or len(project) != 1):
formatter.error(
message="--branch requires exactly one --project (branch ID is per-project)",
error_code="INVALID_ARGUMENT",
)
raise typer.Exit(code=2)

# Resolve active branch only for single-project queries; multi-project
# listing intentionally skips active-branch resolution because branches
# are per-project state. Read commands use ignore_active_branch=True:
# Storage API branch endpoint only returns locally modified tables, so
# auto-scoping to the active branch traps users into an empty listing.
effective_branch: int | None = branch
if branch is None and project and len(project) == 1:
_, effective_branch = resolve_branch(
config_store, formatter, project[0], None, ignore_active_branch=True
)

try:
result = service.list_tables(
alias=project,
aliases=project,
bucket_id=bucket_id,
branch_id=effective_branch,
)
Expand All @@ -282,27 +304,38 @@ def storage_tables(
tables = result["tables"]
if not tables:
formatter.console.print("[dim]No tables found.[/dim]")
emit_project_warnings(formatter, result)
return

table = Table(title=f"Tables - {result['project_alias']}")
table.add_column("Table ID", style="bold cyan")
table.add_column("Rows", justify="right")
table.add_column("Size", justify="right", style="dim")
table.add_column("Last Import", style="dim")

# Group by project so multi-project output stays readable.
by_project: dict[str, list[dict]] = {}
for t in tables:
size_mb = t["data_size_bytes"] / (1024 * 1024) if t["data_size_bytes"] else 0
last_import = t.get("last_import_date", "")
if last_import and "T" in last_import:
last_import = last_import.split("T")[0]
table.add_row(
t["id"],
str(t["rows_count"]),
f"{size_mb:.1f} MB",
last_import,
)
alias = t["project_alias"]
by_project.setdefault(alias, []).append(t)

formatter.console.print(table)
for alias, proj_tables in by_project.items():
table = Table(title=f"Tables - {alias}")
table.add_column("Table ID", style="bold cyan")
table.add_column("Rows", justify="right")
table.add_column("Size", justify="right", style="dim")
table.add_column("Last Import", style="dim")

for t in proj_tables:
size_mb = t["data_size_bytes"] / (1024 * 1024) if t["data_size_bytes"] else 0
last_import = t.get("last_import_date", "")
if last_import and "T" in last_import:
last_import = last_import.split("T")[0]
table.add_row(
t["id"],
str(t["rows_count"]),
f"{size_mb:.1f} MB",
last_import,
)

formatter.console.print(table)
formatter.console.print()

emit_project_warnings(formatter, result)


@storage_app.command("table-detail", rich_help_panel=_TABLES)
Expand Down
4 changes: 2 additions & 2 deletions src/keboola_agent_cli/hints/definitions/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
HintRegistry.register(
CommandHint(
cli_command="storage.tables",
description="List tables in a project",
description="List tables in one or more projects",
steps=[
HintStep(
comment="List tables",
Expand All @@ -158,7 +158,7 @@
service_module="storage_service",
method="list_tables",
args={
"alias": "{project}",
"aliases": "{project}",
"bucket_id": "{bucket_id}",
"branch_id": "{branch}",
},
Expand Down
102 changes: 72 additions & 30 deletions src/keboola_agent_cli/services/storage_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,47 +291,59 @@ def get_table_detail(

def list_tables(
self,
alias: str,
aliases: list[str] | None = None,
bucket_id: str | None = None,
branch_id: int | None = None,
) -> dict[str, Any]:
"""List tables from a project, optionally filtered by bucket.
"""List tables from one or more projects (in parallel).

When ``bucket_id`` is specified with multiple projects, the filter is
applied independently in each project -- a missing bucket in a given
project is recorded as a per-project error without aborting the others.

Args:
alias: Project alias.
bucket_id: Optional bucket ID filter.
branch_id: If set, target a specific dev branch.
aliases: Project aliases to query. If None, queries all.
bucket_id: Optional bucket ID filter applied per project.
branch_id: If set, target a specific dev branch
(only valid with a single project).

Returns:
Dict with 'tables' list.
Dict with 'tables' list (each row tagged with ``project_alias``)
and 'errors' list (per-project failures).
"""
projects = self.resolve_projects([alias])
project = projects[alias]
projects = self.resolve_projects(aliases)

client = self._client_factory(project.stack_url, project.token)
try:
raw_tables = client.list_tables(bucket_id=bucket_id, branch_id=branch_id)
finally:
client.close()
def _worker(alias: str, project: ProjectConfig) -> tuple[str, list[dict[str, Any]], bool]:
return self._fetch_tables(
alias,
project,
bucket_id=bucket_id,
branch_id=branch_id,
)

tables = [
{
"project_alias": alias,
"id": t.get("id", ""),
"name": t.get("name", ""),
"display_name": t.get("displayName", t.get("name", "")),
"bucket_id": t.get("bucket", {}).get("id", "")
if isinstance(t.get("bucket"), dict)
else "",
"rows_count": t.get("rowsCount", 0),
"data_size_bytes": t.get("dataSizeBytes", 0),
"is_alias": t.get("isAlias", False),
"last_import_date": t.get("lastImportDate", ""),
}
for t in raw_tables
]
successes, errors = self._run_parallel(projects, _worker)

tables: list[dict[str, Any]] = []
for result in successes:
alias = result[0]
for t in result[1]:
tables.append(
{
"project_alias": alias,
"id": t.get("id", ""),
"name": t.get("name", ""),
"display_name": t.get("displayName", t.get("name", "")),
"bucket_id": t.get("bucket", {}).get("id", "")
if isinstance(t.get("bucket"), dict)
else "",
"rows_count": t.get("rowsCount", 0),
"data_size_bytes": t.get("dataSizeBytes", 0),
"is_alias": t.get("isAlias", False),
"last_import_date": t.get("lastImportDate", ""),
}
)

return {"tables": tables, "project_alias": alias}
return {"tables": tables, "errors": errors}

# ------------------------------------------------------------------
# Write operations
Expand Down Expand Up @@ -1473,3 +1485,33 @@ def _fetch_buckets(
)
finally:
client.close()

def _fetch_tables(
self,
alias: str,
project: ProjectConfig,
bucket_id: str | None = None,
branch_id: int | None = None,
) -> tuple[str, list[dict[str, Any]], bool]:
"""Fetch tables for a single project (worker for _run_parallel).

Per-project failures (e.g. bucket not found in this project, invalid
token) are returned as error tuples so other projects still complete.
"""
from ..errors import KeboolaApiError

client = self._client_factory(project.stack_url, project.token)
try:
tables = client.list_tables(bucket_id=bucket_id, branch_id=branch_id)
return (alias, tables, True)
except KeboolaApiError as exc:
return (
alias,
{
"project_alias": alias,
"error_code": exc.error_code,
"message": exc.message,
},
)
finally:
client.close()
4 changes: 3 additions & 1 deletion tests/test_storage_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,7 @@ def test_cli_branch_flag(self, tmp_path: Path) -> None:
):
MockStore.return_value = store
svc = MockSvc.return_value
svc.list_tables.return_value = {"tables": [], "project_alias": "test"}
svc.list_tables.return_value = {"tables": [], "errors": []}
result = runner.invoke(
app,
[
Expand All @@ -900,6 +900,8 @@ def test_cli_branch_flag(self, tmp_path: Path) -> None:
assert result.exit_code == 0
call_kwargs = svc.list_tables.call_args.kwargs
assert call_kwargs["branch_id"] == 30
# Multi-project CLI passes --project as a list
assert call_kwargs["aliases"] == ["test"]


class TestDeleteColumnBranch:
Expand Down
Loading