From a708eb2432e4d1ece814fac90263793e356b2851 Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 14 Jun 2026 18:24:43 +0200 Subject: [PATCH 1/3] docs(lib): correct query() value-typing contract -- Snowflake returns strings (0.61.1) A live E2E round-trip against a Snowflake workspace showed the Query Service /results endpoint returns scalars as JSON strings (1 -> "1", true -> "true"; NULL -> None), not native types. The 0.61.0 Client.query() docstring and release notes wrongly claimed "native JSON types". The facade is transparent and does not coerce -- callers must cast. - lib.py: query() docstring documents the real, stable contract. - gotchas.md: add the type-serialization gotcha to the facade entry. - v0.61.0 GitHub release notes corrected (gh release edit). - Bump 0.61.1, changelog. No behavior change. --- .claude-plugin/marketplace.json | 2 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- plugins/kbagent/skills/kbagent/references/gotchas.md | 6 ++++++ pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 11 +++++++++++ src/keboola_agent_cli/lib.py | 8 ++++++-- uv.lock | 2 +- 7 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8cd1b16d..d2a4da47 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.61.0", + "version": "0.61.1", "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/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index c18ca528..b5ab10a7 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.61.0", + "version": "0.61.1", "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/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index aea3d2be..df5560c8 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -2676,3 +2676,9 @@ is a thin wrapper, not a workspace manager. Two non-obvious behaviors: on the first `query()`** -- one extra `list_dev_branches` API call, cached after. Pass `branch_id=` to skip it (and to target a dev branch). Storage Files default to the production scope when `branch_id` is unset. +- **`query()` values are warehouse-serialized strings, NOT native types.** The + Query Service `/results` endpoint returns every Snowflake scalar as a JSON + string -- `1` -> `"1"`, `1.5` -> `"1.5"`, `true` -> `"true"` -- with SQL + `NULL` as `None`. The facade is transparent and does not coerce, so callers + must cast (`int(row["x"])` etc.) for typed values. (Verified live against a + Snowflake workspace; BigQuery may differ.) diff --git a/pyproject.toml b/pyproject.toml index 7f8b44be..185601a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.61.0" +version = "0.61.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 029eedba..b8b958f8 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,17 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.61.1": [ + "Note (#416 follow-up): clarified the value-typing contract of the importable " + "`Client.query()`. The Query Service `/results` endpoint serializes Snowflake " + 'scalars as JSON *strings* (`1` -> `"1"`, `1.5` -> `"1.5"`, `true` -> ' + '`"true"`; SQL NULL -> None), and the in-process facade returns them ' + "transparently without coercion. The 0.61.0 docstring and release notes wrongly " + 'claimed "native JSON types"; the `query()` docstring and the gotchas reference ' + "now document the real, stable contract so callers know to cast. No behavior " + "change -- caught and verified by a live E2E round-trip against a Snowflake " + "workspace.", + ], "0.61.0": [ "New (#415): kbagent now ships a stateless, importable library facade -- " "`from keboola_agent_cli import Client` -- so any in-process Python consumer (a Keboola " diff --git a/src/keboola_agent_cli/lib.py b/src/keboola_agent_cli/lib.py index dad5d94c..096a60a7 100644 --- a/src/keboola_agent_cli/lib.py +++ b/src/keboola_agent_cli/lib.py @@ -248,8 +248,12 @@ def query( results inline (the fast ``/results`` path, no CSV-file materialization). Each row is a dict keyed by the result column names exactly as the warehouse reports them -- note Snowflake folds unquoted aliases to - UPPERCASE, so quote aliases if you want lowercase keys. Values arrive as - native JSON types (int/float/bool/None; VARIANT/STRUCT as dict/list). + UPPERCASE, so quote aliases if you want lowercase keys. Values are + returned exactly as the Query Service serializes them and are NOT + coerced by the facade: for Snowflake every scalar comes back as a JSON + string (``1`` -> ``"1"``, ``1.5`` -> ``"1.5"``, ``true`` -> ``"true"``), + with SQL ``NULL`` as ``None``. Cast on the caller side if you need typed + values. When ``sql`` contains multiple statements, the rows of the *last* statement that produced a result set are returned (so ``USE ...; SELECT diff --git a/uv.lock b/uv.lock index 517474cc..4417273c 100644 --- a/uv.lock +++ b/uv.lock @@ -580,7 +580,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.61.0" +version = "0.61.1" source = { editable = "." } dependencies = [ { name = "croniter" }, From 4ec8b425abe37abf1378632c6f80a5e3e0b3a297 Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 14 Jun 2026 18:28:25 +0200 Subject: [PATCH 2/3] docs(gotchas): "Two" -> "Three" non-obvious behaviors (PR #418 review) --- plugins/kbagent/skills/kbagent/references/gotchas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index df5560c8..18271b17 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -2667,7 +2667,7 @@ JSON. That does NOT mean Keboola has no metadata -- always re-fetch via ### `Client` library: `query()` needs a provisioned workspace; `branch_id=None` costs a branch-list call (since v0.61.0) The in-process library facade (`from keboola_agent_cli import Client`, 0.61.0+) -is a thin wrapper, not a workspace manager. Two non-obvious behaviors: +is a thin wrapper, not a workspace manager. Three non-obvious behaviors: - **`query(workspace_id, sql)` does NOT create a workspace.** The `workspace_id` must already exist (make one via `kbagent workspace create` or the Storage From e0b7899ce949bb1627c4d74eaa2dddb004ad4a4c Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 14 Jun 2026 18:38:20 +0200 Subject: [PATCH 3/3] docs: address PR #418 review -- string-typed test mocks + changelog/gotchas Follow-ups from the kbagent-pr-reviewer pass on PR #418: - B-1: tests/test_lib.py mock data + assertions now use the real string contract ("1" not 1), so the tests no longer teach "the library gives ints". - NB-1: the 0.61.0 changelog entry no longer actively claims "native JSON types" (corrected in-place; 0.61.1 cites it as the prior wrong claim). - NB-2: the new gotchas bullet carries the (updated v0.61.1 -- closes #416) tag. - NIT-1: the gotchas hedge now says "BigQuery behavior not yet verified". --- plugins/kbagent/skills/kbagent/references/gotchas.md | 12 ++++++------ src/keboola_agent_cli/changelog.py | 5 +++-- tests/test_lib.py | 12 ++++++++---- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 18271b17..90280577 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -2676,9 +2676,9 @@ is a thin wrapper, not a workspace manager. Three non-obvious behaviors: on the first `query()`** -- one extra `list_dev_branches` API call, cached after. Pass `branch_id=` to skip it (and to target a dev branch). Storage Files default to the production scope when `branch_id` is unset. -- **`query()` values are warehouse-serialized strings, NOT native types.** The - Query Service `/results` endpoint returns every Snowflake scalar as a JSON - string -- `1` -> `"1"`, `1.5` -> `"1.5"`, `true` -> `"true"` -- with SQL - `NULL` as `None`. The facade is transparent and does not coerce, so callers - must cast (`int(row["x"])` etc.) for typed values. (Verified live against a - Snowflake workspace; BigQuery may differ.) +- **`query()` values are warehouse-serialized strings, NOT native types. (updated + v0.61.1 -- closes #416)** The Query Service `/results` endpoint returns every + Snowflake scalar as a JSON string -- `1` -> `"1"`, `1.5` -> `"1.5"`, `true` -> + `"true"` -- with SQL `NULL` as `None`. The facade is transparent and does not + coerce, so callers must cast (`int(row["x"])` etc.) for typed values. (Verified + live against a Snowflake workspace; BigQuery behavior not yet verified.) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index b8b958f8..1da88a1d 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -41,8 +41,9 @@ "Data App, a transformation, a hosted service) can run Query Service SQL and read/write " "Storage Files without a CLI subprocess, a `kbagent serve` daemon, or a config-dir. " "`Client(url, token)` wraps the existing `KeboolaClient`; `client.query(workspace_id, sql)` " - "returns `list[dict]` rows over the fast inline `/results` path (native JSON types; " - "truncation is warned, never silently capped), and `client.files` offers " + "returns `list[dict]` rows over the fast inline `/results` path (corrected in 0.61.1: " + "values are warehouse-serialized strings, not native types; truncation is warned, " + "never silently capped), and `client.files` offers " "`upload(path_or_bytes)`, `read_bytes(file_id) -> bytes`, `list() -> list[FileEntry]` " "(one uniform shape, read via `read_bytes` so callers never branch on a signed URL) and " "`delete()`. The Query Service pagination helper moved from the workspace service into " diff --git a/tests/test_lib.py b/tests/test_lib.py index eb59b0b6..f149a07e 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -68,14 +68,18 @@ def _wire_single_select(mock_kc: MagicMock, *, num_rows: int = 2) -> None: } mock_kc.get_query_results.return_value = { "columns": [{"name": "id"}, {"name": "name"}], - "data": [[1, "alice"], [2, "bob"]], + # The Query Service /results endpoint returns Snowflake scalars as + # JSON strings (see Client.query docstring); the facade is + # transparent, so the mock reflects the real string contract, not + # coerced ints. + "data": [["1", "alice"], ["2", "bob"]], "numberOfRows": num_rows, } def test_maps_columns_and_rows_to_dicts(self, client: Client, mock_kc: MagicMock) -> None: self._wire_single_select(mock_kc) rows = client.query(456, "SELECT id, name FROM t") - assert rows == [{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}] + assert rows == [{"id": "1", "name": "alice"}, {"id": "2", "name": "bob"}] def test_submits_with_resolved_default_branch(self, client: Client, mock_kc: MagicMock) -> None: self._wire_single_select(mock_kc) @@ -120,11 +124,11 @@ def test_multi_statement_returns_last_result_set( } mock_kc.get_query_results.return_value = { "columns": [{"name": "n"}], - "data": [[7]], + "data": [["7"]], # warehouse-serialized string, per the documented contract "numberOfRows": 1, } rows = client.query(1, "USE WAREHOUSE x; SELECT 7 AS n") - assert rows == [{"n": 7}] + assert rows == [{"n": "7"}] # Only the result-producing statement triggers a results fetch. mock_kc.get_query_results.assert_called_once() assert mock_kc.get_query_results.call_args.args[1] == "s2"