From f9eb2f4ee5aa304a5ab0723d91122811581bcc5b Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 25 Aug 2026 09:22:45 +0200 Subject: [PATCH 1/2] Update plotext to 6.0 --- docs/source/index.rst | 1 + pyproject.toml | 2 +- src/maxplotlib/backends/plotext/figure.py | 106 +++++++++- src/maxplotlib/subfigure/line_plot.py | 22 ++- src/maxplotlib/tests/test_plotext.py | 154 +++++++++++++++ tutorials/tutorial_16_plotext_advanced.ipynb | 198 +++++++++++++++++++ 6 files changed, 472 insertions(+), 11 deletions(-) create mode 100644 tutorials/tutorial_16_plotext_advanced.ipynb diff --git a/docs/source/index.rst b/docs/source/index.rst index 4eb8d5f..e1bd3ab 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,3 +30,4 @@ documentation for details. tutorials/tutorial_13_advanced_matplotlib tutorials/tutorial_14_axis_and_layout_controls tutorials/tutorial_15_tikzfigure_subplots + tutorials/tutorial_16_plotext_advanced diff --git a/pyproject.toml b/pyproject.toml index 466e18a..7250c97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "matplotlib", "pint", "plotly", - "plotext >= 5, < 6", + "plotext >= 6.0, < 7", "tikzfigure[vis]>=0.3.0", ] [project.optional-dependencies] diff --git a/src/maxplotlib/backends/plotext/figure.py b/src/maxplotlib/backends/plotext/figure.py index be07159..49c00c5 100644 --- a/src/maxplotlib/backends/plotext/figure.py +++ b/src/maxplotlib/backends/plotext/figure.py @@ -2,8 +2,9 @@ import re from pathlib import Path +from typing import Any -from plotext._figure import _figure_class +from plotext import figure as _plotext_figure _ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") @@ -12,20 +13,115 @@ def strip_ansi(text: str) -> str: return _ANSI_ESCAPE_RE.sub("", text) -def create_plotext_figure(nrows: int = 1, ncols: int = 1) -> _figure_class: - figure = _figure_class() +class _Plotext6Axes: + """Small compatibility surface for maxplotlib's plotext renderer.""" + + def __init__(self, figure): + self._figure = figure + + def _draw(self, signal, label=None): + if label is not None: + signal.label(label) + self._figure.draw(signal) + + def plot(self, x, y, **kwargs): + signal = self._figure.signal(x, y, marker=kwargs.get("marker")) + self._draw(signal, kwargs.get("label")) + + scatter = plot + + def bar(self, *args, **kwargs): + label = kwargs.pop("label", None) + kwargs.pop("color", None) + kwargs.pop("fill", None) + signal = self._figure.bar(*args, **kwargs) + self._draw(signal, label) + + def error(self, x, y, *, xerr=None, yerr=None, color=None, label=None): + signal = self._figure.error(x, y, yerr, xerr, pixel=color) + self._draw(signal, label) + + def matrix_plot(self, data, **kwargs): + signal = self._figure.heatmap(data, symbol=kwargs.get("marker")) + self._draw(signal) + + def text(self, label, x, y, **kwargs): + # Plotext 6 keeps text colour in its marker object; the renderer only + # needs the portable text/alignment arguments here. + kwargs.pop("color", None) + signal = self._figure.text(x, y, label, **kwargs) + self._figure.draw(signal) + + def title(self, label): + self._figure.title(label) + + def xlabel(self, label): + self._figure.label(label, axis=0) + + def ylabel(self, label): + self._figure.label(label, axis=1) + + def grid(self, active=True, *_args): + self._figure.ruler("x").grid(active) + self._figure.ruler("y").grid(active) + + def xlim(self, lower=None, upper=None): + self._figure.ruler("x").lim(lower, upper) + + def ylim(self, lower=None, upper=None): + self._figure.ruler("y").lim(lower, upper) + + def xscale(self, scale): + self._figure.ruler("x").scale(scale) + + def yscale(self, scale): + self._figure.ruler("y").scale(scale) + + def xticks(self, positions=None, labels=None): + self._figure.ruler("x").ticks(positions, labels) + + def yticks(self, positions=None, labels=None): + self._figure.ruler("y").ticks(positions, labels) + + def plotsize(self, width=None, height=None): + self._figure.plot_size(width, height) + + def horizontal_line(self, position, **kwargs): + kwargs.pop("color", None) + self._figure.line(position, orientation=0, **kwargs) + + def vertical_line(self, position, **kwargs): + kwargs.pop("color", None) + self._figure.line(position, orientation=1, **kwargs) + + def subplots(self, rows=None, cols=None): + self._figure.subplots(rows, cols) + return self + + def subplot(self, row=None, col=None): + return _Plotext6Axes(self._figure.subplot(row, col)) + + def __getattr__(self, name): + return getattr(self._figure, name) + + +def create_plotext_figure(nrows: int = 1, ncols: int = 1): + # Plotext 6 exposes the figure through its public API rather than the + # removed private ``plotext._figure._figure_class``. It is a singleton, + # so reset it before handing it to maxplotlib as a fresh canvas. + figure = _Plotext6Axes(_plotext_figure.clear()) if nrows > 1 or ncols > 1: figure.subplots(nrows, ncols) return figure class PlotextFigure: - def __init__(self, figure: _figure_class, suptitle: str | None = None): + def __init__(self, figure: Any, suptitle: str | None = None): self.figure = figure self.suptitle = suptitle def build(self, keep_colors: bool = True) -> str: - output = self.figure.build() + output = str(self.figure.build()) if self.suptitle: output = f"{self.suptitle}\n{output}" return output if keep_colors else strip_ansi(output) diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index ca09c72..55eacc6 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -3371,8 +3371,10 @@ def extend_y(values): y = self._transform_y(line["y"]) extend_x(x) extend_y(y) - xerr = self._coerce_numeric_array(line.get("xerr")) - yerr = self._coerce_numeric_array(line.get("yerr")) + xerr = self._plotext_error_values(line.get("xerr"), len(x)) + yerr = self._plotext_error_values(line.get("yerr"), len(y)) + xerr = self._coerce_numeric_array(xerr) + yerr = self._coerce_numeric_array(yerr) if xerr is not None: extend_x(x - xerr) extend_x(x + xerr) @@ -3432,9 +3434,19 @@ def extend_y(values): def _plotext_error_values(self, error, count): if error is None: return None - if np.isscalar(error): - return [float(error)] * count - return np.asarray(error).tolist() + values = np.asarray(error, dtype=float) + if values.ndim == 0: + # Plotext's error() interprets each value as the full bar width, + # while Matplotlib interprets it as the distance from the point + # to one end of a symmetric error bar. + return [float(values) * 2] * count + if values.ndim == 2 and values.shape[0] == 2: + # Matplotlib's asymmetric form is [lower, upper]. Plotext only + # accepts symmetric widths, so preserve the total extent. + values = values[0] + values[1] + else: + values = values * 2 + return values.tolist() def _plotext_ranges(self, layers=None): xs, ys = self._plotext_bounds(layers=layers) diff --git a/src/maxplotlib/tests/test_plotext.py b/src/maxplotlib/tests/test_plotext.py index a43b6fd..9522890 100644 --- a/src/maxplotlib/tests/test_plotext.py +++ b/src/maxplotlib/tests/test_plotext.py @@ -2,6 +2,7 @@ import matplotlib.patches as mpatches import numpy as np +import pytest from maxplotlib import Canvas from maxplotlib.backends.plotext import PlotextFigure @@ -133,3 +134,156 @@ def test_canvas_plot_plotext_supports_colorbar_notes_symlog_aspect_and_generic_p assert "Generic patch" in output assert "ellipse" in output + + +def test_plotext_supports_axis_controls_and_line_primitives(): + canvas, ax = Canvas.subplots() + ax.plot([0, 1], [0, 1], label="line") + ax.hlines([0.25, 0.75], 0, 1, color="yellow") + ax.vlines([0.25, 0.75], 0, 1, color="cyan") + ax.axhline(0.5, color="red") + ax.axvline(0.5, color="blue") + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.set_xticks([0, 0.5, 1], labels=["left", "middle", "right"]) + ax.set_yticks([0, 0.5, 1], labels=["low", "mid", "high"]) + ax.set_title("Axis controls") + ax.set_xlabel("horizontal") + ax.set_ylabel("vertical") + ax.set_grid(True) + + output = strip_ansi(canvas.render(backend="plotext").build()) + + for text in ( + "Axis controls", + "horizontal", + "vertical", + "left", + "middle", + "right", + "low", + "mid", + "high", + ): + assert text in output + + +def test_plotext_supports_multiple_subplots(): + canvas, axes = Canvas.subplots(nrows=1, ncols=2) + axes[0].plot([0, 1], [0, 1]) + axes[0].set_title("left subplot") + axes[1].bar([0, 1], [1, 2]) + axes[1].set_title("right subplot") + + output = strip_ansi(canvas.render(backend="plotext").build()) + + assert "left subplot" in output + assert "right subplot" in output + + +def test_plotext_layer_filtering_changes_rendered_content(): + canvas, ax = Canvas.subplots() + ax.plot([0, 1], [0, 1], label="first layer", layer=0) + ax.plot([0, 1], [1, 0], label="second layer", layer=1) + ax.set_legend(True) + + first = strip_ansi(canvas.render(backend="plotext", layers=[0]).build()) + second = strip_ansi(canvas.render(backend="plotext", layers=[1]).build()) + + assert "first layer" in first + assert "second layer" not in first + assert "second layer" in second + assert "first layer" not in second + + +def test_plotext_savefig_writes_plain_text_and_supports_append(tmp_path): + canvas, ax = Canvas.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_title("Saved output") + output_file = tmp_path / "plot.txt" + + figure = canvas.render(backend="plotext") + figure.savefig(output_file, keep_colors=False) + first = output_file.read_text(encoding="utf-8") + figure.savefig(output_file, append=True, keep_colors=False) + combined = output_file.read_text(encoding="utf-8") + + assert "Saved output" in first + assert "\x1b[" not in first + assert combined == first + first + + +def test_plotext_rejects_unsupported_twinx(): + canvas, ax = Canvas.subplots() + ax.plot([0, 1], [0, 1]) + canvas.twinx() + + with pytest.raises(NotImplementedError, match="twinx"): + canvas.render(backend="plotext") + + +def test_plotext_supports_asymmetric_errors_baseline_fill_and_annotations(): + canvas, ax = Canvas.subplots() + x = np.arange(4) + ax.fill_between(x, [1, 2, 3, 2], 0, label="baseline") + ax.errorbar( + x, + [1, 2, 1, 3], + xerr=[[0.1, 0.2, 0.1, 0.2], [0.2, 0.1, 0.2, 0.1]], + yerr=[[0.2, 0.1, 0.2, 0.1], [0.1, 0.2, 0.1, 0.2]], + label="measurements", + ) + ax.annotate("peak", xy=(3, 3), xytext=(2, 2.5), arrowprops={"color": "red"}) + ax.set_title("Uncertainty and annotations") + ax.set_legend(True) + + output = strip_ansi(canvas.render(backend="plotext").build()) + + assert "Uncertainty and annotations" in output + assert "baseline" in output + assert "measurements" in output + assert "peak" in output + + +def test_plotext_supports_gantt_and_flame_chart_labels(): + canvas, ax = Canvas.subplots() + ax.gantt( + ["plan", "build", "test"], + [0, 1, 3], + [1, 2, 1], + ) + ax.set_title("Gantt") + gantt_output = strip_ansi(canvas.render(backend="plotext").build()) + + canvas, ax = Canvas.subplots() + ax.flame_chart( + labels=["root", "worker", "io"], + parents=[None, 0, 1], + values=[5, 3, 1], + start_times=[0, 0, 2], + ) + ax.set_title("Flame") + flame_output = strip_ansi(canvas.render(backend="plotext").build()) + + assert "Gantt" in gantt_output + assert all(label in gantt_output for label in ("plan", "build", "test")) + assert "Flame" in flame_output + + +def test_plotext_rejects_unsupported_plot_types_and_imshow_options(): + canvas, ax = Canvas.subplots() + ax.hist([1, 2, 2, 3]) + with pytest.raises(NotImplementedError, match="plot type: hist"): + canvas.render(backend="plotext") + + canvas, ax = Canvas.subplots() + ax.add_imshow(np.eye(3), cmap="viridis") + with pytest.raises(NotImplementedError, match="imshow kwargs: cmap"): + canvas.render(backend="plotext") + + +def test_plotext_empty_canvas_still_builds(): + figure = Canvas().render(backend="plotext") + + assert isinstance(figure, PlotextFigure) + assert isinstance(figure.build(keep_colors=False), str) diff --git a/tutorials/tutorial_16_plotext_advanced.ipynb b/tutorials/tutorial_16_plotext_advanced.ipynb new file mode 100644 index 0000000..9996be2 --- /dev/null +++ b/tutorials/tutorial_16_plotext_advanced.ipynb @@ -0,0 +1,198 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 16 - Plotext Advanced Workflows\n", + "\n", + "This tutorial focuses on practical terminal workflows that are easy to miss when moving from Matplotlib to the Plotext backend: layer-by-layer output, subplot dashboards, terminal-safe files, and backend limitations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import matplotlib.patches as patches\n", + "import numpy as np\n", + "\n", + "from maxplotlib import Canvas" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## 1. Build a terminal dashboard\n", + "\n", + "`Canvas.subplots()` works with Plotext too. Keep subplot titles short enough for a terminal and render with `keep_colors=False` when the output will be logged or tested." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, axes = Canvas.subplots(nrows=1, ncols=2)\n", + "x = np.linspace(0, 2 * np.pi, 80)\n", + "\n", + "axes[0].plot(x, np.sin(x), label=\"signal\")\n", + "axes[0].set_title(\"Signal\")\n", + "axes[0].set_grid(True)\n", + "axes[0].set_legend(True)\n", + "\n", + "axes[1].bar([0, 1, 2], [4, 7, 3], label=\"count\")\n", + "axes[1].set_xticks([0, 1, 2], labels=[\"A\", \"B\", \"C\"])\n", + "axes[1].set_title(\"Counts\")\n", + "\n", + "print(canvas.render(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 2. Add uncertainty and annotations\n", + "\n", + "Error bars accept scalar, symmetric-array, and Matplotlib's two-row asymmetric-array forms. Reference lines, text, and annotations are rendered as terminal primitives." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.arange(5)\n", + "y = np.array([1.0, 1.8, 1.3, 2.5, 2.0])\n", + "canvas, ax = Canvas.subplots()\n", + "ax.errorbar(\n", + " x, y,\n", + " yerr=[[0.1] * 5, [0.25] * 5],\n", + " label=\"observations\",\n", + ")\n", + "ax.axhline(y.mean(), color=\"yellow\")\n", + "ax.annotate(\"peak\", xy=(3, 2.5), xytext=(2, 2.8))\n", + "ax.set_title(\"Measurements\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.render(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 3. Render layers incrementally\n", + "\n", + "Layers are useful for progress reports and debugging. Pass a list to `render(..., layers=[...])`, or use `savefig(..., layer_by_layer=True)` to write successive text files." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, y, label=\"raw\", layer=0)\n", + "ax.plot(x, np.maximum.accumulate(y), label=\"running max\", layer=1)\n", + "ax.set_title(\"Layered diagnostics\")\n", + "ax.set_legend(True)\n", + "\n", + "for selected_layers in ([0], [0, 1]):\n", + " text = canvas.render(backend=\"plotext\", layers=selected_layers).build(\n", + " keep_colors=False\n", + " )\n", + " print(f\"--- layers={selected_layers} ---\")\n", + " print(text)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## 4. Save terminal output\n", + "\n", + "Plotext output is text, not an image. Saving without ANSI colors makes the file portable to CI logs, issue trackers, and plain-text artifacts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "output_path = Path(\"plotext-output.txt\")\n", + "figure = canvas.render(backend=\"plotext\")\n", + "figure.savefig(output_path, keep_colors=False)\n", + "print(f\"wrote {output_path}\")" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 5. Matrix plots and patches\n", + "\n", + "Matrix data is displayed with Plotext's heatmap primitive. Common Matplotlib patches are approximated by their polygon outline, which is useful for lightweight terminal diagnostics." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.add_imshow(np.arange(16).reshape(4, 4))\n", + "ax.add_patch(patches.Rectangle((0.5, 0.5), 2, 2, fill=False, label=\"window\"))\n", + "ax.add_colorbar(label=\"intensity\")\n", + "ax.set_title(\"Matrix diagnostic\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.render(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## Current limitations\n", + "\n", + "The backend intentionally raises `NotImplementedError` for plot types that do not have a faithful Plotext 6 equivalent, such as histograms, pie charts, stem plots, and secondary/twin axes. Use Matplotlib or Plotly for those cases." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From caa616efc2e5032cfa296c8544e0c3235d8cd232 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 25 Aug 2026 09:22:57 +0200 Subject: [PATCH 2/2] formatting --- tutorials/tutorial_16_plotext_advanced.ipynb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tutorials/tutorial_16_plotext_advanced.ipynb b/tutorials/tutorial_16_plotext_advanced.ipynb index 9996be2..969571a 100644 --- a/tutorials/tutorial_16_plotext_advanced.ipynb +++ b/tutorials/tutorial_16_plotext_advanced.ipynb @@ -78,7 +78,8 @@ "y = np.array([1.0, 1.8, 1.3, 2.5, 2.0])\n", "canvas, ax = Canvas.subplots()\n", "ax.errorbar(\n", - " x, y,\n", + " x,\n", + " y,\n", " yerr=[[0.1] * 5, [0.25] * 5],\n", " label=\"observations\",\n", ")\n",