diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 01aba376..f34e2f21 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.87.0", + "version": "0.87.1", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, sync configs as files, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index 07cfb21c..29963d30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -433,6 +433,11 @@ 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 table-detail --project NAME --table-id ID [--branch ID] +# table-detail (0.87.1+, #621): also returns `definition` -- the typed-table layout (primaryKeysNames +# plus, on BigQuery, timePartitioning/rangePartitioning/clustering; null when untyped). The read half +# of the repartition flow: after `create-table --source-table-id ...` + `swap-tables` the table id is +# the same either way, so only the layout says whether the swap took. `storage tables` (LIST) does not +# return it. Human mode prints Partitioning/Clustering rows only when a layout is present. 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[(length)] ...] [--primary-key COL] [--not-null COL ...] [--default NAME=VALUE ...] [--source-table-id ID] [--source-branch-id N] [--time-partitioning-type DAY|HOUR|MONTH|YEAR] [--time-partitioning-field COL] [--time-partitioning-expiration-ms MS] [--range-partitioning-field COL --range-partitioning-start S --range-partitioning-end E --range-partitioning-interval I] [--clustering-field COL ...] [--branch ID] [--if-not-exists] # --column XOR --source-table-id (0.66.0+, BigQuery only): --source-table-id copies an existing table's data into the requested partition/clustering layout (schema derived from source) -> swap into place with swap-tables. Partition/clustering flags work in both modes (BigQuery only); time vs range partitioning are mutually exclusive. A non-BigQuery project fails fast (pre-flight backend check). diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index b4fd0bf2..88f3f52f 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.87.0", + "version": "0.87.1", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, sync configs as files, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index aa9a5e70..e75c6993 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -159,7 +159,7 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def - `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 backend-native direct-access paths (branch-aware). Output adapts to backend: Snowflake -> `snowflake_database` / `snowflake_schema` / per-table `snowflake_path` quoted with `"..."`. BigQuery -> `bigquery_dataset` (and `bigquery_project` when surfaced via API `databaseName`) / per-table `bigquery_path` quoted with backticks. Always-present backend-agnostic keys: `sql_dialect` (`"snowflake"` / `"bigquery"`) and per-table `sql_path` -- prefer these in agent code instead of branching on backend yourself - `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 table-detail --project NAME --table-id ID [--branch ID]` -- table detail with columns, types, primary key, row count (branch-aware). Also carries `definition` (since v0.87.1) -- the typed-table layout: `primaryKeysNames` plus, on BigQuery, `timePartitioning` / `rangePartitioning` / `clustering`; `null` on an untyped table. This is the read half of the repartition flow (`create-table --source-table-id --time-partitioning-field ...` -> `swap-tables`): the table id is identical whether the swap happened or not, so the layout is the only field that distinguishes them. Human mode adds `Partitioning:` / `Clustering:` rows only when a layout exists. `storage tables` (the LIST endpoint) genuinely does not return it - `storage create-bucket --project NAME --stage STAGE --name NAME [--description D] [--backend B] [--branch ID]` -- create bucket (branch-aware). With `--branch ID` on a project lacking the `storage-branches` feature (legacy fake-branch), response carries `legacy_branch_storage: true` and human mode prints a warning -- the runner will create a parallel `out.c--*` bucket at job time. See `storage-types-workflow.md` - `storage create-table --project NAME --bucket-id ID --name NAME [--column col:TYPE[(length)] ...] [--primary-key COL] [--not-null COL ...] [--default NAME=VALUE ...] [--source-table-id ID] [--source-branch-id N] [--time-partitioning-type DAY|HOUR|MONTH|YEAR] [--time-partitioning-field COL] [--time-partitioning-expiration-ms MS] [--range-partitioning-field COL --range-partitioning-start S --range-partitioning-end E --range-partitioning-interval I] [--clustering-field COL ...] [--branch ID] [--if-not-exists]` -- create typed table. Base types `STRING/INTEGER/NUMERIC/FLOAT/BOOLEAN/DATE/TIMESTAMP` plus native backend types with length (`VARCHAR(40)`, `NUMBER(18,2)`, `TIMESTAMP_TZ`, `VARIANT`, etc.) -- type/length validation delegated to the Storage API. `--not-null` marks a column `nullable=false`; `--default NAME=VALUE` sets a DEFAULT expression (booleans must be lowercase `true`/`false`). In a dev branch, the target bucket is auto-materialized if it has not yet been written to there -- response surfaces this via `auto_created_bucket: bool`. On legacy fake-branch projects (no `storage-branches` feature), `legacy_branch_storage: true` flags that the runner will use a separate `out.c--*` bucket at job time. `--if-not-exists` (0.47.0+) turns a duplicate-display-name failure into `action: skipped` when the table really exists at the expected id (safe for parallel workers). Since 0.47.1 the skipped envelope reports the EXISTING table's actual `columns`/`primary_key`/`name`, mirrors the request under `requested_columns`/`requested_primary_key`, and sets `schema_drift: true` when they diverge. **`--source-table-id` (0.66.0+, BigQuery only)** copies an existing table's data into the requested partition/clustering layout instead of building from `--column` (schema derived from source -> `--column`/`--not-null`/`--default` forbidden; the two are mutually exclusive). This is the supported way to repartition a populated BigQuery table -- then promote it with `storage swap-tables`. Partition/clustering flags (`--time-partitioning-*`, `--range-partitioning-*`, `--clustering-field`) also work on a plain `--column` create (BigQuery only); time vs range partitioning are mutually exclusive and range bounds are strings. When any source/partition/clustering flag is used, a one-call backend pre-flight rejects non-BigQuery projects (exit 2) before the create. See `storage-types-workflow.md` - `storage upload-table --project NAME --table-id ID --file PATH [--incremental] [--branch ID]` -- upload CSV (branch-aware) diff --git a/pyproject.toml b/pyproject.toml index 4daf1f29..b79c3f72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.87.0" +version = "0.87.1" 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 45744bef..203e267d 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,26 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.87.1": [ + "New (#621): `storage table-detail` now returns the table's `definition`, so a " + "BigQuery repartition can finally be verified from kbagent. The field carries " + "`primaryKeysNames` plus, on BigQuery, `timePartitioning`, `rangePartitioning` and " + "`clustering`; `null` for an untyped table. kbagent could " + "already WRITE that layout (`storage create-table --source-table-id " + "--time-partitioning-field --clustering-field`, promoted with `storage swap-tables`) " + "but had no way to read it back: the service built its response from a field allowlist " + "that dropped `definition`, so the write half of the repartition flow was supported and " + "the verify half was not. It matters because the table ID is unchanged whether the swap " + "happened or not -- the layout is the only field that tells a completed repartition " + "from a failed one, and on a Keboola-managed BigQuery project where you hold no " + "`bigquery.jobs.create`, the Storage `definition` is the only view of the registered " + "layout you can reach at all. Human mode adds `Partitioning:` / `Clustering:` rows " + "between the primary key and last-import lines, and only when a layout exists, so " + "Snowflake and untyped-table output is byte-identical. Additive in `--json` too. The " + "`GET /storage/table-detail/...` route picks it up for free. `storage tables` is " + "unchanged: the LIST endpoint genuinely does not return `definition`, and fetching it " + "per table would be a different change with a different cost profile.", + ], "0.87.0": [ "New (#626): `data-app create` gains `--workspace / --no-workspace` and grants " "Storage access BY DEFAULT. The flag writes `runtime.workspace.enabled: true`, which " diff --git a/src/keboola_agent_cli/commands/_storage_format.py b/src/keboola_agent_cli/commands/_storage_format.py new file mode 100644 index 00000000..c1981fbe --- /dev/null +++ b/src/keboola_agent_cli/commands/_storage_format.py @@ -0,0 +1,85 @@ +"""Human-mode rendering helpers for the ``kbagent storage`` group. + +Lives in a private module because ``commands/storage.py`` is already past the +1,200-LOC commands-file ceiling (CONTRIBUTING.md > "File-size budgets"), so +rendering that grows -- such as the typed-table layout added for #621 -- moves +here rather than extending it further. +""" + +from __future__ import annotations + +from typing import Any + +from rich.markup import escape +from rich.table import Table + +from ..output import OutputFormatter + + +def _format_table_layout(definition: Any) -> list[tuple[str, str]]: + """Render a typed table's partition/cluster layout as label/value rows. + + ``definition`` is the Storage API's typed-table descriptor: ``None`` for an + untyped table, and on BigQuery it carries ``timePartitioning``, + ``rangePartitioning`` and ``clustering``. Returns an empty list whenever + there is no layout to show, so Snowflake and untyped-table output stays + byte-identical. + """ + if not isinstance(definition, dict): + return [] + rows: list[tuple[str, str]] = [] + time_part = definition.get("timePartitioning") or {} + if isinstance(time_part, dict) and time_part.get("type"): + # `field` is absent for ingestion-time partitioning, where BigQuery + # partitions on the load timestamp rather than on a column. + field = time_part.get("field") + rows.append( + ("Partitioning", f"{time_part['type']} on {field}" if field else time_part["type"]) + ) + range_part = definition.get("rangePartitioning") or {} + if isinstance(range_part, dict) and range_part.get("field"): + rng = range_part.get("range") or {} + bounds = ( + f" [{rng.get('start')}, {rng.get('end')}) step {rng.get('interval')}" + if isinstance(rng, dict) and rng + else "" + ) + rows.append(("Range partitioning", f"{range_part['field']}{bounds}")) + clustering = definition.get("clustering") or {} + if isinstance(clustering, dict) and clustering.get("fields"): + rows.append(("Clustering", ", ".join(str(f) for f in clustering["fields"]))) + return rows + + +def render_table_detail(formatter: OutputFormatter, result: dict[str, Any]) -> None: + """Print the human-mode ``storage table-detail`` report. + + Layout rows appear only for a typed table that actually declares one, so + Snowflake and untyped-table output is unchanged. + """ + formatter.console.print(f"[bold]Table:[/bold] {result['table_id']}") + formatter.console.print(f" Name: {escape(result['display_name'] or result['name'])}") + formatter.console.print(f" Bucket: {result['bucket_id']}") + formatter.console.print(f" Rows: {result['rows_count']:,}") + size_mb = result["data_size_bytes"] / (1024 * 1024) + formatter.console.print(f" Size: {size_mb:.2f} MB") + if result["primary_key"]: + formatter.console.print(f" Primary key: {', '.join(result['primary_key'])}") + for label, value in _format_table_layout(result.get("definition")): + formatter.console.print(f" {label}: {value}") + if result["last_import_date"]: + formatter.console.print(f" Last import: {result['last_import_date']}") + + if result["column_details"]: + formatter.console.print() + table = Table(title="Columns") + table.add_column("Name", style="bold cyan") + table.add_column("Type", style="dim") + table.add_column("Nullable", style="dim") + for col in result["column_details"]: + table.add_row( + col["name"], + col.get("type", ""), + "yes" if col.get("nullable") else "", + ) + formatter.console.print(table) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 3138a3db..0432536b 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -592,6 +592,12 @@ kbagent storage table-detail --project NAME --table-id TABLE_ID [--branch ID] Show detailed table info: columns (with types if available), primary key, row count, size, last import date. + Also returns `definition` -- the typed-table layout (primaryKeysNames plus, on BigQuery, + timePartitioning / rangePartitioning / clustering), null for an untyped table. This is how you + VERIFY a `create-table` + `swap-tables` repartition actually took effect: the table id is + unchanged either way, so only the layout tells the two apart. Human mode prints Partitioning / + Clustering rows when present. The LIST endpoint does not return it, so `storage tables` has no + equivalent field. Uses production by default; pass --branch to query a dev branch explicitly. kbagent storage create-bucket --project NAME --stage STAGE --name BUCKET_NAME [--description D] [--backend B] [--branch ID] diff --git a/src/keboola_agent_cli/commands/storage.py b/src/keboola_agent_cli/commands/storage.py index cf08fcdb..55ebc04c 100644 --- a/src/keboola_agent_cli/commands/storage.py +++ b/src/keboola_agent_cli/commands/storage.py @@ -20,6 +20,7 @@ map_error_to_exit_code, resolve_branch, ) +from ._storage_format import render_table_detail storage_app = typer.Typer(help="Browse and manage storage buckets, tables, and files") @@ -408,34 +409,7 @@ def storage_table_detail( if formatter.json_mode: formatter.output(result) else: - formatter.console.print(f"[bold]Table:[/bold] {result['table_id']}") - formatter.console.print(f" Name: {escape(result['display_name'] or result['name'])}") - formatter.console.print(f" Bucket: {result['bucket_id']}") - formatter.console.print(f" Rows: {result['rows_count']:,}") - size_mb = result["data_size_bytes"] / (1024 * 1024) - formatter.console.print(f" Size: {size_mb:.2f} MB") - if result["primary_key"]: - formatter.console.print(f" Primary key: {', '.join(result['primary_key'])}") - if result["last_import_date"]: - formatter.console.print(f" Last import: {result['last_import_date']}") - - if result["column_details"]: - formatter.console.print() - from rich.table import Table - - table = Table(title="Columns") - table.add_column("Name", style="bold cyan") - table.add_column("Type", style="dim") - table.add_column("Nullable", style="dim") - - for col in result["column_details"]: - table.add_row( - col["name"], - col.get("type", ""), - "yes" if col.get("nullable") else "", - ) - - formatter.console.print(table) + render_table_detail(formatter, result) @storage_app.command("create-bucket", rich_help_panel=_BUCKETS) diff --git a/src/keboola_agent_cli/services/storage_service.py b/src/keboola_agent_cli/services/storage_service.py index ebcc2977..6b455ec4 100644 --- a/src/keboola_agent_cli/services/storage_service.py +++ b/src/keboola_agent_cli/services/storage_service.py @@ -668,8 +668,7 @@ def get_table_detail( column_details = [] for col in columns: col_info: dict[str, Any] = {"name": col} - meta = column_metadata.get(col, []) - for m in meta: + for m in column_metadata.get(col, []): key = m.get("key", "") value = m.get("value", "") if key == "KBC.datatype.basetype": @@ -708,6 +707,14 @@ def get_table_detail( "rows_count": table.get("rowsCount") or 0, "data_size_bytes": table.get("dataSizeBytes") or 0, "is_alias": table.get("isAlias", False), + # Typed-table layout: primaryKeysNames plus, on BigQuery, + # timePartitioning / rangePartitioning / clustering. None for an + # untyped table. This is the only view of the registered layout + # reachable without BigQuery metadata access, and the only field + # that tells a completed `swap-tables` repartition from a failed + # one -- the table id is unchanged either way (#621). The LIST + # endpoint does not return it, so this stays detail-only. + "definition": table.get("definition"), "last_import_date": table.get("lastImportDate", ""), "last_change_date": table.get("lastChangeDate", ""), "created": table.get("created", ""), diff --git a/tests/test_storage_table_definition.py b/tests/test_storage_table_definition.py new file mode 100644 index 00000000..6f5dc225 --- /dev/null +++ b/tests/test_storage_table_definition.py @@ -0,0 +1,192 @@ +"""``storage table-detail`` surfaces a typed table's ``definition`` (issue #621). + +``storage create-table`` can apply BigQuery ``timePartitioning`` / +``rangePartitioning`` / ``clustering`` and ``storage swap-tables`` promotes the +result into place, but nothing could read that layout back: the service built +its response from an explicit field allowlist that dropped ``definition``. + +That made the repartition flow unverifiable from kbagent -- the table id is +unchanged whether the swap happened or not, so the layout is the only field +that tells the two apart, and on a Keboola-managed BigQuery project it may be +the only view of the registered layout reachable at all. +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.commands._storage_format import _format_table_layout +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import AppConfig, ProjectConfig +from keboola_agent_cli.services.storage_service import StorageService + +TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +TABLE_ID = "out.c-my-bucket.my-table" + +PARTITIONED_DEFINITION = { + "primaryKeysNames": ["id"], + "timePartitioning": {"type": "DAY", "field": "created_at"}, + "clustering": {"fields": ["tenant_id", "country"]}, +} + + +def _make_store(tmp_path: Path) -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir(exist_ok=True) + store = ConfigStore(config_dir=config_dir) + store.save( + AppConfig( + projects={ + "test": ProjectConfig( + stack_url="https://connection.europe-west3.gcp.keboola.com", + token=TEST_TOKEN, + ) + }, + default_project="test", + ) + ) + return store + + +def _table_resource(definition: object | None) -> dict: + resource: dict = { + "id": TABLE_ID, + "name": "my-table", + "displayName": "my-table", + "bucket": {"id": "out.c-my-bucket", "backend": "bigquery"}, + "columns": ["id", "created_at"], + "primaryKey": ["id"], + "rowsCount": 6290737, + "columnMetadata": {}, + "metadata": [], + } + if definition is not None: + resource["definition"] = definition + return resource + + +class TestServiceSurfacesDefinition: + """StorageService.get_table_detail passes ``definition`` through verbatim.""" + + def test_typed_table_definition_passed_through(self, tmp_path: Path) -> None: + mock_client = MagicMock() + mock_client.get_table_detail.return_value = _table_resource(PARTITIONED_DEFINITION) + service = StorageService( + config_store=_make_store(tmp_path), client_factory=lambda _u, _t: mock_client + ) + + result = service.get_table_detail("test", TABLE_ID) + + assert result["definition"] == PARTITIONED_DEFINITION + + def test_untyped_table_definition_is_none(self, tmp_path: Path) -> None: + """Absent on an untyped table -- ``None`` needs no special-casing.""" + mock_client = MagicMock() + mock_client.get_table_detail.return_value = _table_resource(None) + service = StorageService( + config_store=_make_store(tmp_path), client_factory=lambda _u, _t: mock_client + ) + + result = service.get_table_detail("test", TABLE_ID) + + assert result["definition"] is None + + +class TestFormatTableLayout: + """The human-mode renderer only emits rows for a layout that exists.""" + + def test_no_layout_renders_nothing(self) -> None: + assert _format_table_layout(None) == [] + assert _format_table_layout({}) == [] + assert _format_table_layout({"primaryKeysNames": ["id"]}) == [] + + def test_non_dict_definition_renders_nothing(self) -> None: + """A Storage API deploy once served ``definition`` as ``[]``. + + See tests/test_storage_empty_definition.py -- the renderer must not + reintroduce that crash class. + """ + assert _format_table_layout([]) == [] + assert _format_table_layout("nonsense") == [] + + def test_time_partitioning_and_clustering(self) -> None: + assert _format_table_layout(PARTITIONED_DEFINITION) == [ + ("Partitioning", "DAY on created_at"), + ("Clustering", "tenant_id, country"), + ] + + def test_ingestion_time_partitioning_has_no_field(self) -> None: + """BigQuery partitions on load time when ``field`` is absent.""" + assert _format_table_layout({"timePartitioning": {"type": "HOUR"}}) == [ + ("Partitioning", "HOUR") + ] + + def test_range_partitioning_with_bounds(self) -> None: + definition = { + "rangePartitioning": { + "field": "customer_id", + "range": {"start": "0", "end": "1000", "interval": "10"}, + } + } + assert _format_table_layout(definition) == [ + ("Range partitioning", "customer_id [0, 1000) step 10") + ] + + def test_range_partitioning_without_bounds(self) -> None: + definition = {"rangePartitioning": {"field": "customer_id"}} + assert _format_table_layout(definition) == [("Range partitioning", "customer_id")] + + +class TestTableDetailCli: + """End-to-end through the CLI, both output modes.""" + + def _invoke(self, tmp_path: Path, definition: object, json_mode: bool) -> str: + runner = CliRunner() + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = _make_store(tmp_path) + MockSvc.return_value.get_table_detail.return_value = { + "project_alias": "test", + "table_id": TABLE_ID, + "name": "my-table", + "display_name": "my-table", + "bucket_id": "out.c-my-bucket", + "rows_count": 6290737, + "data_size_bytes": 1024, + "primary_key": ["id"], + "column_details": [], + "last_import_date": "2026-08-19T17:26:18+0200", + "definition": definition, + } + argv = ["storage", "table-detail", "--project", "test", "--table-id", TABLE_ID] + result = runner.invoke(app, (["--json"] if json_mode else []) + argv) + assert result.exit_code == 0, result.output + return result.output + + def test_human_output_shows_layout(self, tmp_path: Path) -> None: + output = self._invoke(tmp_path, PARTITIONED_DEFINITION, json_mode=False) + + assert "Partitioning: DAY on created_at" in output + assert "Clustering: tenant_id, country" in output + # Ordered between the primary key and the last-import line. + assert output.index("Primary key") < output.index("Partitioning") + assert output.index("Clustering") < output.index("Last import") + + def test_human_output_unchanged_without_layout(self, tmp_path: Path) -> None: + """Snowflake / untyped tables print exactly what they printed before.""" + output = self._invoke(tmp_path, None, json_mode=False) + + assert "Partitioning" not in output + assert "Clustering" not in output + assert "Primary key: id" in output + + def test_json_output_carries_definition(self, tmp_path: Path) -> None: + import json + + payload = json.loads(self._invoke(tmp_path, PARTITIONED_DEFINITION, json_mode=True)) + + assert payload["data"]["definition"] == PARTITIONED_DEFINITION diff --git a/uv.lock b/uv.lock index 2d96ab21..ee16bd2d 100644 --- a/uv.lock +++ b/uv.lock @@ -581,7 +581,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.87.0" +version = "0.87.1" source = { editable = "." } dependencies = [ { name = "croniter" },