plotlive 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plotlive/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ plotlive — Interactive matplotlib-compatible graphs rendered with pygame.
3
+
4
+ Usage:
5
+ import plotlive.pyplot as plt
6
+ import numpy as np
7
+
8
+ plt.plot([1, 2, 3], [4, 5, 6], label='data')
9
+ plt.legend()
10
+ plt.show()
11
+ """
12
+ from .figure import Figure
13
+ from .axes import Axes
14
+ from . import pyplot
15
+ from .pyplot import show, figure, subplots
16
+
17
+ __all__ = ['Figure', 'Axes', 'pyplot', 'show', 'figure', 'subplots']
18
+ __version__ = '0.1.0'
plotlive/_jupyter.py ADDED
@@ -0,0 +1,127 @@
1
+ """
2
+ plotlive._jupyter — inline display helpers for Jupyter notebooks.
3
+
4
+ This module is imported lazily inside pyplot.show() only when a Jupyter
5
+ kernel is detected, so it never affects the normal pygame path.
6
+ """
7
+ from __future__ import annotations
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from .figure import Figure
12
+
13
+
14
+ def is_jupyter() -> bool:
15
+ """Return True when running inside a Jupyter notebook (not terminal IPython)."""
16
+ try:
17
+ from IPython import get_ipython # type: ignore[import]
18
+ ip = get_ipython()
19
+ return ip is not None and type(ip).__name__ == 'ZMQInteractiveShell'
20
+ except Exception:
21
+ return False
22
+
23
+
24
+ def show_figure(fig: Figure) -> None:
25
+ """Render figure as PNG and display it inline via IPython."""
26
+ import pygame
27
+ import tempfile
28
+ import os
29
+ from IPython.display import display, Image # type: ignore[import]
30
+ from .renderer import FigureRenderer
31
+
32
+ if not pygame.get_init():
33
+ pygame.init()
34
+
35
+ surf = FigureRenderer(fig).render()
36
+ tmp = None
37
+ try:
38
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
39
+ tmp = f.name
40
+ pygame.image.save(surf, tmp)
41
+ with open(tmp, 'rb') as f:
42
+ png_data = f.read()
43
+ finally:
44
+ if tmp:
45
+ try:
46
+ os.unlink(tmp)
47
+ except OSError:
48
+ pass
49
+ display(Image(data=png_data, format='png'))
50
+
51
+
52
+ def show_animation(fig: Figure, set_current_fig) -> None:
53
+ """Export animation as GIF (or MP4 fallback) and display it inline via IPython.
54
+
55
+ set_current_fig is a callable(fig) that updates pyplot's global state so
56
+ that gcf()/gca() resolve correctly inside state-machine update functions.
57
+ """
58
+ import pygame
59
+ import tempfile
60
+ import os
61
+ from IPython.display import display # type: ignore[import]
62
+
63
+ if not pygame.get_init():
64
+ pygame.init()
65
+
66
+ # Keep gcf()/gca() pointing at this figure while frames are rendered
67
+ set_current_fig(fig)
68
+
69
+ anim = fig._animation
70
+ if anim is None:
71
+ return
72
+
73
+ # --- GIF via Pillow (preferred: self-contained, works everywhere) ---
74
+ try:
75
+ import PIL # noqa: F401 — availability check only
76
+ from IPython.display import Image # type: ignore[import]
77
+ tmp = None
78
+ try:
79
+ with tempfile.NamedTemporaryFile(suffix='.gif', delete=False) as f:
80
+ tmp = f.name
81
+ anim.save(tmp, writer='pillow')
82
+ with open(tmp, 'rb') as f:
83
+ gif_data = f.read()
84
+ finally:
85
+ if tmp:
86
+ try:
87
+ os.unlink(tmp)
88
+ except OSError:
89
+ pass
90
+ display(Image(data=gif_data, format='gif'))
91
+ return
92
+ except ImportError:
93
+ pass
94
+
95
+ # --- MP4 via imageio + ffmpeg (fallback) ---
96
+ try:
97
+ import imageio # noqa: F401
98
+ tmp = None
99
+ try:
100
+ with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
101
+ tmp = f.name
102
+ anim.save(tmp, writer='ffmpeg')
103
+ with open(tmp, 'rb') as f:
104
+ video_data = f.read()
105
+ finally:
106
+ if tmp:
107
+ try:
108
+ os.unlink(tmp)
109
+ except OSError:
110
+ pass
111
+ import base64
112
+ from IPython.display import HTML # type: ignore[import]
113
+ b64 = base64.b64encode(video_data).decode('ascii')
114
+ display(HTML(
115
+ f'<video controls style="max-width:100%">'
116
+ f'<source type="video/mp4" src="data:video/mp4;base64,{b64}">'
117
+ f'</video>'
118
+ ))
119
+ return
120
+ except (ImportError, Exception):
121
+ pass
122
+
123
+ # --- Last resort: first frame as static image ---
124
+ anim._call_func(0)
125
+ for ax in fig.axes:
126
+ ax._dirty = True
127
+ show_figure(fig)
plotlive/_parsers.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+
4
+ _MARKERS = set('os^v<>Dp+x.*phH1234')
5
+ # Order matters: parse multi-char linestyles before single-char
6
+ _LINESTYLES = ['-.', '--', '-', ':']
7
+ _LS_MARKERS = set('-') # chars that could be either linestyle or marker start
8
+
9
+
10
+ def _parse_fmt(fmt: str) -> dict:
11
+ """
12
+ Parse matplotlib format string like 'b--o' into property dict.
13
+ Returns subset of: color, linestyle, marker.
14
+ """
15
+ if not fmt:
16
+ return {}
17
+
18
+ result: dict = {}
19
+ s = fmt
20
+
21
+ # Extract linestyle first (multi-char before single-char)
22
+ for ls in _LINESTYLES:
23
+ if ls in s:
24
+ result['linestyle'] = ls
25
+ s = s.replace(ls, '', 1)
26
+ break
27
+
28
+ # Remaining characters: color and/or marker
29
+ color_chars = set('brgykwmc')
30
+ for ch in list(s):
31
+ if ch in color_chars and 'color' not in result:
32
+ result['color'] = ch
33
+ elif ch in _MARKERS and 'marker' not in result:
34
+ result['marker'] = ch
35
+
36
+ return result
37
+
38
+
39
+ def _parse_plot_args(args: tuple) -> list[tuple[np.ndarray, np.ndarray, str]]:
40
+ """
41
+ Parse positional args to plot() into list of (x, y, fmt) tuples.
42
+
43
+ Patterns:
44
+ (y,) -> (arange(len(y)), y, '')
45
+ (y, fmt) -> (arange(len(y)), y, fmt) if fmt is str
46
+ (x, y) -> (x, y, '')
47
+ (x, y, fmt) -> (x, y, fmt)
48
+ (x, y, fmt, x2, y2, fmt2, ...) -> multiple
49
+ """
50
+ segments: list[tuple[np.ndarray, np.ndarray, str]] = []
51
+ i = 0
52
+ args_list = list(args)
53
+
54
+ while i < len(args_list):
55
+ a0 = args_list[i]
56
+
57
+ # Detect string (format) at position i
58
+ if isinstance(a0, str):
59
+ raise ValueError(f"Unexpected string argument at position {i}: {a0!r}")
60
+
61
+ x0 = np.asarray(a0, dtype=float)
62
+
63
+ if i + 1 >= len(args_list):
64
+ # Only one array: treat as y
65
+ segments.append((np.arange(len(x0), dtype=float), x0, ''))
66
+ i += 1
67
+ continue
68
+
69
+ a1 = args_list[i + 1]
70
+
71
+ if isinstance(a1, str):
72
+ # (y, fmt)
73
+ segments.append((np.arange(len(x0), dtype=float), x0, a1))
74
+ i += 2
75
+ continue
76
+
77
+ # a1 is array-like: (x, y, ...)
78
+ y0 = np.asarray(a1, dtype=float)
79
+
80
+ if i + 2 < len(args_list) and isinstance(args_list[i + 2], str):
81
+ fmt = args_list[i + 2]
82
+ segments.append((x0, y0, fmt))
83
+ i += 3
84
+ else:
85
+ segments.append((x0, y0, ''))
86
+ i += 2
87
+
88
+ return segments
plotlive/animation.py ADDED
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Callable
5
+
6
+ _ANIMATION_EVENT_ID = None
7
+
8
+
9
+ def _get_animation_event():
10
+ global _ANIMATION_EVENT_ID
11
+ if _ANIMATION_EVENT_ID is None:
12
+ import pygame
13
+ _ANIMATION_EVENT_ID = pygame.USEREVENT + 1
14
+ return _ANIMATION_EVENT_ID
15
+
16
+
17
+ # ------------------------------------------------------------------
18
+ # Export helpers
19
+ # ------------------------------------------------------------------
20
+
21
+ def _surf_to_array(surf):
22
+ """pygame Surface → (H, W, 3) uint8 numpy array."""
23
+ import pygame
24
+ import numpy as np
25
+ arr = pygame.surfarray.array3d(surf) # (W, H, 3)
26
+ return np.ascontiguousarray(arr.transpose(1, 0, 2)) # (H, W, 3)
27
+
28
+
29
+ def _save_gif(arrays: list, filename: str, fps: int) -> None:
30
+ try:
31
+ from PIL import Image
32
+ except ImportError:
33
+ raise ImportError(
34
+ 'GIF export requires Pillow:\n'
35
+ ' pip install Pillow'
36
+ )
37
+ duration_ms = max(20, int(1000 / fps))
38
+ imgs = [Image.fromarray(arr) for arr in arrays]
39
+ imgs[0].save(
40
+ filename,
41
+ save_all=True,
42
+ append_images=imgs[1:],
43
+ loop=0,
44
+ duration=duration_ms,
45
+ optimize=False,
46
+ )
47
+
48
+
49
+ def _save_video(arrays: list, filename: str, fps: int) -> None:
50
+ try:
51
+ import imageio
52
+ except ImportError:
53
+ raise ImportError(
54
+ 'Video export requires imageio:\n'
55
+ ' pip install imageio[ffmpeg]'
56
+ )
57
+ with imageio.get_writer(filename, fps=fps) as writer:
58
+ for arr in arrays:
59
+ writer.append_data(arr)
60
+
61
+
62
+ def _resolve_backend(filename: str, writer) -> str:
63
+ """Return 'gif' or 'video', inferring from writer name and file extension."""
64
+ ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
65
+ if isinstance(writer, str):
66
+ if writer == 'pillow':
67
+ return 'gif'
68
+ if writer in ('ffmpeg', 'ffmpeg_file', 'imageio'):
69
+ return 'video'
70
+ if ext == 'gif':
71
+ return 'gif'
72
+ if ext in ('mp4', 'mov', 'avi', 'webm'):
73
+ return 'video'
74
+ raise ValueError(
75
+ f'Unknown format ".{ext}". '
76
+ 'Supported extensions: .gif .mp4 .mov .avi'
77
+ )
78
+
79
+
80
+ def _resolve_frames(frames, save_count) -> list:
81
+ """Convert matplotlib-style frames arg to a concrete list of frame values."""
82
+ if frames is None:
83
+ n = save_count if save_count is not None else 100
84
+ return list(range(n))
85
+ if isinstance(frames, int):
86
+ return list(range(frames))
87
+ if inspect.isgeneratorfunction(frames):
88
+ gen = frames()
89
+ if save_count is not None:
90
+ return [next(gen) for _ in range(save_count)]
91
+ return list(gen)
92
+ return list(frames)
93
+
94
+
95
+ class FuncAnimation:
96
+ """
97
+ matplotlib-compatible animation driven by a user-supplied function.
98
+
99
+ Matches the ``matplotlib.animation.FuncAnimation`` interface exactly::
100
+
101
+ from plotlive.animation import FuncAnimation
102
+ anim = FuncAnimation(fig, update, frames=50, interval=200)
103
+ anim.save('output.gif')
104
+
105
+ Animations start **paused** — press Space to play, ←/→ to step.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ fig,
111
+ func: Callable,
112
+ frames=None,
113
+ init_func=None,
114
+ fargs=None,
115
+ save_count: int | None = None,
116
+ *,
117
+ cache_frame_data: bool = True,
118
+ interval: int = 200,
119
+ repeat_delay: int = 0,
120
+ repeat: bool = True,
121
+ blit: bool = False,
122
+ ):
123
+ self.figure = fig
124
+ self._func = func
125
+ self._fargs = tuple(fargs) if fargs is not None else ()
126
+ self.interval = interval
127
+ self.repeat = repeat
128
+
129
+ self._frame_seq = _resolve_frames(frames, save_count)
130
+ self.frames = len(self._frame_seq)
131
+
132
+ self._displayed_frame: int = -1
133
+ self._next_frame: int = 0
134
+ self._playing: bool = False
135
+ self._finished: bool = False
136
+
137
+ def _call_func(self, i: int) -> None:
138
+ frame = self._frame_seq[i]
139
+ if self._fargs:
140
+ self._func(frame, *self._fargs)
141
+ else:
142
+ self._func(frame)
143
+
144
+ @property
145
+ def event_id(self) -> int:
146
+ return _get_animation_event()
147
+
148
+ # ------------------------------------------------------------------
149
+ # Playback control
150
+ # ------------------------------------------------------------------
151
+
152
+ def start(self) -> None:
153
+ import pygame
154
+ self._playing = True
155
+ self._finished = False
156
+ pygame.time.set_timer(self.event_id, self.interval)
157
+
158
+ def pause(self) -> None:
159
+ import pygame
160
+ self._playing = False
161
+ pygame.time.set_timer(self.event_id, 0)
162
+
163
+ def resume(self) -> None:
164
+ if not self._finished:
165
+ self.start()
166
+
167
+ def toggle(self) -> None:
168
+ if self._playing:
169
+ self.pause()
170
+ else:
171
+ self.resume()
172
+
173
+ # ------------------------------------------------------------------
174
+ # Frame rendering
175
+ # ------------------------------------------------------------------
176
+
177
+ def _show_frame(self, n: int) -> bool:
178
+ n = max(0, min(n, self.frames - 1))
179
+ self._call_func(n)
180
+ self._displayed_frame = n
181
+ self._next_frame = n + 1
182
+ for ax in self.figure.axes:
183
+ ax._dirty = True
184
+ return True
185
+
186
+ def step_forward(self) -> bool:
187
+ next_n = self._displayed_frame + 1
188
+ if next_n >= self.frames:
189
+ return False
190
+ return self._show_frame(next_n)
191
+
192
+ def step_back(self) -> bool:
193
+ prev_n = max(0, self._displayed_frame - 1) if self._displayed_frame >= 0 else 0
194
+ return self._show_frame(prev_n)
195
+
196
+ # ------------------------------------------------------------------
197
+ # Export — matches matplotlib's Animation.save() signature
198
+ # ------------------------------------------------------------------
199
+
200
+ def save(
201
+ self,
202
+ filename: str,
203
+ writer=None,
204
+ fps: int | None = None,
205
+ dpi: float | None = None,
206
+ codec: str | None = None,
207
+ bitrate: int | None = None,
208
+ extra_args=None,
209
+ metadata: dict | None = None,
210
+ extra_anim=None,
211
+ savefig_kwargs: dict | None = None,
212
+ *,
213
+ progress_callback=None,
214
+ ) -> None:
215
+ """
216
+ Render all frames off-screen and write to *filename*.
217
+
218
+ Matches ``matplotlib.animation.Animation.save()``::
219
+
220
+ anim.save('output.gif', writer='pillow', fps=10)
221
+ anim.save('output.mp4', writer='ffmpeg', fps=30)
222
+ anim.save('output.gif') # writer and fps inferred automatically
223
+
224
+ Requirements:
225
+ GIF — pip install Pillow
226
+ Video — pip install imageio[ffmpeg]
227
+ """
228
+ import pygame
229
+ from .renderer import FigureRenderer
230
+
231
+ if not pygame.get_init():
232
+ pygame.init()
233
+
234
+ backend = _resolve_backend(filename, writer) # raises early for bad ext
235
+ effective_fps = fps if fps is not None else max(1, 1000 // self.interval)
236
+
237
+ print(f'Exporting {self.frames} frames → {filename}')
238
+ arrays = []
239
+ _step = max(1, self.frames // 10)
240
+ for i in range(self.frames):
241
+ self._call_func(i)
242
+ surf = FigureRenderer(self.figure).render()
243
+ arrays.append(_surf_to_array(surf))
244
+ if progress_callback is not None:
245
+ progress_callback(i + 1, self.frames)
246
+ if (i + 1) % _step == 0 or i == self.frames - 1:
247
+ print(f' {i + 1}/{self.frames}')
248
+
249
+ self._call_func(0) # restore to frame 0 so show() opens at the start
250
+
251
+ if backend == 'gif':
252
+ _save_gif(arrays, filename, effective_fps)
253
+ else:
254
+ _save_video(arrays, filename, effective_fps)
255
+ print(f'Saved → {filename}')
256
+
257
+ # ------------------------------------------------------------------
258
+ # Timer callback (called by the pygame event loop in pyplot.show())
259
+ # ------------------------------------------------------------------
260
+
261
+ def _on_timer(self) -> bool:
262
+ if not self._playing or self._finished:
263
+ return False
264
+
265
+ self._show_frame(self._next_frame)
266
+
267
+ if self._next_frame >= self.frames:
268
+ if self.repeat:
269
+ self._next_frame = 0
270
+ self._displayed_frame = -1
271
+ else:
272
+ self._finished = True
273
+ self.pause()
274
+
275
+ return True
276
+
277
+
278
+ # Alias so existing code using Animation still works
279
+ Animation = FuncAnimation