Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dependencies = [
"matplotlib",
"pint",
"plotly",
"plotext >= 5, < 6",
"plotext >= 6.0, < 7",
"tikzfigure[vis]>=0.3.0",
]
[project.optional-dependencies]
Expand Down
106 changes: 101 additions & 5 deletions src/maxplotlib/backends/plotext/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-?]*[ -/]*[@-~]")

Expand All @@ -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)
Expand Down
22 changes: 17 additions & 5 deletions src/maxplotlib/subfigure/line_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
154 changes: 154 additions & 0 deletions src/maxplotlib/tests/test_plotext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading
Loading