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-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.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",
Expand Down
8 changes: 7 additions & 1 deletion plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. (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.)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-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"
Expand Down
16 changes: 14 additions & 2 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,26 @@

# 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 "
"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 "
Expand Down
8 changes: 6 additions & 2 deletions src/keboola_agent_cli/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions tests/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

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

Loading