From 0c0392fe680b3af826adb29a46aca4d3bbcdf7e5 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Thu, 23 Jul 2026 06:02:46 -0400 Subject: [PATCH] ability to display static Altair plots * if a .| dataframe operation returns a plot, display it inline, using the kitty graphics protocol by default (since it has the widest support in contemporary terminals such as kitty and Ghostty https://sw.kovidgoyal.net/kitty/graphics-protocol/) * revise transforms.md doc, including changes outside the subject area of plotting There are some small incidental PromptState tests added, as "coverage" showed that our coverage there was not 100% for some reason. There are many minor followups we can imagine here, such as providing the facility to save an image. --- README.md | 2 +- changelog.md | 1 + doc/screenshots/total_histogram.png | Bin 0 -> 247721 bytes doc/transforms.md | 41 ++++-- mycli/app_state.py | 11 ++ mycli/client.py | 7 ++ mycli/main_modes/repl.py | 19 ++- mycli/myclirc | 21 ++++ mycli/output.py | 24 ++++ mycli/packages/polars_transform.py | 36 +++++- mycli/packages/sqlresult.py | 9 +- mycli/types.py | 6 + pyproject.toml | 2 + test/myclirc | 21 ++++ test/pytests/test_app_state.py | 72 +++++++++++ test/pytests/test_client.py | 70 +++++++++++ test/pytests/test_main_modes_repl.py | 73 +++++++++-- test/pytests/test_output.py | 33 +++++ test/pytests/test_polars_transform.py | 171 +++++++++++++++++++++++++- 19 files changed, 592 insertions(+), 27 deletions(-) create mode 100644 doc/screenshots/total_histogram.png diff --git a/README.md b/README.md index 9b67059f3..6d3705797 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Features * Pretty print tabular data (with colors!). * Support for SSL connections * Shell-style trailing redirects with `$>`, `$>>` and `$|` operators. -* [Polars](https://pola.rs) dataframe [transforms](doc/transforms.md) with `.|` and Parquet saves with `.>`. +* [Polars](https://pola.rs) dataframe [transforms and plots](doc/transforms.md) with `.|`, and Parquet saves with `.>`. * Support for querying LLMs with context derived from your schema using `/llm`. * Support for storing passwords in the system keyring. diff --git a/changelog.md b/changelog.md index b12ae07ea..5ceb42fb7 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Features --------- * Subcommand completions for the `/dsn` command. * Allow file target of `$>` redirection to be quoted. +* Display of inline plots returned from `.|` operations. Bug Fixes diff --git a/doc/screenshots/total_histogram.png b/doc/screenshots/total_histogram.png new file mode 100644 index 0000000000000000000000000000000000000000..fe68d1d489a42d0d626d281f6edb2bdf1c850321 GIT binary patch literal 247721 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⋘K8biZ!B;>xVs+ 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 U-=@+3{p-YA{$=fV?^*Ew09#_S#{d8T literal 0 HcmV?d00001 diff --git a/doc/transforms.md b/doc/transforms.md index 81779f730..ce8c65775 100644 --- a/doc/transforms.md +++ b/doc/transforms.md @@ -1,4 +1,4 @@ -# Transforms with Polars Dataframes +# Transforms, Plots, and Parquets with Polars Dataframes ## Installing @@ -8,19 +8,24 @@ Install mycli with dataframe support using: pip install --upgrade 'mycli[dataframe]' ``` -or install the Polars and Altair libraries separately. +or install these libraries separately: + + * [`polars`](https://pypi.org/project/polars/) + * [`altair`](https://pypi.org/project/altair/) + * [`vl-convert-python`](https://pypi.org/project/vl-convert-python/) ## BETA STATUS -Dataframe transforms are new and experimental. The interface and functionality -may still change. +Dataframe transforms and plots are new and experimental. The interface and +functionality may still change. Here are some known limitations: - * transforms can't be mixed with `$|` shell redirection + * transforms can't be composed with `$|` shell redirection * multiple transform steps are not permitted - * the Altair library is provided, but plots can't yet be displayed * results from `UNION`s may be unable to be transformed + * images cannot yet be saved + * PNG images are static and do not support all Altair features And there are inherent limitations to the post-processing model: the entire SQL result must be transferred from the server and loaded into local memory. @@ -34,7 +39,7 @@ single SQL statement. The Python expression receives * `pl`, the Polars module * `alt`, the Altair module -Spaces may be required around the operator. +Spaces may be required around the `.|` operator. Transform example: @@ -51,20 +56,34 @@ SELECT customer_id, COUNT(1) AS len FROM orders GROUP BY customer_id; Transform expressions run with normal Python privileges, and expressions should not be run from untrusted sources. If the transform operation returns a Polars `DataFrame` or `Series`, the result is rendered by mycli -as tabular output; other return types currently give a warning and cannot -be displayed. +as tabular output. Most other return types will be silently ignored. Transform expressions are useful for operations such as medians which -cannot be done (or are simply awkward) in SQL. Example: +cannot be done (or are awkward) in SQL. Example: ```sql SELECT * FROM orders .| df.describe(); ``` +## Plotting + +If the dataframe transform operation returns an Altair plot, the result can +be rendered as an inline PNG in many terminals. + +Example: + +```sql +SELECT * FROM orders .| df['total'].plot.hist(); +``` +![histogram](https://raw.githubusercontent.com/dbcli/mycli/main/doc/screenshots/total_histogram.png) + +Image size, display protocol, and other properties can be configured in +the `[dataframe]` section of `~/.myclirc`. + ## Saving A query result, transformed `DataFrame`, or transformed `Series` can be -written directly to Parquet with the `.>` operator. +written directly to a Parquet file with the `.>` operator. Save example: diff --git a/mycli/app_state.py b/mycli/app_state.py index d8381ea77..02e175e51 100644 --- a/mycli/app_state.py +++ b/mycli/app_state.py @@ -6,6 +6,7 @@ from configobj import ConfigObj from mycli.config import strip_matching_quotes +from mycli.types import ImageProtocol if TYPE_CHECKING: from mycli.client import MyCli @@ -46,6 +47,16 @@ def normalize_ssl_mode( return ssl_mode, error_notice +def normalize_image_protocol(image_protocol: str | None) -> tuple[ImageProtocol, str | None]: + if image_protocol == 'iterm2': + return 'iterm2', None + if image_protocol == 'kitty': + return 'kitty', None + if image_protocol in ('none', '', None): + return 'none', None + return 'none', f'Invalid config option provided for image_protocol ({image_protocol}); disabling.' + + def configure_prompt_state( mycli: MyCli, config: ConfigObj, diff --git a/mycli/client.py b/mycli/client.py index 8dd4c3706..e961f15ac 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -17,6 +17,7 @@ configure_prompt_state, destructive_keywords_from_config, llm_prompt_truncation, + normalize_image_protocol, normalize_ssl_mode, ) from mycli.client_commands import ClientCommandsMixin @@ -145,6 +146,12 @@ def __init__( self.null_string = c['main'].get('null_string') self.numeric_alignment = c['main'].get('numeric_alignment', 'right') or 'right' self.binary_display = c['main'].get('binary_display') + self.image_protocol, image_protocol_error = normalize_image_protocol(c['dataframe'].get('image_protocol')) + if image_protocol_error: + self.echo(image_protocol_error, err=True, fg='red') + self.plot_scale_factor = c['dataframe'].as_float('plot_scale_factor') + self.plot_ppi = c['dataframe'].as_int('plot_ppi') + self.plot_theme = c['dataframe'].get('plot_theme', 'carbong90') or 'carbong90' self.llm_prompt_field_truncate, self.llm_prompt_section_truncate = llm_prompt_truncation(c) self.ssl_mode, ssl_mode_error = normalize_ssl_mode(c, self.config_without_package_defaults) diff --git a/mycli/main_modes/repl.py b/mycli/main_modes/repl.py index 1825c643c..a20902058 100644 --- a/mycli/main_modes/repl.py +++ b/mycli/main_modes/repl.py @@ -751,9 +751,24 @@ def _one_iteration( if polars_transform is not None: assert polars_pipeline is not None if polars_pipeline.parquet_path is None: - polars_result = run_polars_transform(polars_transform, results) + polars_result = run_polars_transform( + polars_transform, + results, + image_protocol=mycli.image_protocol, + plot_scale_factor=mycli.plot_scale_factor, + plot_ppi=mycli.plot_ppi, + plot_theme=mycli.plot_theme, + ) else: - polars_result = run_polars_transform(polars_transform, results, polars_pipeline.parquet_path) + polars_result = run_polars_transform( + polars_transform, + results, + polars_pipeline.parquet_path, + image_protocol=mycli.image_protocol, + plot_scale_factor=mycli.plot_scale_factor, + plot_ppi=mycli.plot_ppi, + plot_theme=mycli.plot_theme, + ) if polars_pipeline.parquet_path is None: if polars_pipeline.output_mode == 'explorer': special.set_explorer_output(True) diff --git a/mycli/myclirc b/mycli/myclirc index 12ee2c840..a7882745d 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -265,6 +265,27 @@ format = mysql_unicode # Whether to remove the last line from the formatted output. trim_footer = False +[dataframe] + +# How to display inline image results in a terminal emulator. Possible values: +# * kitty +# * iterm2 +# empty to disable +image_protocol = kitty + +# PNG plot resolution in pixels per inch. Must be a positive integer. +# The apparent size is a combination of plot_ppi and plot_scale_factor. +plot_ppi = 200 + +# PNG plot resolution multiplier. Must be a positive number. +# The apparent size is a combination of plot_ppi and plot_scale_factor. +plot_scale_factor = 1.0 + +# Altair theme for rendering plots. Examples: carbong90, dark, default. +# Available themes depend on the installed libraries. See +# https://github.com/vega/vega-themes/#included-themes +plot_theme = carbong90 + [search] # Whether to apply syntax highlighting to the preview window in fuzzy history diff --git a/mycli/output.py b/mycli/output.py index 6b3a23204..55b93bb4e 100644 --- a/mycli/output.py +++ b/mycli/output.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from datetime import datetime from decimal import Decimal from io import TextIOWrapper @@ -115,6 +116,13 @@ def output( is_warnings_style: bool = False, ) -> None: """Output text to stdout or a pager command.""" + if result.image is not None: + if result.image_protocol == 'iterm2': + click.secho('') + self.output_iterm2_image(result.image) + elif result.image_protocol == 'kitty': + click.secho('') + self.output_kitty_image(result.image) if output: if self.prompt_session is not None: size = self.prompt_session.output.get_size() @@ -186,6 +194,22 @@ def newlinewrapper(text: list[str]) -> Generator[str, None, None]: styled_status = to_formatted_text(status, style=add_style) prompt_toolkit.print_formatted_text(styled_status, style=self.ptoolkit_style) + def output_iterm2_image(self, image: bytes) -> None: + """Emit a PNG using the iTerm2 inline image protocol.""" + filename = base64.b64encode(b'chart.png').decode('ascii') + contents = base64.b64encode(image).decode('ascii') + click.echo(f'\x1b]1337;File=name={filename};size={len(image)};width=auto;height=auto;preserveAspectRatio=1;inline=1:{contents}\x07') + + def output_kitty_image(self, image: bytes) -> None: + """Emit a PNG using Kitty's direct graphics protocol.""" + contents = base64.b64encode(image).decode('ascii') + chunks = [contents[index : index + 4096] for index in range(0, len(contents), 4096)] or [''] + for index, chunk in enumerate(chunks): + action = 'a=T,f=100,' if index == 0 else '' + more = 1 if index < len(chunks) - 1 else 0 + click.echo(f'\x1b_G{action}m={more};{chunk}\x1b\\', nl=False) + click.echo() + def configure_pager(self) -> None: if not os.environ.get("LESS"): os.environ["LESS"] = "-RXF" diff --git a/mycli/packages/polars_transform.py b/mycli/packages/polars_transform.py index 7a015e243..2c7bc2c7b 100644 --- a/mycli/packages/polars_transform.py +++ b/mycli/packages/polars_transform.py @@ -2,6 +2,7 @@ import builtins from dataclasses import dataclass +from io import BytesIO from types import CodeType from typing import Any, Iterable @@ -9,7 +10,7 @@ from mycli.packages.special.delimitercommand import DelimiterCommand from mycli.packages.sqlresult import SQLResult -from mycli.types import OutputMode +from mycli.types import ImageProtocol, OutputMode delimiter_command = DelimiterCommand() @@ -169,10 +170,22 @@ def _load_altair() -> Any: return alt +def _load_vl_convert() -> None: + try: + import vl_convert # noqa: F401 + except ImportError as exc: + raise PolarsTransformError('Altair plot rendering requires vl-convert-python. Install mycli[dataframe].') from exc + + def run_polars_transform( transform: PolarsTransform, results: Iterable[SQLResult], parquet_path: str | None = None, + *, + image_protocol: ImageProtocol = 'none', + plot_scale_factor: float = 1.0, + plot_ppi: int = 200, + plot_theme: str = 'carbong90', ) -> SQLResult: iterator = iter(results) try: @@ -215,6 +228,27 @@ def run_polars_transform( raise PolarsTransformError(f'Unable to write Parquet file "{parquet_path}": {type(exc).__name__}: {exc}') from exc return SQLResult(status=f'Wrote {len(series_dataframe)} rows to {parquet_path}.') return SQLResult(header=[column_name], rows=[(item,) for item in value]) + if transform.altair is not None and isinstance(value, transform.altair.TopLevelMixin): + if parquet_path is not None: + raise PolarsTransformError('Polars transforms must return a DataFrame or Series before writing Parquet output.') + if image_protocol == 'none': + return SQLResult(status='image_protocol is unset in ~/.myclirc. Inline plotting is disabled.') + _load_vl_convert() + png = BytesIO() + try: + transform.altair.theme.enable(plot_theme) + except Exception as exc: + raise PolarsTransformError(f'Unable to enable Altair plot theme "{plot_theme}": {type(exc).__name__}: {exc}') from exc + try: + value.save( + png, + format='png', + scale_factor=plot_scale_factor, + ppi=plot_ppi, + ) + except Exception as exc: + raise PolarsTransformError(f'Unable to render Altair chart: {type(exc).__name__}: {exc}') from exc + return SQLResult(image=png.getvalue(), image_protocol=image_protocol) if parquet_path is not None: raise PolarsTransformError('Polars transforms must return a DataFrame or Series before writing Parquet output.') return SQLResult(status=f'Nothing could be displayed for return type: {type(value)}') diff --git a/mycli/packages/sqlresult.py b/mycli/packages/sqlresult.py index b1f5e272b..0b9d4b9c7 100644 --- a/mycli/packages/sqlresult.py +++ b/mycli/packages/sqlresult.py @@ -4,6 +4,8 @@ from prompt_toolkit.formatted_text import FormattedText, to_plain_text from pymysql.cursors import Cursor +from mycli.types import ImageProtocol + @dataclass class SQLResult: @@ -13,9 +15,14 @@ class SQLResult: postamble: str | None = None status: str | FormattedText | None = None command: dict[str, str | float] | None = None + image: bytes | None = None + image_protocol: ImageProtocol = 'none' def __str__(self): - return f"{self.preamble}, {self.header}, {self.rows}, {self.postamble}, {self.status}, {self.command}" + image = f'<{len(self.image)} bytes>' if self.image is not None else None + return ( + f"{self.preamble}, {self.header}, {self.rows}, {self.postamble}, {self.status}, {self.command}, {image}, {self.image_protocol}" + ) @cached_property def status_plain(self): diff --git a/mycli/types.py b/mycli/types.py index 85f2d6d3a..2a00632c6 100644 --- a/mycli/types.py +++ b/mycli/types.py @@ -9,3 +9,9 @@ 'expanded', 'tabular', ] + +ImageProtocol = Literal[ + 'none', + 'iterm2', + 'kitty', +] diff --git a/pyproject.toml b/pyproject.toml index adc9e51f9..3d1da695c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ llm = [ dataframe = [ "polars ~= 1.42.1", "altair ~= 6.2.2", + "vl-convert-python ~= 1.9.0", ] all = [ "mycli[llm,dataframe]", @@ -73,6 +74,7 @@ dev = [ "ruff ~= 0.15.0", "polars ~= 1.42.1", "altair ~= 6.2.2", + "vl-convert-python ~= 1.9.0", ] [project.scripts] diff --git a/test/myclirc b/test/myclirc index e13fa5c7a..fca7a1a5b 100644 --- a/test/myclirc +++ b/test/myclirc @@ -265,6 +265,27 @@ format = mysql_unicode # Whether to remove the last line from the formatted output. trim_footer = False +[dataframe] + +# How to display inline image results in a terminal emulator. Possible values: +# * kitty +# * iterm2 +# empty to disable +image_protocol = kitty + +# PNG plot resolution in pixels per inch. Must be a positive integer. +# The apparent size is a combination of plot_ppi and plot_scale_factor. +plot_ppi = 200 + +# PNG plot resolution multiplier. Must be a positive number. +# The apparent size is a combination of plot_ppi and plot_scale_factor. +plot_scale_factor = 1.0 + +# Altair theme for rendering plots. Examples: carbong90, dark, default. +# Available themes depend on the installed libraries. See +# https://github.com/vega/vega-themes/#included-themes +plot_theme = carbong90 + [search] # Whether to apply syntax highlighting to the preview window in fuzzy history diff --git a/test/pytests/test_app_state.py b/test/pytests/test_app_state.py index f97bf9578..94bedcaa7 100644 --- a/test/pytests/test_app_state.py +++ b/test/pytests/test_app_state.py @@ -5,8 +5,10 @@ from mycli.app_state import ( AppStateMixin, + configure_prompt_state, destructive_keywords_from_config, llm_prompt_truncation, + normalize_image_protocol, normalize_ssl_mode, ) @@ -16,6 +18,76 @@ def __init__(self, login_path: str | None = None) -> None: self.login_path = login_path +class PromptState: + prompt_format: str + prompt_lines: int + multiline_continuation_char: str + toolbar_format: str + terminal_tab_title_format: str + terminal_window_title_format: str + multiplex_window_title_format: str + multiplex_pane_title_format: str + + def __init__(self) -> None: + self.default_prompt = 'default> ' + + +@pytest.mark.parametrize( + ('image_protocol', 'expected'), + [ + ('iterm2', ('iterm2', None)), + ('kitty', ('kitty', None)), + ('none', ('none', None)), + ('', ('none', None)), + (None, ('none', None)), + ('unknown', ('none', 'Invalid config option provided for image_protocol (unknown); disabling.')), + ], +) +def test_normalize_image_protocol(image_protocol: str | None, expected: tuple[str, str | None]) -> None: + assert normalize_image_protocol(image_protocol) == expected + + +@pytest.mark.parametrize( + ('prompt', 'config_prompt', 'toolbar_format', 'config_toolbar', 'expected_prompt', 'expected_toolbar'), + [ + ('custom> ', 'configured> ', 'custom toolbar', 'configured toolbar', 'custom> ', 'custom toolbar'), + (None, 'configured> ', None, 'configured toolbar', 'configured> ', 'configured toolbar'), + ('', '', '', 'configured toolbar', 'default> ', 'configured toolbar'), + ], +) +def test_configure_prompt_state_uses_overrides_and_fallbacks( + prompt: str | None, + config_prompt: str, + toolbar_format: str | None, + config_toolbar: str, + expected_prompt: str, + expected_toolbar: str, +) -> None: + state = PromptState() + config = ConfigObj({ + 'main': { + 'prompt': config_prompt, + 'prompt_continuation': '... ', + 'toolbar': config_toolbar, + 'terminal_tab_title': 'tab title', + 'terminal_window_title': 'window title', + 'multiplex_window_title': 'multiplex window title', + 'multiplex_pane_title': 'multiplex pane title', + }, + }) + + configure_prompt_state(state, config, prompt, toolbar_format) # type: ignore[arg-type] + + assert state.prompt_format == expected_prompt + assert state.prompt_lines == 0 + assert state.multiline_continuation_char == '... ' + assert state.toolbar_format == expected_toolbar + assert state.terminal_tab_title_format == 'tab title' + assert state.terminal_window_title_format == 'window title' + assert state.multiplex_window_title_format == 'multiplex window title' + assert state.multiplex_pane_title_format == 'multiplex pane title' + + @pytest.mark.parametrize('ssl_mode', ['auto', 'on', 'off']) def test_normalize_ssl_mode_accepts_known_values(ssl_mode: str) -> None: config = ConfigObj({'main': {'ssl_mode': ssl_mode}, 'connection': {'default_ssl_mode': ssl_mode}}) diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index 1c5489941..733b112df 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -42,6 +42,76 @@ def test_init_reports_invalid_ssl_mode(monkeypatch: pytest.MonkeyPatch, tmp_path assert echo_calls == [('Invalid config option provided for ssl_mode (invalid); ignoring.', {'err': True, 'fg': 'red'})] +def test_init_configures_image_protocol(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patch_constructor_side_effects(monkeypatch) + myclirc = write_myclirc( + tmp_path, + """ + [dataframe] + image_protocol = iterm2 + plot_scale_factor = 1.5 + plot_ppi = 144 + plot_theme = dark + """, + ) + + cli = MyCli(myclirc=myclirc) + + assert cli.image_protocol == 'iterm2' + assert cli.plot_scale_factor == 1.5 + assert cli.plot_ppi == 144 + assert isinstance(cli.plot_ppi, int) + assert cli.plot_theme == 'dark' + + +def test_init_uses_default_plot_theme_for_empty_value(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patch_constructor_side_effects(monkeypatch) + myclirc = write_myclirc( + tmp_path, + """ + [dataframe] + plot_theme = + """, + ) + + cli = MyCli(myclirc=myclirc) + + assert cli.plot_theme == 'carbong90' + + +def test_init_configures_kitty_image_protocol(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patch_constructor_side_effects(monkeypatch) + myclirc = write_myclirc( + tmp_path, + """ + [dataframe] + image_protocol = kitty + """, + ) + + cli = MyCli(myclirc=myclirc) + + assert cli.image_protocol == 'kitty' + + +def test_init_reports_invalid_image_protocol(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patch_constructor_side_effects(monkeypatch) + echo_calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setattr(MyCli, 'echo', lambda self, message, **kwargs: echo_calls.append((message, kwargs))) + myclirc = write_myclirc( + tmp_path, + """ + [dataframe] + image_protocol = sixel + """, + ) + + cli = MyCli(myclirc=myclirc) + + assert cli.image_protocol == 'none' + assert echo_calls == [('Invalid config option provided for image_protocol (sixel); disabling.', {'err': True, 'fg': 'red'})] + + def test_init_honors_explicit_show_warnings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: patch_constructor_side_effects(monkeypatch) show_warnings_calls: list[bool] = [] diff --git a/test/pytests/test_main_modes_repl.py b/test/pytests/test_main_modes_repl.py index 322ff56dd..d17eadebe 100644 --- a/test/pytests/test_main_modes_repl.py +++ b/test/pytests/test_main_modes_repl.py @@ -154,6 +154,10 @@ def make_repl_cli(sqlexecute: Any | None = None) -> Any: cli.null_string = '' cli.numeric_alignment = 'right' cli.binary_display = None + cli.image_protocol = 'none' + cli.plot_scale_factor = 1.0 + cli.plot_ppi = 200 + cli.plot_theme = 'carbong90' cli.prompt_session = None cli.post_redirect_command = None cli.logfile = None @@ -1178,7 +1182,7 @@ def run(self, text: str) -> Iterator[SQLResult]: cli = make_repl_cli(sqlexecute) transform = object() prepare_calls: list[tuple[str, str]] = [] - run_calls: list[object] = [] + run_calls: list[tuple[object, str, float, int, str]] = [] output_flags: list[bool] = [] hook_calls: list[tuple[str, str]] = [] @@ -1186,8 +1190,16 @@ def prepare(sql: str, expression: str) -> object: prepare_calls.append((sql, expression)) return transform - def run(received_transform: object, results: Iterator[SQLResult]) -> SQLResult: - run_calls.append(received_transform) + def run( + received_transform: object, + results: Iterator[SQLResult], + *, + image_protocol: str, + plot_scale_factor: float, + plot_ppi: int, + plot_theme: str, + ) -> SQLResult: + run_calls.append((received_transform, image_protocol, plot_scale_factor, plot_ppi, plot_theme)) assert list(results) == [SQLResult(header=['id'], rows=[(1,)])] return SQLResult(header=['count'], rows=[(1,)]) @@ -1201,13 +1213,17 @@ def run(received_transform: object, results: Iterator[SQLResult]) -> SQLResult: lambda command, filename: hook_calls.append((command, filename)), ) cli.post_redirect_command = 'post {}' + cli.image_protocol = 'iterm2' + cli.plot_scale_factor = 1.5 + cli.plot_ppi = 144 + cli.plot_theme = 'dark' command = f'SELECT * FROM orders .| df.group_by(\'customer_id\').len() {terminator}' repl_mode._one_iteration(cli, repl_mode.ReplState(), command) assert sqlexecute.calls == ['SELECT * FROM orders'] assert prepare_calls == [('SELECT * FROM orders', "df.group_by('customer_id').len()")] - assert run_calls == [transform] + assert run_calls == [(transform, 'iterm2', 1.5, 144, 'dark')] assert output_flags == [True] assert hook_calls == [] assert cli.log_queries == [command] @@ -1256,7 +1272,20 @@ def prepare(sql: str, expression: str | None) -> object: prepare_calls.append((sql, expression)) return transform - def run(received_transform: object, results: Iterator[SQLResult], path: str) -> SQLResult: + def run( + received_transform: object, + results: Iterator[SQLResult], + path: str, + *, + image_protocol: str, + plot_scale_factor: float, + plot_ppi: int, + plot_theme: str, + ) -> SQLResult: + assert image_protocol == 'none' + assert plot_scale_factor == 1.0 + assert plot_ppi == 200 + assert plot_theme == 'carbong90' assert list(results) == [SQLResult(header=['id'], rows=[(1,)])] run_calls.append((received_transform, path)) return SQLResult(status=f'Wrote 1 rows to {path}.') @@ -1306,7 +1335,20 @@ def prepare(sql: str, expression: str | None) -> object: prepare_calls.append((sql, expression)) return transform - def run(received_transform: object, results: Iterator[SQLResult], path: str) -> SQLResult: + def run( + received_transform: object, + results: Iterator[SQLResult], + path: str, + *, + image_protocol: str, + plot_scale_factor: float, + plot_ppi: int, + plot_theme: str, + ) -> SQLResult: + assert image_protocol == 'none' + assert plot_scale_factor == 1.0 + assert plot_ppi == 200 + assert plot_theme == 'carbong90' assert received_transform is transform assert list(results) == [SQLResult(header=['id'], rows=[(1,)])] run_calls.append(path) @@ -1351,7 +1393,9 @@ def run(self, text: str) -> Iterator[SQLResult]: monkeypatch.setattr( repl_mode, 'run_polars_transform', - lambda received_transform, results, path: SQLResult(status=f'Wrote 1 rows to {path}.'), + lambda received_transform, results, path, *, image_protocol, plot_scale_factor, plot_ppi, plot_theme: SQLResult( + status=f'Wrote 1 rows to {path}.' + ), ) def raise_hook_error(command: str, filename: str) -> None: @@ -1384,7 +1428,16 @@ def run(self, text: str) -> Iterator[SQLResult]: monkeypatch.setattr(repl_mode, 'prepare_polars_transform', lambda sql, expression: object()) - def fail_write(received_transform: object, results: Iterator[SQLResult], path: str) -> SQLResult: + def fail_write( + received_transform: object, + results: Iterator[SQLResult], + path: str, + *, + image_protocol: str, + plot_scale_factor: float, + plot_ppi: int, + plot_theme: str, + ) -> SQLResult: raise repl_mode.PolarsTransformError('write failed') monkeypatch.setattr(repl_mode, 'run_polars_transform', fail_write) @@ -1417,7 +1470,9 @@ def run(self, text: str) -> Iterator[SQLResult]: monkeypatch.setattr( repl_mode, 'run_polars_transform', - lambda transform, results: (_ for _ in ()).throw(repl_mode.PolarsTransformError('Polars expression failed')), + lambda transform, results, *, image_protocol, plot_scale_factor, plot_ppi, plot_theme: (_ for _ in ()).throw( + repl_mode.PolarsTransformError('Polars expression failed') + ), ) command = 'SELECT * FROM orders .| df.bad_method()' diff --git a/test/pytests/test_output.py b/test/pytests/test_output.py index b64bfb459..696e20656 100644 --- a/test/pytests/test_output.py +++ b/test/pytests/test_output.py @@ -79,6 +79,39 @@ def test_echo_logs_and_prints(monkeypatch: pytest.MonkeyPatch) -> None: assert printed == [('message', {'fg': 'red'})] +def test_output_emits_iterm2_image_without_text_sinks(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_bare_mycli() + cli.explicit_pager = False + cli.get_output_margin = lambda status=None: 1 # type: ignore[assignment] + logged: list[Any] = [] + emitted: list[str] = [] + cli.log_output = lambda value: logged.append(value) # type: ignore[assignment] + monkeypatch.setattr(click, 'echo', lambda value=None, **_kwargs: emitted.append(value)) + + OutputMixin.output(cli, itertools.chain(), SQLResult(image=b'png', image_protocol='iterm2')) + + assert logged == [] + assert emitted == ['\x1b]1337;File=name=Y2hhcnQucG5n;size=3;width=auto;height=auto;preserveAspectRatio=1;inline=1:cG5n\x07'] + + +def test_output_emits_kitty_image_in_base64_chunks(monkeypatch: pytest.MonkeyPatch) -> None: + cli = make_bare_mycli() + cli.explicit_pager = False + cli.get_output_margin = lambda status=None: 1 # type: ignore[assignment] + emitted: list[tuple[str | None, dict[str, Any]]] = [] + monkeypatch.setattr(click, 'echo', lambda value=None, **kwargs: emitted.append((value, kwargs))) + image = b'x' * 4000 + + OutputMixin.output(cli, itertools.chain(), SQLResult(image=image, image_protocol='kitty')) + + encoded = output_module.base64.b64encode(image).decode('ascii') + assert emitted == [ + (f'\x1b_Ga=T,f=100,m=1;{encoded[:4096]}\x1b\\', {'nl': False}), + (f'\x1b_Gm=0;{encoded[4096:]}\x1b\\', {'nl': False}), + (None, {}), + ] + + def test_get_output_margin_renders_prompt_once_and_counts_status_lines(monkeypatch: pytest.MonkeyPatch) -> None: cli = make_bare_mycli() cli.prompt_lines = 0 diff --git a/test/pytests/test_polars_transform.py b/test/pytests/test_polars_transform.py index 4536c485e..44375173d 100644 --- a/test/pytests/test_polars_transform.py +++ b/test/pytests/test_polars_transform.py @@ -14,7 +14,7 @@ run_polars_transform, ) from mycli.packages.sqlresult import SQLResult -from mycli.types import OutputMode +from mycli.types import ImageProtocol, OutputMode class FakeDataFrame: @@ -68,10 +68,55 @@ class FailingPolars: DataFrame = FailingDataFrame -class FakeAltair: +class FakeTopLevelMixin: pass +class FakeChart(FakeTopLevelMixin): + scale_factors: list[float] = [] + ppis: list[int] = [] + + def __init__(self, _data: FakeDataFrame) -> None: + self.save_calls: list[str] = [] + + def save(self, file: Any, **kwargs: Any) -> None: + self.save_calls.append(kwargs['format']) + self.scale_factors.append(float(kwargs['scale_factor'])) + self.ppis.append(kwargs['ppi']) + file.write(b'png image') + + +class FailingChart(FakeTopLevelMixin): + def save(self, _file: Any, **_kwargs: str) -> None: + raise OSError('renderer failed') + + +class FakeTheme: + enabled: list[str] = [] + + @classmethod + def enable(cls, name: str) -> None: + cls.enabled.append(name) + + +class FakeAltair: + TopLevelMixin = FakeTopLevelMixin + theme = FakeTheme + + @staticmethod + def Chart(data: FakeDataFrame) -> FakeChart: + return FakeChart(data) + + +class FailingAltair: + TopLevelMixin = FakeTopLevelMixin + theme = FakeTheme + + @staticmethod + def Chart(_data: FakeDataFrame) -> FailingChart: + return FailingChart() + + def make_transform(expression: str) -> PolarsTransform: return PolarsTransform( sql='SELECT id FROM orders', @@ -258,6 +303,20 @@ def fail_altair_import(name: str, *args: Any, **kwargs: Any) -> Any: polars_transform._load_altair() +def test_load_vl_convert_reports_missing_dependency(monkeypatch: pytest.MonkeyPatch) -> None: + original_import = __import__ + + def fail_vl_convert_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == 'vl_convert': + raise ImportError('not installed') + return original_import(name, *args, **kwargs) + + monkeypatch.setattr('builtins.__import__', fail_vl_convert_import) + + with pytest.raises(PolarsTransformError, match='requires vl-convert-python'): + polars_transform._load_vl_convert() + + def test_run_polars_transform_materializes_and_renders_returned_dataframe() -> None: result = run_polars_transform( make_transform('df'), @@ -378,6 +437,114 @@ def test_run_polars_transform_reports_non_dataframe_result() -> None: assert result.status_plain == "Nothing could be displayed for return type: " +def test_run_polars_transform_reports_disabled_altair_chart_output() -> None: + result = run_polars_transform( + make_transform('alt.Chart(df)'), + iter([SQLResult(header=['id'], rows=[(1,)])]), + ) + + assert result.status_plain == 'image_protocol is unset in ~/.myclirc. Inline plotting is disabled.' + + +@pytest.mark.parametrize('image_protocol', ['iterm2', 'kitty']) +def test_run_polars_transform_renders_altair_chart_for_image_protocol( + monkeypatch: pytest.MonkeyPatch, + image_protocol: ImageProtocol, +) -> None: + load_calls: list[None] = [] + FakeChart.scale_factors = [] + FakeChart.ppis = [] + FakeTheme.enabled = [] + monkeypatch.setattr(polars_transform, '_load_vl_convert', lambda: load_calls.append(None)) + + result = run_polars_transform( + make_transform('alt.Chart(df)'), + iter([SQLResult(header=['id'], rows=[(1,)])]), + image_protocol=image_protocol, + plot_scale_factor=1.5, + plot_ppi=144, + plot_theme='dark', + ) + + assert load_calls == [None] + assert FakeChart.scale_factors == [1.5] + assert FakeChart.ppis == [144] + assert FakeTheme.enabled == ['dark'] + assert result == SQLResult(image=b'png image', image_protocol=image_protocol) + + +def test_run_polars_transform_uses_integer_default_plot_ppi(monkeypatch: pytest.MonkeyPatch) -> None: + FakeChart.ppis = [] + monkeypatch.setattr(polars_transform, '_load_vl_convert', lambda: None) + + run_polars_transform( + make_transform('alt.Chart(df)'), + iter([SQLResult(header=['id'], rows=[(1,)])]), + image_protocol='kitty', + ) + + assert FakeChart.ppis == [200] + + +def test_run_polars_transform_reports_invalid_altair_theme(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(polars_transform, '_load_vl_convert', lambda: None) + + def fail_enable(_name: str) -> None: + raise ValueError('unknown theme') + + monkeypatch.setattr(FakeTheme, 'enable', fail_enable) + + with pytest.raises(PolarsTransformError, match='Unable to enable Altair plot theme "not-a-theme": ValueError: unknown theme'): + run_polars_transform( + make_transform('alt.Chart(df)'), + iter([SQLResult(header=['id'], rows=[(1,)])]), + image_protocol='kitty', + plot_theme='not-a-theme', + ) + + +def test_run_polars_transform_reports_altair_chart_render_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(polars_transform, '_load_vl_convert', lambda: None) + transform = PolarsTransform( + sql='SELECT id FROM orders', + expression='alt.Chart(df)', + code=compile('alt.Chart(df)', '', 'eval'), + polars=FakePolars, + altair=FailingAltair, + ) + + with pytest.raises(PolarsTransformError, match='Unable to render Altair chart: OSError: renderer failed'): + run_polars_transform( + transform, + iter([SQLResult(header=['id'], rows=[(1,)])]), + image_protocol='iterm2', + ) + + +def test_run_polars_transform_reports_missing_altair_renderer(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_load() -> None: + raise PolarsTransformError('Altair plot rendering requires vl-convert-python. Install mycli[dataframe].') + + monkeypatch.setattr(polars_transform, '_load_vl_convert', fail_load) + + with pytest.raises(PolarsTransformError, match='requires vl-convert-python'): + run_polars_transform( + make_transform('alt.Chart(df)'), + iter([SQLResult(header=['id'], rows=[(1,)])]), + image_protocol='iterm2', + ) + + +def test_run_polars_transform_rejects_altair_chart_parquet_output() -> None: + with pytest.raises(PolarsTransformError, match='must return a DataFrame or Series'): + run_polars_transform( + make_transform('alt.Chart(df)'), + iter([SQLResult(header=['id'], rows=[(1,)])]), + 'chart.parquet', + image_protocol='iterm2', + ) + + def test_prepare_polars_transform_supports_direct_parquet_output(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr('mycli.packages.polars_transform._load_polars', lambda: FakePolars)