From 15484dfe39d6745a49ca089cb4c50ed7c30f1545 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:37:26 +0100 Subject: [PATCH 1/4] fix(core): keep diagnostics off stdout on the failure path Closes #104. Four deepctl_core modules declared bare `Console()` instances bound to stdout and printed diagnostics through them, so `dg -o json ` on an auth failure wrote 132 bytes of English prose to stdout and left stderr empty -- a JSONDecodeError for the CI step that redirects stdout and parses it, on the single most common way a CI step fails. get_status_console() warns about exactly this pattern in its own docstring. auth, client, timing and base_group_command carry diagnostics only, so their console is now the shared stderr_console. base_command's is deliberately NOT moved: it writes the payload (tables, JSON, raw text from _output_*), which belongs on stdout. It moves from a bare Console() to the shared stdout instance so it stops silently missing the agentic no-color/highlight config -- the second half of the docstring's warning. Moving the prose off stdout left stdout *empty* on failure, which is still unparseable. The guard's `except` now emits an ErrorResult through the normal output path, so stdout carries {"status": "error", "error": ...}. It is a no-op in default mode, so humans see no duplicate of the stderr diagnosis. Verified against the issue's five commands with an invalid key: each now exits 1 with parseable JSON on stdout and the prose on stderr. Tests pin all three properties: which shared console each module holds, that the JSON failure payload parses while default mode stays silent on stdout, and an AST sweep asserting no module in deepctl_core declares a bare Console() at all -- confirmed to fail when one is reintroduced. --- .../deepctl-core/src/deepctl_core/auth.py | 7 +- .../src/deepctl_core/base_command.py | 29 +++- .../src/deepctl_core/base_group_command.py | 7 +- .../deepctl-core/src/deepctl_core/client.py | 8 +- .../deepctl-core/src/deepctl_core/timing.py | 7 +- .../tests/unit/test_output_channels.py | 148 ++++++++++++++++++ 6 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 packages/deepctl-core/tests/unit/test_output_channels.py diff --git a/packages/deepctl-core/src/deepctl_core/auth.py b/packages/deepctl-core/src/deepctl_core/auth.py index f15c04a..96f15d7 100644 --- a/packages/deepctl-core/src/deepctl_core/auth.py +++ b/packages/deepctl-core/src/deepctl_core/auth.py @@ -9,14 +9,17 @@ import httpx import keyring from pydantic import BaseModel -from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn from .client import _split_base_url from .config import Config from .models import ProfileInfo, ProfilesResult +from .output import stderr_console -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console # Auth provider base URL (dx-id OIDC provider) AUTH_BASE_URL = os.getenv("DEEPGRAM_CLI_BASE_URL", "https://id.dx.deepgram.com") diff --git a/packages/deepctl-core/src/deepctl_core/base_command.py b/packages/deepctl-core/src/deepctl_core/base_command.py index 38f8b69..8aa48be 100644 --- a/packages/deepctl-core/src/deepctl_core/base_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_command.py @@ -4,15 +4,20 @@ from typing import Any, ClassVar import click -from rich.console import Console from .auth import AuthManager from .client import DeepgramClient from .config import Config +from .models import ErrorResult from .output import _agentic, print_error, print_info, print_warning, stderr_console +from .output import console as stdout_console from .timing import TimingContext -console = Console() +# The PAYLOAD console -- stdout, deliberately. Tables, JSON and raw text +# written by _output_* are the machine-readable result and belong there. +# Shared instance rather than a bare Console() so it honours the agentic +# no-color/highlight settings; diagnostics use stderr_console instead. +console = stdout_console class BaseCommand(ABC): @@ -105,10 +110,22 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: else: print_warning("No project ID specified") - except Exception: - # guard() already printed helpful error messages; - # exit without duplicating them. - raise SystemExit(1) + except Exception as auth_error: + # guard() already wrote the human-readable diagnosis to + # stderr, so don't duplicate it -- but stdout must still + # carry a parseable payload in a machine-readable + # format. A CI step doing `dg -o json ... > out.json` + # and parsing the result should get a structured error, + # not an empty file. output_result is a no-op in + # default mode, so this adds nothing for humans. + self._tag_telemetry_status("error") + try: + self.output_result( + ErrorResult(error=str(auth_error)), config + ) + except (BrokenPipeError, OSError): + pass + raise SystemExit(1) from auth_error # Check project ID if required if self.requires_project: diff --git a/packages/deepctl-core/src/deepctl_core/base_group_command.py b/packages/deepctl-core/src/deepctl_core/base_group_command.py index f8b3941..7948c3f 100644 --- a/packages/deepctl-core/src/deepctl_core/base_group_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_group_command.py @@ -3,14 +3,17 @@ from typing import Any import click -from rich.console import Console from .auth import AuthManager from .base_command import BaseCommand from .client import DeepgramClient from .config import Config +from .output import stderr_console -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console class BaseGroupCommand(BaseCommand): diff --git a/packages/deepctl-core/src/deepctl_core/client.py b/packages/deepctl-core/src/deepctl_core/client.py index 8c842e0..5fbfbdc 100644 --- a/packages/deepctl-core/src/deepctl_core/client.py +++ b/packages/deepctl-core/src/deepctl_core/client.py @@ -9,7 +9,8 @@ from deepgram import DeepgramClient as DGClient from deepgram import DeepgramClientEnvironment from deepgram.core.api_error import ApiError -from rich.console import Console + +from .output import stderr_console if TYPE_CHECKING: from collections.abc import Iterator @@ -18,7 +19,10 @@ from .auth import AuthManager from .config import Config -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console def _split_base_url(base_url: str) -> tuple[str, str, str]: diff --git a/packages/deepctl-core/src/deepctl_core/timing.py b/packages/deepctl-core/src/deepctl_core/timing.py index 86309c4..21c3ff7 100644 --- a/packages/deepctl-core/src/deepctl_core/timing.py +++ b/packages/deepctl-core/src/deepctl_core/timing.py @@ -7,9 +7,12 @@ from threading import local from typing import Any -from rich.console import Console +from .output import stderr_console -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console # Thread-local storage for timing data _timing_data = local() diff --git a/packages/deepctl-core/tests/unit/test_output_channels.py b/packages/deepctl-core/tests/unit/test_output_channels.py new file mode 100644 index 0000000..e8fa8d8 --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -0,0 +1,148 @@ +"""Which stream each console writes to, pinned. + +Regression cover for #104: `deepctl_core` modules declared bare `Console()` +instances bound to stdout and printed diagnostics through them, so +`dg -o json ` on an auth failure wrote English prose to stdout and left +stderr empty -- unparseable for the CI step that redirects stdout and parses +it. `get_status_console()` warns about exactly this in its own docstring; the +tests below turn that warning into a gate. + +The distinction these tests protect is *what the console carries*, not the +module it lives in: + +- diagnostics (errors, status, progress, timing chrome) -> stderr, always +- the payload (JSON/YAML/table/CSV a command produces) -> stdout, always +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest +from deepctl_core import output + +CORE_SRC = Path(output.__file__).parent + +# Modules whose module-level `console` carries diagnostics only. +DIAGNOSTIC_MODULES = [ + "auth", + "client", + "timing", + "base_group_command", + "plugin_manager", +] + + +class TestConsoleBindings: + """Every module-level console must be one of the two shared instances.""" + + @pytest.mark.parametrize("module_name", DIAGNOSTIC_MODULES) + def test_diagnostic_console_is_the_shared_stderr_console( + self, module_name: str + ) -> None: + import importlib + + module = importlib.import_module(f"deepctl_core.{module_name}") + + assert module.console is output.stderr_console + assert module.console.stderr is True + + def test_base_command_console_is_the_shared_stdout_console(self) -> None: + """The payload console stays on stdout -- deliberately. + + Tables, JSON and raw text written by `_output_*` are the + machine-readable result and belong on stdout. The requirement is that + it is the *shared* instance, so it honours the agentic no-color and + highlight settings a bare Console() would silently miss. + """ + from deepctl_core import base_command + + assert base_command.console is output.console + assert base_command.console.stderr is False + + +class TestNoBareConsoleInCore: + """A bare `Console()` in deepctl_core is how #104 happened.""" + + def test_no_module_declares_a_bare_console(self) -> None: + offenders: list[str] = [] + + for path in sorted(CORE_SRC.glob("*.py")): + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = getattr(func, "id", None) or getattr(func, "attr", None) + if name != "Console": + continue + # output.py is where the two shared instances are built. + if path.name == "output.py": + continue + offenders.append(f"{path.name}:{node.lineno}") + + assert not offenders, ( + "bare Console() in deepctl_core reintroduces the #104 stdout " + "pollution -- import `console` or `stderr_console` from .output " + f"instead. Found at: {', '.join(offenders)}" + ) + + +class TestAuthFailurePayload: + """The #104 reproduction, as a test.""" + + def _run_guard_failure(self, capsys, output_format: str): + """Drive a requires_auth command whose guard() raises.""" + from unittest.mock import MagicMock, patch + + import click + from deepctl_core.auth import AuthenticationError + from deepctl_core.base_command import BaseCommand + from deepctl_core.config import Config + + class NeedsAuth(BaseCommand): + name = "needs-auth" + help = "test command" + requires_auth = True + + def handle(self, config, auth_manager, client, **kwargs): # type: ignore[no-untyped-def] + raise AssertionError("handle must not run when guard() fails") + + command = NeedsAuth() + ctx = MagicMock(spec=click.Context) + ctx.obj = {"config": Config()} + + auth_manager = MagicMock() + auth_manager.guard.side_effect = AuthenticationError( + "Invalid API key - authentication failed" + ) + + with ( + patch( + "deepctl_core.base_command.AuthManager", return_value=auth_manager + ), + patch("deepctl_core.base_command.DeepgramClient"), + patch( + "deepctl_core.output.get_output_format", return_value=output_format + ), + ): + with pytest.raises(SystemExit) as exc: + command.execute(ctx) + + assert exc.value.code == 1 + return capsys.readouterr() + + def test_json_failure_writes_parseable_payload_to_stdout(self, capsys) -> None: + captured = self._run_guard_failure(capsys, "json") + + payload = json.loads(captured.out) + assert payload["status"] == "error" + assert "Invalid API key" in payload["error"] + + def test_default_mode_writes_nothing_to_stdout(self, capsys) -> None: + """Human mode must not gain a duplicate of the stderr diagnosis.""" + captured = self._run_guard_failure(capsys, "default") + + assert captured.out == "" From 72cf6186356c7ed1ef4dfc18ce4601661c67d29f Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:37:26 +0100 Subject: [PATCH 2/4] fix(commands): correct advertised examples that don't parse Closes #105. `dg usage` advertised `--days 30` and `--start`/`--end`; the real options are `--start-date`/`--end-date` and there is no `--days` at all. The help text contradicted its own options list eight lines further down. The issue suggested sweeping the other commands, which found the same class of error in two more places: - `dg debug stream` -- debug's subcommands are audio, browser, network, probe and toolkit. The WebSocket stream debugging its agent_help describes is `probe` ("Stream probe proxy"). - `dg profiles --show default` -- profiles has --switch, --current and --list; there is no --show. This is worse than a help-text typo because the same `examples` array is what `--agent-friendly` emits, so an agent asking the CLI how to use itself was handed four commands that fail. tests/unit/test_command_examples.py now parses every string in every examples array against the real command tree, one test per example (125 of them). parse_args resolves the command and validates options without invoking the handler, so nothing touches the network. Examples are shell snippets rather than bare argv, so it extracts just the `dg ...` segments -- pipelines, upstream producers and trailing # comments are not ours to validate, and command substitution is skipped outright. `dg debug toolkit`'s subcommands are exempt: they are built from a manifest fetched by `toolkit refresh` and cached on disk, so a clean checkout has only `refresh` and its script examples are unverifiable rather than wrong. A test_examples_were_discovered guard fails if the entry point groups go stale, so the suite cannot silently degrade to testing nothing. --- .../src/deepctl_cmd_debug/command.py | 2 +- .../src/deepctl_cmd_login/command.py | 2 +- .../src/deepctl_cmd_usage/command.py | 4 +- tests/unit/test_command_examples.py | 131 ++++++++++++++++++ 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_command_examples.py diff --git a/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py b/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py index ef8c347..df0af89 100644 --- a/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py +++ b/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py @@ -27,7 +27,7 @@ class DebugCommand(BaseGroupCommand): "dg debug audio -f recording.wav", "dg debug network", "dg debug browser", - "dg debug stream", + "dg debug probe", ] agent_help = ( "Diagnostic utilities for troubleshooting Deepgram integrations. " diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index 2f3db58..79eb1e9 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -538,7 +538,7 @@ class ProfilesCommand(BaseCommand): examples = [ "dg profiles --list", - "dg profiles --show default", + "dg profiles --current", "dg profiles --switch staging", ] agent_help = ( diff --git a/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py b/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py index 26f2e7b..ace787f 100644 --- a/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py +++ b/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py @@ -37,8 +37,8 @@ class UsageCommand(BaseCommand): examples = [ "dg usage", - "dg usage --days 30", - "dg usage --start 2025-01-01 --end 2025-01-31", + "dg usage --last-month", + "dg usage --start-date 2025-01-01 --end-date 2025-01-31", ] agent_help = ( "View Deepgram API usage statistics for the current project. " diff --git a/tests/unit/test_command_examples.py b/tests/unit/test_command_examples.py new file mode 100644 index 0000000..c4d1da7 --- /dev/null +++ b/tests/unit/test_command_examples.py @@ -0,0 +1,131 @@ +"""Every advertised example must actually parse. + +Regression cover for #105: `dg usage` shipped three examples, two of which the +command could not parse (`--days`, `--start`/`--end` against real options +`--start-date`/`--end-date`). The help text contradicted its own options list +eight lines further down. + +This matters beyond `--help`. The same `examples` array is what +`--agent-friendly` emits, so an agent asking the CLI how to use itself was +handed commands that fail. A sweep at the time this test was written found +four broken examples across three commands, so the class needed a gate rather +than three fixes. + +Parsing only -- `parse_args` resolves the command and validates the options +without invoking the handler, so nothing here touches the network. +""" + +from __future__ import annotations + +import re +import shlex +from importlib import metadata + +import click +import pytest + +# Entry point groups that carry command classes. +COMMAND_GROUPS = ["deepctl.commands", "deepctl.subcommands.debug"] + +BINARY_NAMES = ("dg", "deepctl", "deepgram") + +# Groups whose subcommands are built at runtime from state this test cannot +# see, so their examples are unverifiable rather than wrong. +# toolkit: subcommands come from a manifest fetched by `dg debug toolkit +# refresh` and cached on disk; a clean checkout has only `refresh`. +DYNAMIC_SUBCOMMAND_PREFIXES = [("debug", "toolkit")] + + +def _dg_invocations(example: str) -> list[list[str]]: + """Extract the argv of each `dg ...` invocation in a shell example. + + Examples are shell snippets, not bare argv: they contain pipelines + (`dg speak "hi" | ffplay -`), upstream producers (`cat f | dg read`), and + trailing `# comments`. Only the segments that invoke our own binary are + ours to validate. + """ + if "$(" in example or "`" in example: + # Command substitution -- `eval "$(dg completion bash)"` and friends. + # The inner dg call is real but the surrounding shell is not argv. + return [] + + invocations = [] + for segment in re.split(r"\|\||&&|\|", example): + try: + argv = shlex.split(segment, comments=True) + except ValueError: + continue + if argv and argv[0] in BINARY_NAMES: + invocations.append(argv[1:]) + return invocations + + +def _parse(cli: click.Group, argv: list[str]) -> None: + """Resolve the command path and parse its options. Never invokes.""" + ctx = click.Context(cli, info_name="dg") + command: click.Command = cli + args = list(argv) + + while isinstance(command, click.Group) and args and not args[0].startswith("-"): + name, sub, args = command.resolve_command(ctx, args) + if sub is None: + raise click.UsageError(f"No such command {name!r}") + ctx = click.Context(sub, parent=ctx, info_name=name) + command = sub + + command.parse_args(ctx, list(args)) + + +def _collect() -> list[tuple[str, str, list[str]]]: + """(command name, example string, argv) for every advertised example.""" + entry_points = metadata.entry_points() + collected = [] + for group in COMMAND_GROUPS: + for entry_point in entry_points.select(group=group): + try: + command_class = entry_point.load() + except Exception: # pragma: no cover - a broken package fails elsewhere + continue + for example in getattr(command_class, "examples", None) or []: + for argv in _dg_invocations(example): + if any( + tuple(argv[: len(prefix)]) == prefix + for prefix in DYNAMIC_SUBCOMMAND_PREFIXES + ): + continue + collected.append((entry_point.name, example, argv)) + return collected + + +CASES = _collect() + + +def test_examples_were_discovered() -> None: + """Guard the guard: an import change that empties CASES must not pass.""" + assert len(CASES) > 50, ( + f"only {len(CASES)} examples discovered -- the entry point groups in " + "COMMAND_GROUPS are probably stale, so this file is testing nothing" + ) + + +@pytest.mark.parametrize( + ("command_name", "example", "argv"), + CASES, + ids=[f"{name}: {example}" for name, example, _ in CASES], +) +def test_example_parses(command_name: str, example: str, argv: list[str]) -> None: + """Every string in every `examples` array must parse against the real CLI.""" + from deepctl.main import cli + + try: + _parse(cli, argv) + except (SystemExit, click.exceptions.Exit): + # An eager option such as --help short-circuits; it parsed fine. + pass + except click.ClickException as exc: + pytest.fail( + f"`{example}` is advertised by `dg {command_name}` but does not " + f"parse: {type(exc).__name__}: {exc}\n" + "Fix the example, or add the option/subcommand it promises. This " + "array is also what --agent-friendly emits." + ) From a0939dc819ff383c5a085804c7112673449f6bac Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:37:26 +0100 Subject: [PATCH 3/4] docs(web): attribute auto-JSON to agent/CI detection, not piping Closes #106. The landing page promised output auto-switches to JSON when stdout is piped. It doesn't: setup_output only flips the format when is_agentic() is true, and is_agentic() needs 3+ soft signals. A plain pipe from an interactive shell scores 1 (stdout not a tty), or 2 if stdin is redirected too -- TERM is set in any normal terminal, so the third point never arrives. Verified directly: with both streams non-tty and TERM=xterm, is_agentic() is False and setup_output("default") leaves the format at "default". Two instances beyond the three the issue names: - README.md said the switch happens in "a non-TTY environment (pipes, CI, or AI coding tools)". CI and AI tools are right; pipes are not. - index.astro:433 is a JSON-LD FAQPage answer -- structured data Google can surface as a rich result, so the false claim travels further than the page. The 'Errors to stderr' bullet is softened rather than kept. #106 offered keeping "Clean stdout channel. No surprises in pipes." if #104 landed first; #104 landed, but the claim is still not true. Probing failure paths across ten commands found `dg ffprobe --path /nonexistent` and `dg debug audio -f /nonexistent.wav` still emitting prose to stdout ahead of the JSON payload, from bare Console() instances in their own packages. Those consoles are mixed -- they also carry the human-readable display that belongs on stdout in default mode -- so routing them needs per-call-site judgment across ~30 command packages rather than a module-level swap. Out of scope here; the bullet now describes the contract without the absolute guarantee. The agent-mode copy at :645 and llms.txt:34 already attributed the switch correctly and are left alone. Verified by building the site: all three visible strings render, the old claims are gone, and the FAQPage schema still parses. --- README.md | 7 +++++-- web/src/pages/index.astro | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8ff3363..6dc29c8 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,11 @@ dg keys --list -o csv dg usage --last-week -o yaml ``` -When running in a non-TTY environment (pipes, CI, or AI coding tools), the CLI -automatically switches to structured JSON output with plain-text status messages. +In CI and AI coding tools, the CLI detects the context and automatically +switches to structured JSON output with plain-text status messages. A plain +pipe on its own does not trigger this — `dg projects | jq` still gets the +human-readable table, so pass `-o json` explicitly when you are piping from an +interactive shell. ### Exit codes diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 18748bc..9f9cbed 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -430,7 +430,7 @@ const commandCards: CommandCard[] = [ "name": "Can I pipe the Deepgram CLI output to other tools?", "acceptedAnswer": { "@type": "Answer", - "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. When stdout is a pipe, the CLI automatically switches to JSON. Status messages always go to stderr to keep stdout clean." + "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. In CI and AI coding tools the CLI detects the context and switches to JSON automatically; from an interactive shell, pass -o json explicitly. Status messages go to stderr so stdout carries only the result." } } ] @@ -669,12 +669,12 @@ const commandCards: CommandCard[] = [

Every command writes structured data to stdout and diagnostics to stderr. Switch formats with -o json or let it - auto-switch when piped. Plays nicely with every UNIX tool you already know. + auto-switch in agent and CI environments. Plays nicely with every UNIX tool you already know.

{[ - ['JSON / YAML / table / CSV', 'Explicit output format, or auto-JSON when piped.'], - ['Errors to stderr', 'Clean stdout channel. No surprises in pipes.'], + ['JSON / YAML / table / CSV', 'Explicit output format, or auto-JSON in agent and CI contexts.'], + ['Errors to stderr', 'Status and diagnostics on stderr, structured results on stdout.'], ['Exit codes everywhere', 'Non-zero on error. Works in set -e scripts.'], ].map(([label, desc]) => (
From f4bec645baa33a4788df289027a1fe6792de348c Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:51:41 +0100 Subject: [PATCH 4/4] fix(test): read source as UTF-8 in the bare-Console AST sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard called path.read_text() with no encoding, so it used the locale codec. On Windows that is cp1252, which cannot decode the ⏱️ in timing.py:119, and the test died with UnicodeDecodeError before it could assert anything -- failing every windows-latest job in the matrix while passing on Linux and macOS. Reproduced locally by forcing the codec: read_text(encoding="cp1252") on timing.py raises at byte 3610; utf-8 reads it fine. --- packages/deepctl-core/tests/unit/test_output_channels.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/deepctl-core/tests/unit/test_output_channels.py b/packages/deepctl-core/tests/unit/test_output_channels.py index e8fa8d8..f4c6b55 100644 --- a/packages/deepctl-core/tests/unit/test_output_channels.py +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -70,7 +70,11 @@ def test_no_module_declares_a_bare_console(self) -> None: offenders: list[str] = [] for path in sorted(CORE_SRC.glob("*.py")): - tree = ast.parse(path.read_text(), filename=str(path)) + # encoding is explicit because read_text() otherwise uses the + # locale codec -- cp1252 on Windows, which cannot decode the + # emoji in timing.py and fails the whole matrix. + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) for node in ast.walk(tree): if not isinstance(node, ast.Call): continue