From 9856fa20f475c80397448ae75434da8ce3d8fca8 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Fri, 24 Jul 2026 06:39:48 -0400 Subject: [PATCH] don't attempt inline plots in the Windows console We can lean on prompt_toolkit's console-type detection for this, and exclude some consoles which would probably print a mess of base64 characters to the output. --- changelog.md | 1 + mycli/compat.py | 22 ++++++++++++++ mycli/output.py | 5 ++-- test/pytests/test_output.py | 58 ++++++++++++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/changelog.md b/changelog.md index 5e03e4d06..62fadfe8b 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ Features * Subcommand completions for the `/dsn` command. * Allow file target of `$>` redirection to be quoted. * Display of inline plots returned from `.|` operations. +* Don't attempt inline plots in the Windows console. Bug Fixes diff --git a/mycli/compat.py b/mycli/compat.py index bca14261f..72090d38e 100644 --- a/mycli/compat.py +++ b/mycli/compat.py @@ -1,5 +1,27 @@ """Platform and Python version compatibility support.""" +from importlib import import_module import sys WIN: bool = sys.platform in ("win32", "cygwin") + + +def _is_win32() -> bool: + return sys.platform == 'win32' + + +def is_windows_console(output: object | None) -> bool: + """Return whether output uses a native Windows console backend.""" + if not _is_win32() or output is None: + return False + + output_types = tuple( + getattr(import_module(module_name), class_name) + for module_name, class_name in ( + ('prompt_toolkit.output.conemu', 'ConEmuOutput'), + ('prompt_toolkit.output.win32', 'Win32Output'), + ('prompt_toolkit.output.windows10', 'Windows10_Output'), + ) + ) + + return isinstance(output, output_types) diff --git a/mycli/output.py b/mycli/output.py index 55b93bb4e..7b709cc68 100644 --- a/mycli/output.py +++ b/mycli/output.py @@ -29,7 +29,7 @@ from pygments.style import Style as PygmentsStyle from pymysql.cursors import Cursor -from mycli.compat import WIN +from mycli.compat import WIN, is_windows_console from mycli.constants import DEFAULT_HEIGHT, DEFAULT_WIDTH import mycli.main_modes.repl as repl_mode from mycli.packages import special @@ -116,7 +116,8 @@ def output( is_warnings_style: bool = False, ) -> None: """Output text to stdout or a pager command.""" - if result.image is not None: + prompt_output = self.prompt_session.output if self.prompt_session is not None else None + if result.image is not None and not is_windows_console(prompt_output): if result.image_protocol == 'iterm2': click.secho('') self.output_iterm2_image(result.image) diff --git a/test/pytests/test_output.py b/test/pytests/test_output.py index 696e20656..1d11d44d7 100644 --- a/test/pytests/test_output.py +++ b/test/pytests/test_output.py @@ -2,7 +2,8 @@ import itertools import shutil -from types import SimpleNamespace +import sys +from types import ModuleType, SimpleNamespace from typing import Any, cast import click @@ -11,9 +12,11 @@ from prompt_toolkit.formatted_text import ANSI, FormattedText, to_plain_text import pytest +from mycli import compat from mycli import output as output_module from mycli.output import OutputMixin from mycli.packages.sqlresult import SQLResult +from mycli.types import ImageProtocol from test.utils import DummyFormatter, FakeCursorBase, make_bare_mycli # type: ignore[attr-defined] @@ -112,6 +115,59 @@ def test_output_emits_kitty_image_in_base64_chunks(monkeypatch: pytest.MonkeyPat ] +@pytest.mark.parametrize('image_protocol', ['iterm2', 'kitty']) +def test_output_suppresses_images_in_windows_console( + monkeypatch: pytest.MonkeyPatch, + image_protocol: ImageProtocol, +) -> None: + cli = make_bare_mycli() + cli.prompt_session = cast( + Any, + SimpleNamespace(output=SimpleNamespace(get_size=lambda: SimpleNamespace(columns=80, rows=24))), + ) + cli.get_output_margin = lambda status=None: 1 # type: ignore[assignment] + emitted: list[str | None] = [] + monkeypatch.setattr(output_module, 'is_windows_console', lambda output: True) + monkeypatch.setattr(click, 'echo', lambda value=None, **_kwargs: emitted.append(value)) + + OutputMixin.output(cli, itertools.chain(), SQLResult(image=b'png', image_protocol=image_protocol)) + + assert emitted == [] + + +def test_is_windows_console_detects_prompt_toolkit_backends(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeWin32Output: + pass + + class FakeWindows10Output: + pass + + class FakeConEmuOutput: + pass + + modules = ( + ('prompt_toolkit.output.win32', 'Win32Output', FakeWin32Output), + ('prompt_toolkit.output.windows10', 'Windows10_Output', FakeWindows10Output), + ('prompt_toolkit.output.conemu', 'ConEmuOutput', FakeConEmuOutput), + ) + for module_name, class_name, output_class in modules: + module = ModuleType(module_name) + setattr(module, class_name, output_class) + monkeypatch.setitem(sys.modules, module_name, module) + monkeypatch.setattr(compat.sys, 'platform', 'win32') + + assert compat.is_windows_console(FakeWin32Output()) + assert compat.is_windows_console(FakeWindows10Output()) + assert compat.is_windows_console(FakeConEmuOutput()) + assert not compat.is_windows_console(object()) + + +def test_is_windows_console_ignores_non_windows_platform(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(compat.sys, 'platform', 'cygwin') + + assert not compat.is_windows_console(object()) + + def test_get_output_margin_renders_prompt_once_and_counts_status_lines(monkeypatch: pytest.MonkeyPatch) -> None: cli = make_bare_mycli() cli.prompt_lines = 0