Skip to content

Commit 0c0392f

Browse files
committed
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.
1 parent 8e83b22 commit 0c0392f

19 files changed

Lines changed: 592 additions & 27 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Features
7171
* Pretty print tabular data (with colors!).
7272
* Support for SSL connections
7373
* Shell-style trailing redirects with `$>`, `$>>` and `$|` operators.
74-
* [Polars](https://pola.rs) dataframe [transforms](doc/transforms.md) with `.|` and Parquet saves with `.>`.
74+
* [Polars](https://pola.rs) dataframe [transforms and plots](doc/transforms.md) with `.|`, and Parquet saves with `.>`.
7575
* Support for querying LLMs with context derived from your schema using `/llm`.
7676
* Support for storing passwords in the system keyring.
7777

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Features
55
---------
66
* Subcommand completions for the `/dsn` command.
77
* Allow file target of `$>` redirection to be quoted.
8+
* Display of inline plots returned from `.|` operations.
89

910

1011
Bug Fixes
242 KB
Loading

doc/transforms.md

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Transforms with Polars Dataframes
1+
# Transforms, Plots, and Parquets with Polars Dataframes
22

33
## Installing
44

@@ -8,19 +8,24 @@ Install mycli with dataframe support using:
88
pip install --upgrade 'mycli[dataframe]'
99
```
1010

11-
or install the Polars and Altair libraries separately.
11+
or install these libraries separately:
12+
13+
* [`polars`](https://pypi.org/project/polars/)
14+
* [`altair`](https://pypi.org/project/altair/)
15+
* [`vl-convert-python`](https://pypi.org/project/vl-convert-python/)
1216

1317
## BETA STATUS
1418

15-
Dataframe transforms are new and experimental. The interface and functionality
16-
may still change.
19+
Dataframe transforms and plots are new and experimental. The interface and
20+
functionality may still change.
1721

1822
Here are some known limitations:
1923

20-
* transforms can't be mixed with `$|` shell redirection
24+
* transforms can't be composed with `$|` shell redirection
2125
* multiple transform steps are not permitted
22-
* the Altair library is provided, but plots can't yet be displayed
2326
* results from `UNION`s may be unable to be transformed
27+
* images cannot yet be saved
28+
* PNG images are static and do not support all Altair features
2429

2530
And there are inherent limitations to the post-processing model: the entire
2631
SQL result must be transferred from the server and loaded into local memory.
@@ -34,7 +39,7 @@ single SQL statement. The Python expression receives
3439
* `pl`, the Polars module
3540
* `alt`, the Altair module
3641

37-
Spaces may be required around the operator.
42+
Spaces may be required around the `.|` operator.
3843

3944
Transform example:
4045

@@ -51,20 +56,34 @@ SELECT customer_id, COUNT(1) AS len FROM orders GROUP BY customer_id;
5156
Transform expressions run with normal Python privileges, and expressions
5257
should not be run from untrusted sources. If the transform operation
5358
returns a Polars `DataFrame` or `Series`, the result is rendered by mycli
54-
as tabular output; other return types currently give a warning and cannot
55-
be displayed.
59+
as tabular output. Most other return types will be silently ignored.
5660

5761
Transform expressions are useful for operations such as medians which
58-
cannot be done (or are simply awkward) in SQL. Example:
62+
cannot be done (or are awkward) in SQL. Example:
5963

6064
```sql
6165
SELECT * FROM orders .| df.describe();
6266
```
6367

68+
## Plotting
69+
70+
If the dataframe transform operation returns an Altair plot, the result can
71+
be rendered as an inline PNG in many terminals.
72+
73+
Example:
74+
75+
```sql
76+
SELECT * FROM orders .| df['total'].plot.hist();
77+
```
78+
![histogram](https://raw.githubusercontent.com/dbcli/mycli/main/doc/screenshots/total_histogram.png)
79+
80+
Image size, display protocol, and other properties can be configured in
81+
the `[dataframe]` section of `~/.myclirc`.
82+
6483
## Saving
6584

6685
A query result, transformed `DataFrame`, or transformed `Series` can be
67-
written directly to Parquet with the `.>` operator.
86+
written directly to a Parquet file with the `.>` operator.
6887

6988
Save example:
7089

mycli/app_state.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from configobj import ConfigObj
77

88
from mycli.config import strip_matching_quotes
9+
from mycli.types import ImageProtocol
910

1011
if TYPE_CHECKING:
1112
from mycli.client import MyCli
@@ -46,6 +47,16 @@ def normalize_ssl_mode(
4647
return ssl_mode, error_notice
4748

4849

50+
def normalize_image_protocol(image_protocol: str | None) -> tuple[ImageProtocol, str | None]:
51+
if image_protocol == 'iterm2':
52+
return 'iterm2', None
53+
if image_protocol == 'kitty':
54+
return 'kitty', None
55+
if image_protocol in ('none', '', None):
56+
return 'none', None
57+
return 'none', f'Invalid config option provided for image_protocol ({image_protocol}); disabling.'
58+
59+
4960
def configure_prompt_state(
5061
mycli: MyCli,
5162
config: ConfigObj,

mycli/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
configure_prompt_state,
1818
destructive_keywords_from_config,
1919
llm_prompt_truncation,
20+
normalize_image_protocol,
2021
normalize_ssl_mode,
2122
)
2223
from mycli.client_commands import ClientCommandsMixin
@@ -145,6 +146,12 @@ def __init__(
145146
self.null_string = c['main'].get('null_string')
146147
self.numeric_alignment = c['main'].get('numeric_alignment', 'right') or 'right'
147148
self.binary_display = c['main'].get('binary_display')
149+
self.image_protocol, image_protocol_error = normalize_image_protocol(c['dataframe'].get('image_protocol'))
150+
if image_protocol_error:
151+
self.echo(image_protocol_error, err=True, fg='red')
152+
self.plot_scale_factor = c['dataframe'].as_float('plot_scale_factor')
153+
self.plot_ppi = c['dataframe'].as_int('plot_ppi')
154+
self.plot_theme = c['dataframe'].get('plot_theme', 'carbong90') or 'carbong90'
148155
self.llm_prompt_field_truncate, self.llm_prompt_section_truncate = llm_prompt_truncation(c)
149156

150157
self.ssl_mode, ssl_mode_error = normalize_ssl_mode(c, self.config_without_package_defaults)

mycli/main_modes/repl.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -751,9 +751,24 @@ def _one_iteration(
751751
if polars_transform is not None:
752752
assert polars_pipeline is not None
753753
if polars_pipeline.parquet_path is None:
754-
polars_result = run_polars_transform(polars_transform, results)
754+
polars_result = run_polars_transform(
755+
polars_transform,
756+
results,
757+
image_protocol=mycli.image_protocol,
758+
plot_scale_factor=mycli.plot_scale_factor,
759+
plot_ppi=mycli.plot_ppi,
760+
plot_theme=mycli.plot_theme,
761+
)
755762
else:
756-
polars_result = run_polars_transform(polars_transform, results, polars_pipeline.parquet_path)
763+
polars_result = run_polars_transform(
764+
polars_transform,
765+
results,
766+
polars_pipeline.parquet_path,
767+
image_protocol=mycli.image_protocol,
768+
plot_scale_factor=mycli.plot_scale_factor,
769+
plot_ppi=mycli.plot_ppi,
770+
plot_theme=mycli.plot_theme,
771+
)
757772
if polars_pipeline.parquet_path is None:
758773
if polars_pipeline.output_mode == 'explorer':
759774
special.set_explorer_output(True)

mycli/myclirc

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,27 @@ format = mysql_unicode
265265
# Whether to remove the last line from the formatted output.
266266
trim_footer = False
267267

268+
[dataframe]
269+
270+
# How to display inline image results in a terminal emulator. Possible values:
271+
# * kitty
272+
# * iterm2
273+
# empty to disable
274+
image_protocol = kitty
275+
276+
# PNG plot resolution in pixels per inch. Must be a positive integer.
277+
# The apparent size is a combination of plot_ppi and plot_scale_factor.
278+
plot_ppi = 200
279+
280+
# PNG plot resolution multiplier. Must be a positive number.
281+
# The apparent size is a combination of plot_ppi and plot_scale_factor.
282+
plot_scale_factor = 1.0
283+
284+
# Altair theme for rendering plots. Examples: carbong90, dark, default.
285+
# Available themes depend on the installed libraries. See
286+
# https://github.com/vega/vega-themes/#included-themes
287+
plot_theme = carbong90
288+
268289
[search]
269290

270291
# Whether to apply syntax highlighting to the preview window in fuzzy history

mycli/output.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import base64
34
from datetime import datetime
45
from decimal import Decimal
56
from io import TextIOWrapper
@@ -115,6 +116,13 @@ def output(
115116
is_warnings_style: bool = False,
116117
) -> None:
117118
"""Output text to stdout or a pager command."""
119+
if result.image is not None:
120+
if result.image_protocol == 'iterm2':
121+
click.secho('')
122+
self.output_iterm2_image(result.image)
123+
elif result.image_protocol == 'kitty':
124+
click.secho('')
125+
self.output_kitty_image(result.image)
118126
if output:
119127
if self.prompt_session is not None:
120128
size = self.prompt_session.output.get_size()
@@ -186,6 +194,22 @@ def newlinewrapper(text: list[str]) -> Generator[str, None, None]:
186194
styled_status = to_formatted_text(status, style=add_style)
187195
prompt_toolkit.print_formatted_text(styled_status, style=self.ptoolkit_style)
188196

197+
def output_iterm2_image(self, image: bytes) -> None:
198+
"""Emit a PNG using the iTerm2 inline image protocol."""
199+
filename = base64.b64encode(b'chart.png').decode('ascii')
200+
contents = base64.b64encode(image).decode('ascii')
201+
click.echo(f'\x1b]1337;File=name={filename};size={len(image)};width=auto;height=auto;preserveAspectRatio=1;inline=1:{contents}\x07')
202+
203+
def output_kitty_image(self, image: bytes) -> None:
204+
"""Emit a PNG using Kitty's direct graphics protocol."""
205+
contents = base64.b64encode(image).decode('ascii')
206+
chunks = [contents[index : index + 4096] for index in range(0, len(contents), 4096)] or ['']
207+
for index, chunk in enumerate(chunks):
208+
action = 'a=T,f=100,' if index == 0 else ''
209+
more = 1 if index < len(chunks) - 1 else 0
210+
click.echo(f'\x1b_G{action}m={more};{chunk}\x1b\\', nl=False)
211+
click.echo()
212+
189213
def configure_pager(self) -> None:
190214
if not os.environ.get("LESS"):
191215
os.environ["LESS"] = "-RXF"

mycli/packages/polars_transform.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import builtins
44
from dataclasses import dataclass
5+
from io import BytesIO
56
from types import CodeType
67
from typing import Any, Iterable
78

89
import sqlglot
910

1011
from mycli.packages.special.delimitercommand import DelimiterCommand
1112
from mycli.packages.sqlresult import SQLResult
12-
from mycli.types import OutputMode
13+
from mycli.types import ImageProtocol, OutputMode
1314

1415
delimiter_command = DelimiterCommand()
1516

@@ -169,10 +170,22 @@ def _load_altair() -> Any:
169170
return alt
170171

171172

173+
def _load_vl_convert() -> None:
174+
try:
175+
import vl_convert # noqa: F401
176+
except ImportError as exc:
177+
raise PolarsTransformError('Altair plot rendering requires vl-convert-python. Install mycli[dataframe].') from exc
178+
179+
172180
def run_polars_transform(
173181
transform: PolarsTransform,
174182
results: Iterable[SQLResult],
175183
parquet_path: str | None = None,
184+
*,
185+
image_protocol: ImageProtocol = 'none',
186+
plot_scale_factor: float = 1.0,
187+
plot_ppi: int = 200,
188+
plot_theme: str = 'carbong90',
176189
) -> SQLResult:
177190
iterator = iter(results)
178191
try:
@@ -215,6 +228,27 @@ def run_polars_transform(
215228
raise PolarsTransformError(f'Unable to write Parquet file "{parquet_path}": {type(exc).__name__}: {exc}') from exc
216229
return SQLResult(status=f'Wrote {len(series_dataframe)} rows to {parquet_path}.')
217230
return SQLResult(header=[column_name], rows=[(item,) for item in value])
231+
if transform.altair is not None and isinstance(value, transform.altair.TopLevelMixin):
232+
if parquet_path is not None:
233+
raise PolarsTransformError('Polars transforms must return a DataFrame or Series before writing Parquet output.')
234+
if image_protocol == 'none':
235+
return SQLResult(status='image_protocol is unset in ~/.myclirc. Inline plotting is disabled.')
236+
_load_vl_convert()
237+
png = BytesIO()
238+
try:
239+
transform.altair.theme.enable(plot_theme)
240+
except Exception as exc:
241+
raise PolarsTransformError(f'Unable to enable Altair plot theme "{plot_theme}": {type(exc).__name__}: {exc}') from exc
242+
try:
243+
value.save(
244+
png,
245+
format='png',
246+
scale_factor=plot_scale_factor,
247+
ppi=plot_ppi,
248+
)
249+
except Exception as exc:
250+
raise PolarsTransformError(f'Unable to render Altair chart: {type(exc).__name__}: {exc}') from exc
251+
return SQLResult(image=png.getvalue(), image_protocol=image_protocol)
218252
if parquet_path is not None:
219253
raise PolarsTransformError('Polars transforms must return a DataFrame or Series before writing Parquet output.')
220254
return SQLResult(status=f'Nothing could be displayed for return type: {type(value)}')

0 commit comments

Comments
 (0)