From dd379950aa7845d531878be0f692345988911899 Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 2 Aug 2026 10:23:59 +0200 Subject: [PATCH] chore(release): renumber the unreleased 0.77.1 to 0.78.0 and log #546 v0.77.1 was merged but never tagged, and the next release will be 0.78.0 bundling several PRs -- so the changelog key and pyproject version are renumbered rather than leaving an entry for a version that will not exist. More importantly, #549 landed with no changelog entry at all, matching how #530/#531 landed before their release PR. That is the failure mode that left 0.67.0-0.70.1 unpublished and undocumented, so the #546 fix is written up now rather than at tag time. Also clears the two `redundant-cast` warnings my #549 tests introduced. Deleting the casts turned them into `unresolved-attribute` ERRORS -- `TextIOWrapper.buffer` is declared as `_WrappedBuffer`, which has no `getvalue` -- so the fix is to stop reaching through `.buffer` at all: a small frozen `Cp1250Stdout` holds the `BytesIO` directly. That also gives the streaming tests in the other class access to the same helper, which they did not have. --- .claude-plugin/marketplace.json | 2 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- .../skills/kbagent/references/gotchas.md | 4 +- pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 10 +++- tests/test_output.py | 53 ++++++++++++------- uv.lock | 2 +- 7 files changed, 48 insertions(+), 27 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index df5119fd..98a8eaee 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.77.1", + "version": "0.78.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/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 64982931..484be301 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.77.1", + "version": "0.78.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/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index dd3057c6..aaa27c72 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -2138,7 +2138,7 @@ transparent -- no user action is normally required. - Never crashes the CLI -- update failures leave the current invocation running and print a recovery command (since v0.76.2) -### Windows updates are deferred, not immediate (since v0.77.1) +### Windows updates are deferred, not immediate (since v0.78.0) `uv tool install` recreates a tool environment by **removing** it and then building a fresh venv at the same path. It is not atomic and has no rollback. @@ -2164,7 +2164,7 @@ So on Windows kbagent never installs into its own live environment: - `KBAGENT_DEFER_UPDATE=1` / `=0` forces the deferred path on or off, overriding the platform default. -A slow install is never killed on any platform (also since v0.77.1): the +A slow install is never killed on any platform (also since v0.78.0): the timeout bounds only how long kbagent waits, because terminating uv mid-write produces the same half-deleted environment a file lock does. When that happens the banner says the install is *still running* and deliberately offers no diff --git a/pyproject.toml b/pyproject.toml index 5dea558e..22276dd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.77.1" +version = "0.78.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 09925e98..eb0e0856 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,7 +24,15 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { - "0.77.1": [ + "0.78.0": [ + "Fix (#546): `kbagent --json` no longer crashes with `UnicodeEncodeError` on Windows " + "consoles using a non-UTF-8 codepage (cp1250 on Czech/Polish/Hungarian Windows). Any " + "non-ASCII character in the data -- an arrow in a flow name was the report -- made " + "machine-readable output unusable, because pydantic's `model_dump_json` emits raw UTF-8 " + "and `sys.stdout` then encoded it through the console codepage. All machine output, " + "including the `--stream` NDJSON from `kbagent agent run`, is now written as UTF-8 bytes " + "independent of the console. The `PYTHONUTF8=1` workaround is no longer needed. JSON " + "lines now end LF rather than CRLF on Windows. Thanks to @MichalProchazka for the report.", "Fix (#528): the Windows self-update no longer corrupts the uv tool environment. " "`uv tool install` recreates a tool environment by REMOVING it and then building a fresh " "venv at the same path -- it is not atomic and has no rollback. On POSIX that is harmless, " diff --git a/tests/test_output.py b/tests/test_output.py index 9b8102ad..4fff24f4 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -4,6 +4,7 @@ import json import sys from collections.abc import Callable +from dataclasses import dataclass from io import StringIO from typing import cast @@ -1080,6 +1081,23 @@ def test_falls_back_to_csv_preview_for_full_export(self) -> None: assert "alice" in output +@dataclass(frozen=True) +class Cp1250Stdout: + """A stdout whose text layer cannot encode non-ASCII, like a cp1250 console. + + `raw` is held directly rather than reached through `text.buffer`, whose + declared type (`_WrappedBuffer`) has no `getvalue`. + """ + + text: io.TextIOWrapper + raw: io.BytesIO + + @classmethod + def create(cls) -> "Cp1250Stdout": + raw = io.BytesIO() + return cls(io.TextIOWrapper(raw, encoding="cp1250", newline=""), raw) + + class TestMachineOutputIsAlwaysUtf8: """`--json` must not depend on the console codepage (issue #546). @@ -1097,17 +1115,12 @@ class TestMachineOutputIsAlwaysUtf8: ARROW_NAME = "extract → transform" - @staticmethod - def _cp1250_stdout() -> io.TextIOWrapper: - """A stdout whose text layer cannot encode the payload, like cp1250.""" - return io.TextIOWrapper(io.BytesIO(), encoding="cp1250", newline="") - def _capture(self, monkeypatch: pytest.MonkeyPatch, action: Callable[[], None]) -> bytes: - stream = self._cp1250_stdout() - monkeypatch.setattr(sys, "stdout", stream) + stdout = Cp1250Stdout.create() + monkeypatch.setattr(sys, "stdout", stdout.text) action() - stream.flush() - return cast(io.BytesIO, stream.buffer).getvalue() + stdout.text.flush() + return stdout.raw.getvalue() def test_output_survives_a_codepage_that_cannot_encode_the_data( self, monkeypatch: pytest.MonkeyPatch @@ -1155,14 +1168,14 @@ def test_human_mode_output_written_earlier_keeps_its_place( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Flushing the text layer first stops the two writers reordering.""" - stream = self._cp1250_stdout() - monkeypatch.setattr(sys, "stdout", stream) + stdout = Cp1250Stdout.create() + monkeypatch.setattr(sys, "stdout", stdout.text) sys.stdout.write("first\n") write_machine_output("second") - stream.flush() + stdout.text.flush() - assert cast(io.BytesIO, stream.buffer).getvalue() == b"first\nsecond\n" + assert stdout.raw.getvalue() == b"first\nsecond\n" class TestStreamedAgentEventsAreUtf8: @@ -1180,14 +1193,14 @@ def test_event_with_non_ascii_does_not_crash_on_a_cp1250_console( ) -> None: from keboola_agent_cli.commands.agent import _render_stream_event - stream = io.TextIOWrapper(io.BytesIO(), encoding="cp1250", newline="") - monkeypatch.setattr(sys, "stdout", stream) + stdout = Cp1250Stdout.create() + monkeypatch.setattr(sys, "stdout", stdout.text) formatter = OutputFormatter(json_mode=True) _render_stream_event(formatter, {"event": "log", "data": {"msg": "načítám → hotovo"}}) - stream.flush() + stdout.text.flush() - written = cast(io.BytesIO, stream.buffer).getvalue() + written = stdout.raw.getvalue() assert json.loads(written.decode("utf-8"))["data"]["msg"] == "načítám → hotovo" def test_each_event_is_flushed_so_consumers_see_it_immediately( @@ -1196,11 +1209,11 @@ def test_each_event_is_flushed_so_consumers_see_it_immediately( """A stream consumer reads line by line; buffering would stall it.""" from keboola_agent_cli.commands.agent import _render_stream_event - stream = io.TextIOWrapper(io.BytesIO(), encoding="cp1250", newline="") - monkeypatch.setattr(sys, "stdout", stream) + stdout = Cp1250Stdout.create() + monkeypatch.setattr(sys, "stdout", stdout.text) formatter = OutputFormatter(json_mode=True) _render_stream_event(formatter, {"event": "init", "data": {}}) # Readable without an explicit flush by the test. - assert cast(io.BytesIO, stream.buffer).getvalue().endswith(b"\n") + assert stdout.raw.getvalue().endswith(b"\n") diff --git a/uv.lock b/uv.lock index da3555e0..b3578065 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.77.1" +version = "0.78.0" source = { editable = "." } dependencies = [ { name = "croniter" },