spectra-plot 0.2.0__tar.gz

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.
Files changed (41) hide show
  1. spectra_plot-0.2.0/MANIFEST.in +1 -0
  2. spectra_plot-0.2.0/PKG-INFO +22 -0
  3. spectra_plot-0.2.0/VERSION +1 -0
  4. spectra_plot-0.2.0/pyproject.toml +34 -0
  5. spectra_plot-0.2.0/setup.cfg +4 -0
  6. spectra_plot-0.2.0/spectra/__init__.py +147 -0
  7. spectra_plot-0.2.0/spectra/_animation.py +180 -0
  8. spectra_plot-0.2.0/spectra/_axes.py +275 -0
  9. spectra_plot-0.2.0/spectra/_blob.py +153 -0
  10. spectra_plot-0.2.0/spectra/_cli.py +23 -0
  11. spectra_plot-0.2.0/spectra/_codec.py +545 -0
  12. spectra_plot-0.2.0/spectra/_easy.py +1213 -0
  13. spectra_plot-0.2.0/spectra/_embed.py +504 -0
  14. spectra_plot-0.2.0/spectra/_errors.py +34 -0
  15. spectra_plot-0.2.0/spectra/_figure.py +132 -0
  16. spectra_plot-0.2.0/spectra/_launcher.py +149 -0
  17. spectra_plot-0.2.0/spectra/_log.py +62 -0
  18. spectra_plot-0.2.0/spectra/_persistence.py +111 -0
  19. spectra_plot-0.2.0/spectra/_protocol.py +143 -0
  20. spectra_plot-0.2.0/spectra/_series.py +276 -0
  21. spectra_plot-0.2.0/spectra/_session.py +387 -0
  22. spectra_plot-0.2.0/spectra/_transport.py +137 -0
  23. spectra_plot-0.2.0/spectra/backends/__init__.py +13 -0
  24. spectra_plot-0.2.0/spectra/backends/_qt_compat.py +237 -0
  25. spectra_plot-0.2.0/spectra/backends/backend_qtagg.py +898 -0
  26. spectra_plot-0.2.0/spectra/embed.py +446 -0
  27. spectra_plot-0.2.0/spectra_plot.egg-info/PKG-INFO +22 -0
  28. spectra_plot-0.2.0/spectra_plot.egg-info/SOURCES.txt +39 -0
  29. spectra_plot-0.2.0/spectra_plot.egg-info/dependency_links.txt +1 -0
  30. spectra_plot-0.2.0/spectra_plot.egg-info/requires.txt +7 -0
  31. spectra_plot-0.2.0/spectra_plot.egg-info/top_level.txt +1 -0
  32. spectra_plot-0.2.0/tests/test_codec.py +405 -0
  33. spectra_plot-0.2.0/tests/test_cross_codec.py +364 -0
  34. spectra_plot-0.2.0/tests/test_easy.py +338 -0
  35. spectra_plot-0.2.0/tests/test_easy_embed.py +156 -0
  36. spectra_plot-0.2.0/tests/test_embed.py +249 -0
  37. spectra_plot-0.2.0/tests/test_phase2.py +806 -0
  38. spectra_plot-0.2.0/tests/test_phase3.py +464 -0
  39. spectra_plot-0.2.0/tests/test_phase4.py +543 -0
  40. spectra_plot-0.2.0/tests/test_phase5.py +611 -0
  41. spectra_plot-0.2.0/tests/test_qt_backend.py +229 -0
@@ -0,0 +1 @@
1
+ include VERSION
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: spectra-plot
3
+ Version: 0.2.0
4
+ Summary: GPU-accelerated scientific plotting via IPC
5
+ License: MIT
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Science/Research
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Scientific/Engineering :: Visualization
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Provides-Extra: numpy
19
+ Requires-Dist: numpy>=1.20; extra == "numpy"
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+ Requires-Dist: numpy>=1.20; extra == "dev"
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spectra-plot"
7
+ dynamic = ["version"]
8
+ description = "GPU-accelerated scientific plotting via IPC"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Science/Research",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Visualization",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ numpy = ["numpy>=1.20"]
27
+ dev = ["pytest>=7.0", "numpy>=1.20"]
28
+
29
+ [tool.setuptools.dynamic]
30
+ version = {file = "VERSION"}
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["."]
34
+ include = ["spectra*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,147 @@
1
+ """Spectra — GPU-accelerated scientific plotting via IPC.
2
+
3
+ The easiest plotting library. One line to plot.
4
+
5
+ Usage::
6
+
7
+ import spectra as sp
8
+
9
+ sp.plot([1, 4, 9, 16]) # that's it. window opens.
10
+ sp.plot(x, y, color="red", label="data") # with style
11
+ sp.scatter(x, y) # scatter plot
12
+ sp.hist(data, bins=50) # histogram
13
+ sp.plot3(x, y, z) # 3D line
14
+ sp.live(lambda t: math.sin(t)) # live streaming
15
+
16
+ sp.show() # optional — block until windows closed
17
+
18
+ Advanced usage (full control)::
19
+
20
+ s = sp.Session()
21
+ fig = s.figure("My Plot")
22
+ ax = fig.subplot(1, 1, 1)
23
+ line = ax.line(x, y, label="data")
24
+ fig.show()
25
+ s.show()
26
+ """
27
+
28
+ from ._session import Session
29
+ from ._figure import Figure
30
+ from ._axes import Axes
31
+ from ._series import Series
32
+ from ._errors import (
33
+ SpectraError,
34
+ ConnectionError,
35
+ ProtocolError,
36
+ TimeoutError,
37
+ FigureNotFoundError,
38
+ BackendError,
39
+ )
40
+ from ._animation import ipc_sleep, FramePacer, BackendAnimator
41
+
42
+ # ─── Easy API (one-liners, everything in background) ─────────────────────────
43
+ from ._easy import (
44
+ plot,
45
+ scatter,
46
+ stem,
47
+ hist,
48
+ bar,
49
+ boxplot,
50
+ violin_plot,
51
+ hline,
52
+ vline,
53
+ plot3,
54
+ scatter3,
55
+ surf,
56
+ plotn,
57
+ subplots,
58
+ figure,
59
+ tab,
60
+ subplot,
61
+ subplot3d,
62
+ gcf,
63
+ gca,
64
+ title,
65
+ xlabel,
66
+ ylabel,
67
+ xlim,
68
+ ylim,
69
+ grid,
70
+ legend,
71
+ live,
72
+ stop_live,
73
+ append,
74
+ show,
75
+ close,
76
+ close_all,
77
+ clear,
78
+ )
79
+
80
+ # ─── Backward compatibility ───────────────────────────────────────────────────
81
+ # Old API used sp.line(x, y) — keep as alias for sp.plot(x, y)
82
+ line = plot
83
+
84
+ # Old tests reference these module-level attributes
85
+ _default_session = None
86
+ _current_figure = None
87
+ _current_axes = None
88
+
89
+ try:
90
+ from importlib.metadata import version as _pkg_version
91
+ __version__ = _pkg_version("spectra-plot")
92
+ except Exception:
93
+ __version__ = "0.1.0"
94
+
95
+ __all__ = [
96
+ # Easy API — one-liners
97
+ "plot",
98
+ "scatter",
99
+ "stem",
100
+ "hist",
101
+ "bar",
102
+ "boxplot",
103
+ "violin_plot",
104
+ "hline",
105
+ "vline",
106
+ "plot3",
107
+ "scatter3",
108
+ "surf",
109
+ "plotn",
110
+ "subplots",
111
+ "figure",
112
+ "tab",
113
+ "subplot",
114
+ "subplot3d",
115
+ "gcf",
116
+ "gca",
117
+ "title",
118
+ "xlabel",
119
+ "ylabel",
120
+ "xlim",
121
+ "ylim",
122
+ "grid",
123
+ "legend",
124
+ "live",
125
+ "stop_live",
126
+ "append",
127
+ "show",
128
+ "close",
129
+ "close_all",
130
+ "clear",
131
+ # Backward compat
132
+ "line",
133
+ # Advanced API — full control
134
+ "Session",
135
+ "Figure",
136
+ "Axes",
137
+ "Series",
138
+ "SpectraError",
139
+ "ConnectionError",
140
+ "ProtocolError",
141
+ "TimeoutError",
142
+ "FigureNotFoundError",
143
+ "BackendError",
144
+ "ipc_sleep",
145
+ "FramePacer",
146
+ "BackendAnimator",
147
+ ]
@@ -0,0 +1,180 @@
1
+ """Animation utilities for the Spectra Python client.
2
+
3
+ Provides IPC-aware sleep and frame pacing helpers that drain events
4
+ while waiting, preventing the backend from stalling on a blocked client.
5
+
6
+ Two animation modes:
7
+ 1. Python-driven: Python controls the loop with FramePacer
8
+ 2. Backend-driven: Backend sends ANIM_TICK at fixed rate, Python responds
9
+ """
10
+
11
+ import time
12
+ from typing import TYPE_CHECKING, Callable, Optional
13
+
14
+ if TYPE_CHECKING:
15
+ from ._session import Session
16
+
17
+
18
+ def ipc_sleep(session: "Session", duration: float) -> None:
19
+ """Sleep for `duration` seconds while draining IPC events.
20
+
21
+ Unlike time.sleep(), this keeps the IPC connection alive by
22
+ processing incoming events (e.g. EVT_WINDOW_CLOSED) during the wait.
23
+
24
+ Thread-safe: acquires the session lock before reading from the socket.
25
+ Falls back to plain sleep if the lock is held by another thread
26
+ (e.g. the main thread doing a _request() or show() recv).
27
+ """
28
+ import select
29
+
30
+ if session._transport is None or not session._transport.is_open:
31
+ time.sleep(duration)
32
+ return
33
+
34
+ deadline = time.monotonic() + duration
35
+ while True:
36
+ remaining = deadline - time.monotonic()
37
+ if remaining <= 0:
38
+ break
39
+
40
+ timeout = min(remaining, 0.05) # poll at 20 Hz
41
+ try:
42
+ ready, _, _ = select.select([session._transport.fileno()], [], [], timeout)
43
+ except (ValueError, OSError):
44
+ break
45
+
46
+ if ready:
47
+ # Try to acquire the lock without blocking — if another thread
48
+ # is doing a _request() send+recv, just skip this read.
49
+ acquired = session._lock.acquire(blocking=False)
50
+ if not acquired:
51
+ time.sleep(min(remaining, 0.01))
52
+ continue
53
+ try:
54
+ msg = session._transport.recv()
55
+ finally:
56
+ session._lock.release()
57
+ if msg is None:
58
+ break
59
+ session._handle_event(msg)
60
+
61
+
62
+ class FramePacer:
63
+ """Simple frame pacer for Python-driven animation loops.
64
+
65
+ Usage::
66
+
67
+ pacer = FramePacer(fps=30)
68
+ while running:
69
+ update_data()
70
+ series.set_data(x, y)
71
+ pacer.pace(session)
72
+ """
73
+
74
+ __slots__ = ("_interval", "_last_time")
75
+
76
+ def __init__(self, fps: float = 30.0) -> None:
77
+ self._interval = 1.0 / max(fps, 1.0)
78
+ self._last_time = time.monotonic()
79
+
80
+ @property
81
+ def fps(self) -> float:
82
+ return 1.0 / self._interval
83
+
84
+ @fps.setter
85
+ def fps(self, value: float) -> None:
86
+ self._interval = 1.0 / max(value, 1.0)
87
+
88
+ def pace(self, session: "Session") -> float:
89
+ """Wait until the next frame time, draining events. Returns dt since last call."""
90
+ now = time.monotonic()
91
+ elapsed = now - self._last_time
92
+ remaining = self._interval - elapsed
93
+ if remaining > 0:
94
+ ipc_sleep(session, remaining)
95
+ now = time.monotonic()
96
+ dt = now - self._last_time
97
+ self._last_time = now
98
+ return dt
99
+
100
+
101
+ class BackendAnimator:
102
+ """Backend-driven animation: the backend controls the clock and sends
103
+ ANIM_TICK events at a fixed rate. Python registers a callback that
104
+ receives (t, dt, frame_num) and updates data accordingly.
105
+
106
+ Usage::
107
+
108
+ def on_tick(t, dt, frame_num):
109
+ y = [math.sin(x_i + t) for x_i in x]
110
+ series.set_data(x, y)
111
+
112
+ animator = BackendAnimator(session, figure, fps=60)
113
+ animator.on_tick = on_tick
114
+ animator.start()
115
+ session.show() # blocks until windows closed
116
+ animator.stop()
117
+ """
118
+
119
+ __slots__ = (
120
+ "_session", "_figure_id", "_fps", "_duration",
121
+ "_running", "_on_tick",
122
+ )
123
+
124
+ def __init__(
125
+ self,
126
+ session: "Session",
127
+ figure_id: int,
128
+ fps: float = 60.0,
129
+ duration: float = 0.0,
130
+ ) -> None:
131
+ self._session = session
132
+ self._figure_id = figure_id
133
+ self._fps = fps
134
+ self._duration = duration
135
+ self._running = False
136
+ self._on_tick: Optional[Callable[[float, float, int], None]] = None
137
+
138
+ @property
139
+ def on_tick(self) -> Optional[Callable[[float, float, int], None]]:
140
+ return self._on_tick
141
+
142
+ @on_tick.setter
143
+ def on_tick(self, callback: Optional[Callable[[float, float, int], None]]) -> None:
144
+ self._on_tick = callback
145
+
146
+ @property
147
+ def is_running(self) -> bool:
148
+ return self._running
149
+
150
+ def start(self) -> None:
151
+ """Request the backend to start sending ANIM_TICK events."""
152
+ from . import _protocol as P
153
+ from . import _codec as codec
154
+
155
+ if self._running:
156
+ return
157
+ payload = codec.encode_req_anim_start(
158
+ self._figure_id, self._fps, self._duration
159
+ )
160
+ self._session._request(P.REQ_ANIM_START, payload)
161
+ self._running = True
162
+ # Register this animator with the session for tick dispatch
163
+ self._session._register_animator(self)
164
+
165
+ def stop(self) -> None:
166
+ """Request the backend to stop sending ANIM_TICK events."""
167
+ from . import _protocol as P
168
+ from . import _codec as codec
169
+
170
+ if not self._running:
171
+ return
172
+ payload = codec.encode_req_anim_stop(self._figure_id)
173
+ self._session._request(P.REQ_ANIM_STOP, payload)
174
+ self._running = False
175
+ self._session._unregister_animator(self)
176
+
177
+ def handle_tick(self, t: float, dt: float, frame_num: int) -> None:
178
+ """Called by Session when an ANIM_TICK is received for this figure."""
179
+ if self._on_tick is not None:
180
+ self._on_tick(t, dt, frame_num)
@@ -0,0 +1,275 @@
1
+ """Axes proxy — lightweight handle to axes within a figure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, List, Union
6
+
7
+ from . import _protocol as P
8
+ from . import _codec as codec
9
+
10
+ if TYPE_CHECKING:
11
+ from ._session import Session
12
+ from ._series import Series
13
+
14
+
15
+ class Axes:
16
+ """Proxy for an axes object within a figure.
17
+
18
+ All mutations are sent to the backend via IPC.
19
+ """
20
+
21
+ __slots__ = ("_session", "_figure_id", "_index", "_series_list", "_is_3d")
22
+
23
+ def __init__(self, session: Session, figure_id: int, axes_index: int, is_3d: bool = False) -> None:
24
+ self._session = session
25
+ self._figure_id = figure_id
26
+ self._index = axes_index
27
+ self._series_list: List[Series] = []
28
+ self._is_3d = is_3d
29
+
30
+ @property
31
+ def index(self) -> int:
32
+ return self._index
33
+
34
+ @property
35
+ def figure_id(self) -> int:
36
+ return self._figure_id
37
+
38
+ def line(
39
+ self,
40
+ x: Union[List[float], "object"],
41
+ y: Union[List[float], "object"],
42
+ label: str = "",
43
+ ) -> Series:
44
+ """Add a line series to this axes."""
45
+ return self._add_series("line", x, y, label)
46
+
47
+ def scatter(
48
+ self,
49
+ x: Union[List[float], "object"],
50
+ y: Union[List[float], "object"],
51
+ label: str = "",
52
+ ) -> Series:
53
+ """Add a scatter series to this axes."""
54
+ return self._add_series("scatter", x, y, label)
55
+
56
+ def _add_series(
57
+ self,
58
+ series_type: str,
59
+ x: Union[List[float], "object"],
60
+ y: Union[List[float], "object"],
61
+ label: str = "",
62
+ ) -> Series:
63
+ from ._series import Series
64
+
65
+ # Request series creation
66
+ payload = codec.encode_req_add_series(
67
+ figure_id=self._figure_id,
68
+ axes_index=self._index,
69
+ series_type=series_type,
70
+ label=label,
71
+ )
72
+ resp = self._session._request(P.REQ_ADD_SERIES, payload)
73
+ _, series_index = codec.decode_resp_series_added(resp["payload"])
74
+
75
+ series = Series(self._session, self._figure_id, series_index, series_type, label)
76
+ self._series_list.append(series)
77
+
78
+ # Set initial data
79
+ series.set_data(x, y)
80
+
81
+ return series
82
+
83
+ def _add_series_3d(
84
+ self,
85
+ series_type: str,
86
+ x: Union[List[float], "object"],
87
+ y: Union[List[float], "object"],
88
+ z: Union[List[float], "object"],
89
+ label: str = "",
90
+ ) -> Series:
91
+ """Add a 3D series and set XYZ data."""
92
+ from ._series import Series
93
+
94
+ payload = codec.encode_req_add_series(
95
+ figure_id=self._figure_id,
96
+ axes_index=self._index,
97
+ series_type=series_type,
98
+ label=label,
99
+ )
100
+ resp = self._session._request(P.REQ_ADD_SERIES, payload)
101
+ _, series_index = codec.decode_resp_series_added(resp["payload"])
102
+
103
+ series = Series(self._session, self._figure_id, series_index, series_type, label)
104
+ self._series_list.append(series)
105
+
106
+ series.set_data_xyz(x, y, z)
107
+
108
+ return series
109
+
110
+ @property
111
+ def series(self) -> List[Series]:
112
+ return list(self._series_list)
113
+
114
+ def set_xlim(self, xmin: float, xmax: float) -> None:
115
+ payload = codec.encode_req_update_property(
116
+ figure_id=self._figure_id,
117
+ axes_index=self._index,
118
+ prop="xlim",
119
+ f1=xmin,
120
+ f2=xmax,
121
+ )
122
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
123
+
124
+ def set_ylim(self, ymin: float, ymax: float) -> None:
125
+ payload = codec.encode_req_update_property(
126
+ figure_id=self._figure_id,
127
+ axes_index=self._index,
128
+ prop="ylim",
129
+ f1=ymin,
130
+ f2=ymax,
131
+ )
132
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
133
+
134
+ def set_zlim(self, zmin: float, zmax: float) -> None:
135
+ payload = codec.encode_req_update_property(
136
+ figure_id=self._figure_id,
137
+ axes_index=self._index,
138
+ prop="zlim",
139
+ f1=zmin,
140
+ f2=zmax,
141
+ )
142
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
143
+
144
+ def set_xlabel(self, label: str) -> None:
145
+ payload = codec.encode_req_update_property(
146
+ figure_id=self._figure_id,
147
+ axes_index=self._index,
148
+ prop="xlabel",
149
+ str_val=label,
150
+ )
151
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
152
+
153
+ def set_ylabel(self, label: str) -> None:
154
+ payload = codec.encode_req_update_property(
155
+ figure_id=self._figure_id,
156
+ axes_index=self._index,
157
+ prop="ylabel",
158
+ str_val=label,
159
+ )
160
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
161
+
162
+ def set_title(self, title: str) -> None:
163
+ payload = codec.encode_req_update_property(
164
+ figure_id=self._figure_id,
165
+ axes_index=self._index,
166
+ prop="axes_title",
167
+ str_val=title,
168
+ )
169
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
170
+
171
+ def grid(self, visible: bool = True) -> None:
172
+ payload = codec.encode_req_update_property(
173
+ figure_id=self._figure_id,
174
+ axes_index=self._index,
175
+ prop="grid",
176
+ bool_val=visible,
177
+ )
178
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
179
+
180
+ def legend(self, visible: bool = True) -> None:
181
+ """Toggle legend visibility on this axes."""
182
+ payload = codec.encode_req_update_property(
183
+ figure_id=self._figure_id,
184
+ axes_index=self._index,
185
+ prop="legend",
186
+ bool_val=visible,
187
+ )
188
+ self._session._request(P.REQ_UPDATE_PROPERTY, payload)
189
+
190
+ def remove_series(self, index: int) -> None:
191
+ """Remove a series by index from this axes."""
192
+ payload = codec.encode_req_remove_series(
193
+ figure_id=self._figure_id,
194
+ series_index=index,
195
+ )
196
+ self._session._request(P.REQ_REMOVE_SERIES, payload)
197
+ self._series_list = [s for s in self._series_list if s._index != index]
198
+
199
+ def clear(self) -> None:
200
+ """Remove all series from this axes."""
201
+ # Remove in reverse order so indices stay valid
202
+ for s in reversed(list(self._series_list)):
203
+ payload = codec.encode_req_remove_series(
204
+ figure_id=self._figure_id,
205
+ series_index=s._index,
206
+ )
207
+ self._session._request(P.REQ_REMOVE_SERIES, payload)
208
+ self._series_list.clear()
209
+
210
+ def batch(self) -> "_AxesBatchContext":
211
+ """Context manager for batching multiple property updates into one IPC call.
212
+
213
+ Usage::
214
+
215
+ with ax.batch() as b:
216
+ b.set_xlim(0, 10)
217
+ b.set_ylim(-1, 1)
218
+ b.set_xlabel("Time")
219
+ b.set_ylabel("Value")
220
+ b.grid(True)
221
+ """
222
+ return _AxesBatchContext(self)
223
+
224
+ @property
225
+ def is_3d(self) -> bool:
226
+ return getattr(self, "_is_3d", False)
227
+
228
+ def __repr__(self) -> str:
229
+ is_3d = getattr(self, "_is_3d", False)
230
+ return f"Axes(figure_id={self._figure_id}, index={self._index}, is_3d={is_3d})"
231
+
232
+
233
+
234
+ class _AxesBatchContext:
235
+ """Collects property updates and sends them as a single REQ_UPDATE_BATCH."""
236
+
237
+ __slots__ = ("_axes", "_updates")
238
+
239
+ def __init__(self, axes: Axes) -> None:
240
+ self._axes = axes
241
+ self._updates: list = []
242
+
243
+ def __enter__(self) -> "_AxesBatchContext":
244
+ return self
245
+
246
+ def __exit__(self, *args) -> None:
247
+ if self._updates:
248
+ self._axes._session.batch_update(self._updates)
249
+ self._updates.clear()
250
+
251
+ def _add(self, **kwargs) -> None:
252
+ kwargs.setdefault("figure_id", self._axes._figure_id)
253
+ kwargs.setdefault("axes_index", self._axes._index)
254
+ self._updates.append(kwargs)
255
+
256
+ def set_xlim(self, xmin: float, xmax: float) -> None:
257
+ self._add(prop="xlim", f1=xmin, f2=xmax)
258
+
259
+ def set_ylim(self, ymin: float, ymax: float) -> None:
260
+ self._add(prop="ylim", f1=ymin, f2=ymax)
261
+
262
+ def set_xlabel(self, label: str) -> None:
263
+ self._add(prop="xlabel", str_val=label)
264
+
265
+ def set_ylabel(self, label: str) -> None:
266
+ self._add(prop="ylabel", str_val=label)
267
+
268
+ def set_title(self, title: str) -> None:
269
+ self._add(prop="axes_title", str_val=title)
270
+
271
+ def grid(self, visible: bool = True) -> None:
272
+ self._add(prop="grid", bool_val=visible)
273
+
274
+ def legend(self, visible: bool = True) -> None:
275
+ self._add(prop="legend", bool_val=visible)