maxplotlibx 0.1.9__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,342 @@
1
+ """Detached Matplotlib state used by the object importer.
2
+
3
+ Transforms must be rebound, not frozen in source display coordinates. The
4
+ snapshot memo cuts ownership links before copying and replaces them with tokens;
5
+ each render binds those tokens to its new figure/axes. No source artists are
6
+ reparented, and repeated renders do not share mutable Matplotlib state.
7
+ """
8
+
9
+ import copy
10
+ from dataclasses import asdict, dataclass, field
11
+
12
+ from matplotlib.artist import Artist
13
+ from matplotlib.cbook import CallbackRegistry
14
+ from matplotlib.collections import Collection
15
+ from matplotlib.image import AxesImage
16
+ from matplotlib.lines import Line2D
17
+ from matplotlib.patches import Patch
18
+ from matplotlib.table import Table
19
+ from matplotlib.text import Text
20
+ from matplotlib.transforms import Bbox, BboxTransformTo
21
+
22
+
23
+ @dataclass
24
+ class ImportDiagnostic:
25
+ artist_id: int
26
+ artist_type: str
27
+ label: str
28
+ severity: str
29
+ message: str
30
+ fallback: str | None = None
31
+ backends: tuple = ("matplotlib",)
32
+
33
+
34
+ @dataclass
35
+ class ImportReport:
36
+ """Import decisions, including losses and backend-specific representations."""
37
+
38
+ diagnostics: list = field(default_factory=list)
39
+
40
+ def add(
41
+ self,
42
+ artist,
43
+ message,
44
+ *,
45
+ severity="info",
46
+ fallback=None,
47
+ backends=("matplotlib",),
48
+ ):
49
+ item = ImportDiagnostic(
50
+ id(artist),
51
+ type(artist).__name__,
52
+ str(getattr(artist, "get_label", lambda: "")()),
53
+ severity,
54
+ message,
55
+ fallback,
56
+ backends,
57
+ )
58
+ self.diagnostics.append(item)
59
+ return item
60
+
61
+ def to_dict(self):
62
+ return asdict(self)
63
+
64
+
65
+ def root_figure(figure):
66
+ while getattr(figure, "figure", figure) is not figure:
67
+ figure = figure.figure
68
+ return figure
69
+
70
+
71
+ def figure_bounds(ax, figure):
72
+ return tuple(
73
+ ax.get_position(original=True)
74
+ .transformed(ax.figure.transSubfigure)
75
+ .transformed(figure.transFigure.inverted())
76
+ .bounds
77
+ )
78
+
79
+
80
+ def _bindings(ax, fig):
81
+ bindings = {
82
+ "figure": fig,
83
+ "transFigure": fig.transFigure,
84
+ "transSubfigure": fig.transSubfigure,
85
+ "dpi_scale_trans": fig.dpi_scale_trans,
86
+ "fig_bbox": fig.bbox,
87
+ }
88
+ if ax is not None:
89
+ bindings.update(
90
+ axes=ax,
91
+ transData=ax.transData,
92
+ transAxes=ax.transAxes,
93
+ transScale=ax.transScale,
94
+ transLimits=ax.transLimits,
95
+ ax_bbox=ax.bbox,
96
+ xaxis=ax.xaxis,
97
+ yaxis=ax.yaxis,
98
+ xaxis_transform=ax.get_xaxis_transform(),
99
+ yaxis_transform=ax.get_yaxis_transform(),
100
+ )
101
+ return bindings
102
+
103
+
104
+ class _BindingToken:
105
+ """Weak-referenceable marker for copied Transform parent links."""
106
+
107
+
108
+ class ReboundSnapshot:
109
+ """Copy a payload without retaining its owning axes, figure or callbacks."""
110
+
111
+ def __init__(self, payload, ax=None, fig=None):
112
+ owner = fig if fig is not None else ax.figure
113
+ fig = root_figure(owner)
114
+ self.owner_bounds = None
115
+ self.tokens = {}
116
+ memo = {}
117
+ bindings = _bindings(ax, fig)
118
+ if owner is not fig:
119
+ self.owner_bounds = tuple(
120
+ owner.bbox.transformed(fig.transFigure.inverted()).bounds
121
+ )
122
+ bindings.update(owner=owner, owner_transform=owner.transSubfigure)
123
+ for name, value in bindings.items():
124
+ # Some canonical transforms are aliases of one another.
125
+ if id(value) not in memo:
126
+ self.tokens[name] = memo[id(value)] = _BindingToken()
127
+ artists = []
128
+ if isinstance(payload, Artist):
129
+ artists = payload.findobj()
130
+ for artist in artists:
131
+ for name in ("_remove_method", "stale_callback"):
132
+ value = getattr(artist, name, None)
133
+ if value is not None:
134
+ memo[id(value)] = None
135
+ callbacks = getattr(artist, "_callbacks", None)
136
+ if callbacks is not None:
137
+ memo[id(callbacks)] = CallbackRegistry()
138
+ self.payload = copy.deepcopy(payload, memo)
139
+
140
+ def clone(self, ax=None, fig=None):
141
+ fig = fig if fig is not None else ax.figure
142
+ bindings = _bindings(ax, fig)
143
+ if self.owner_bounds is not None:
144
+ bindings.update(
145
+ owner=fig,
146
+ owner_transform=BboxTransformTo(Bbox.from_bounds(*self.owner_bounds))
147
+ + fig.transFigure,
148
+ )
149
+ return copy.deepcopy(
150
+ self.payload,
151
+ {id(token): bindings[name] for name, token in self.tokens.items()},
152
+ )
153
+
154
+ def draw(self, ax, **overrides):
155
+ artist = self.clone(ax)
156
+ artist.set(**overrides)
157
+ clipbox, clippath = artist.get_clip_box(), artist.get_clip_path()
158
+ # add_* establishes the new removal and stale callbacks.
159
+ if isinstance(artist, Collection):
160
+ ax.add_collection(artist, autolim=False)
161
+ elif isinstance(artist, Line2D):
162
+ ax.add_line(artist)
163
+ elif isinstance(artist, Patch):
164
+ ax.add_patch(artist)
165
+ elif isinstance(artist, AxesImage):
166
+ ax.add_image(artist)
167
+ elif isinstance(artist, Table):
168
+ ax.add_table(artist)
169
+ elif isinstance(artist, Text):
170
+ ax._add_text(artist)
171
+ else:
172
+ ax.add_artist(artist)
173
+ artist.set_clip_path(clippath)
174
+ artist.set_clip_box(clipbox)
175
+ return artist
176
+
177
+
178
+ def capture_axis_state(ax):
179
+ """Capture decorations which have no backend-neutral equivalent yet."""
180
+ state = {"axes": {}, "spines": {}, "titles": {}}
181
+ for name in ("x", "y"):
182
+ axis = getattr(ax, name + "axis")
183
+ state["axes"][name] = dict(
184
+ scale=ReboundSnapshot(axis._scale, ax),
185
+ major_locator=ReboundSnapshot(axis.get_major_locator(), ax),
186
+ minor_locator=ReboundSnapshot(axis.get_minor_locator(), ax),
187
+ major_formatter=ReboundSnapshot(axis.get_major_formatter(), ax),
188
+ minor_formatter=ReboundSnapshot(axis.get_minor_formatter(), ax),
189
+ units=copy.deepcopy(axis.get_units()),
190
+ converter=copy.deepcopy(
191
+ axis.get_converter()
192
+ if hasattr(axis, "get_converter")
193
+ else axis.converter
194
+ ),
195
+ major_kw=_tick_params(axis, "major"),
196
+ minor_kw=_tick_params(axis, "minor"),
197
+ ticks={
198
+ which: [
199
+ _tick_style(tick)
200
+ for tick in getattr(axis, "get_" + which + "_ticks")()
201
+ ]
202
+ for which in ("major", "minor")
203
+ },
204
+ label_position=axis.get_label_position(),
205
+ offset_style=_text_properties(axis.get_offset_text()),
206
+ )
207
+ for name, spine in ax.spines.items():
208
+ state["spines"][name] = dict(
209
+ visible=spine.get_visible(),
210
+ edgecolor=spine.get_edgecolor(),
211
+ linewidth=spine.get_linewidth(),
212
+ linestyle=spine.get_linestyle(),
213
+ bounds=spine.get_bounds(),
214
+ )
215
+ if spine.spine_type in ("left", "right", "top", "bottom"):
216
+ state["spines"][name]["position"] = copy.deepcopy(spine.get_position())
217
+ for loc, title in (
218
+ ("left", ax._left_title),
219
+ ("center", ax.title),
220
+ ("right", ax._right_title),
221
+ ):
222
+ state["titles"][loc] = (
223
+ title.get_text(),
224
+ _text_properties(title),
225
+ title.get_position(),
226
+ )
227
+ state["autotitlepos"] = ax._autotitlepos
228
+ state["label_coords"] = {
229
+ name: (
230
+ axis.label.get_position(),
231
+ ReboundSnapshot(axis.label.get_transform(), ax),
232
+ )
233
+ for name, axis in (("x", ax.xaxis), ("y", ax.yaxis))
234
+ if not axis._autolabelpos
235
+ }
236
+ return state
237
+
238
+
239
+ def _text_properties(text):
240
+ return dict(
241
+ fontproperties=copy.deepcopy(text.get_fontproperties()),
242
+ color=text.get_color(),
243
+ rotation=text.get_rotation(),
244
+ rotation_mode=text.get_rotation_mode(),
245
+ horizontalalignment=text.get_ha(),
246
+ verticalalignment=text.get_va(),
247
+ visible=text.get_visible(),
248
+ usetex=text.get_usetex(),
249
+ )
250
+
251
+
252
+ def _tick_params(axis, which):
253
+ tick = getattr(axis, "get_" + which + "_ticks")(1)[0]
254
+ defaults = dict(
255
+ length=tick._size,
256
+ width=tick._width,
257
+ pad=tick._base_pad,
258
+ direction=tick._tickdir,
259
+ color=tick.tick1line.get_color(),
260
+ )
261
+ defaults.update(copy.deepcopy(getattr(axis, "_" + which + "_tick_kw")))
262
+ return defaults
263
+
264
+
265
+ def _tick_line_style(line):
266
+ return {
267
+ name: getattr(line, "get_" + name)()
268
+ for name in (
269
+ "color",
270
+ "marker",
271
+ "markersize",
272
+ "markeredgewidth",
273
+ "markeredgecolor",
274
+ "visible",
275
+ "zorder",
276
+ )
277
+ }
278
+
279
+
280
+ def _tick_style(tick):
281
+ return dict(
282
+ tick1line=_tick_line_style(tick.tick1line),
283
+ tick2line=_tick_line_style(tick.tick2line),
284
+ label1=_text_properties(tick.label1),
285
+ label2=_text_properties(tick.label2),
286
+ gridline=dict(
287
+ visible=tick.gridline.get_visible(),
288
+ color=tick.gridline.get_color(),
289
+ linewidth=tick.gridline.get_linewidth(),
290
+ linestyle=tick.gridline.get_linestyle(),
291
+ alpha=tick.gridline.get_alpha(),
292
+ ),
293
+ )
294
+
295
+
296
+ def apply_axis_state(ax, state, *, units=True):
297
+ for name, props in state["spines"].items():
298
+ props = copy.deepcopy(props)
299
+ bounds = props.pop("bounds")
300
+ ax.spines[name].set(**props)
301
+ if bounds is not None:
302
+ ax.spines[name].set_bounds(*bounds)
303
+ for name, settings in state["axes"].items():
304
+ axis = getattr(ax, name + "axis")
305
+ # Scales are installed before locators/formatters: changing a scale
306
+ # installs defaults and would otherwise erase the imported tick setup.
307
+ getattr(ax, "set_" + name + "scale")(settings["scale"].clone(ax))
308
+ if units:
309
+ converter = copy.deepcopy(settings["converter"])
310
+ if hasattr(axis, "set_converter"):
311
+ if not getattr(axis, "_converter_is_explicit", False):
312
+ axis.set_converter(converter)
313
+ else:
314
+ axis.converter = converter
315
+ axis.set_units(copy.deepcopy(settings["units"]))
316
+ for kind in ("major", "minor"):
317
+ getattr(axis, "set_" + kind + "_locator")(
318
+ settings[kind + "_locator"].clone(ax)
319
+ )
320
+ getattr(axis, "set_" + kind + "_formatter")(
321
+ settings[kind + "_formatter"].clone(ax)
322
+ )
323
+ axis.set_tick_params(which=kind, **copy.deepcopy(settings[kind + "_kw"]))
324
+ ticks = getattr(axis, "get_" + kind + "_ticks")()
325
+ styles = settings["ticks"][kind]
326
+ for i, tick in enumerate(ticks):
327
+ if styles:
328
+ style = styles[min(i, len(styles) - 1)]
329
+ for part, props in style.items():
330
+ getattr(tick, part).set(**props)
331
+ axis.set_label_position(settings["label_position"])
332
+ axis.get_offset_text().set(**settings["offset_style"])
333
+ for loc, (text, props, position) in (
334
+ state["titles"].items() if hasattr(ax, "set_title") else ()
335
+ ):
336
+ title = ax.set_title(text, loc=loc, **props)
337
+ title.set_position(position)
338
+ ax._autotitlepos = state["autotitlepos"]
339
+ for name, (position, transform) in state["label_coords"].items():
340
+ getattr(ax, name + "axis").set_label_coords(
341
+ *position, transform=transform.clone(ax)
342
+ )