tesorotools-python 0.1.0__py3-none-any.whl → 0.1.2__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.
tesorotools/__init__.py CHANGED
@@ -72,6 +72,7 @@ from tesorotools.artists import (
72
72
  TypeCurve,
73
73
  VectorPlot,
74
74
  Waterfall,
75
+ share_y_axes,
75
76
  )
76
77
  from tesorotools.orchestration import CompositeRegistry, iter_contexts
77
78
  from tesorotools.providers.base import (
@@ -177,6 +178,7 @@ __all__ = [
177
178
  "register_providers",
178
179
  "register_tag",
179
180
  "register_tags",
181
+ "share_y_axes",
180
182
  ]
181
183
 
182
184
 
@@ -12,9 +12,16 @@ Each class follows the same shape:
12
12
  ``data_path`` (``.feather`` on disk), never both.
13
13
  * ``from_yaml(loader, node)`` -- builds the same instance
14
14
  from a ``!{name}`` YAML tag.
15
- * ``plot()`` -- renders the chart and writes the PNG; some
16
- classes also expose ``build()`` / ``save()`` for
17
- fine-grained control.
15
+ * ``build()`` -- renders the chart in memory and returns
16
+ ``(Figure, Axes)``; ``save(fig, path=..., dpi=...)``
17
+ writes it; ``plot()`` does both and closes the figure.
18
+ Every artist exposes the three (see :class:`Artist`).
19
+ * ``to_plotly()`` -- the same chart as an interactive
20
+ Plotly figure (all but ``IntradayPlot``);
21
+ :func:`plotly_unsupported` lists the keys it drops.
22
+
23
+ :func:`share_y_axes` gives the charts that declare the same
24
+ ``y_group`` one common y scale.
18
25
 
19
26
  Shared layout, annotation and styling helpers (plus the
20
27
  ``Format`` and ``Legend`` config holders) live in
@@ -25,13 +32,20 @@ live under :mod:`tesorotools.render`; this package is
25
32
  strictly image output.
26
33
  """
27
34
 
28
- from tesorotools.artists._common import Artist, Format, Legend
35
+ from tesorotools.artists._common import (
36
+ Artist,
37
+ FigureLifecycle,
38
+ Format,
39
+ Legend,
40
+ plotly_unsupported,
41
+ )
29
42
  from tesorotools.artists.bar_line import BarLinePlot
30
43
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
31
44
  from tesorotools.artists.box_plot import BoxPlot
32
45
  from tesorotools.artists.intraday_plot import IntradayPlot
33
46
  from tesorotools.artists.line_plot import LinePlot
34
47
  from tesorotools.artists.matrix import MatrixChart
48
+ from tesorotools.artists.sharing import share_y_axes
35
49
  from tesorotools.artists.shock_plot import ShockChart
36
50
  from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
37
51
  from tesorotools.artists.type_curve import TypeCurve
@@ -42,6 +56,7 @@ __all__ = [
42
56
  "Artist",
43
57
  "BarLinePlot",
44
58
  "BoxPlot",
59
+ "FigureLifecycle",
45
60
  "Format",
46
61
  "GroupedBarChart",
47
62
  "HorizontalBarChart",
@@ -55,4 +70,6 @@ __all__ = [
55
70
  "TypeCurve",
56
71
  "VectorPlot",
57
72
  "Waterfall",
73
+ "plotly_unsupported",
74
+ "share_y_axes",
58
75
  ]
@@ -23,13 +23,18 @@ overrides only need to update ``plots.yaml``.
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ from collections.abc import Sequence
26
27
  from pathlib import Path
27
28
  from typing import Any, Literal, Protocol, Self, cast, runtime_checkable
28
29
 
29
30
  import matplotlib.pyplot as plt
30
31
  import pandas as pd
31
32
  from matplotlib.axes import Axes
32
- from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
33
+ from matplotlib.dates import (
34
+ AutoDateLocator,
35
+ ConciseDateFormatter,
36
+ YearLocator,
37
+ )
33
38
  from matplotlib.dates import (
34
39
  date2num, # type: ignore[reportUnknownVariableType]
35
40
  )
@@ -126,6 +131,70 @@ def dynamic_dpi(fig: Figure) -> int:
126
131
  return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
127
132
 
128
133
 
134
+ def plotly_unsupported(artist: object) -> frozenset[str]:
135
+ """Configuration keys *artist* accepts but ``to_plotly()`` drops.
136
+
137
+ *artist* is an artist class or instance. The keys change the PNG
138
+ but not the interactive figure; nested ones are dotted
139
+ (``"legend.ncol"``, ``"fmt.signed"``). A UI built on top can tell
140
+ the user why moving such a control leaves the figure as it is.
141
+ Needs no ``plotly``: it reads the class attribute
142
+ ``PLOTLY_UNSUPPORTED``, which the test suite checks against real
143
+ renders.
144
+ """
145
+ cls = artist if isinstance(artist, type) else type(artist)
146
+ if not hasattr(cls, "to_plotly"):
147
+ raise TypeError(f"{cls.__name__} has no to_plotly()")
148
+ declared: frozenset[str] | None = getattr(cls, "PLOTLY_UNSUPPORTED", None)
149
+ if declared is None:
150
+ raise TypeError(f"{cls.__name__} does not declare PLOTLY_UNSUPPORTED")
151
+ return frozenset(declared)
152
+
153
+
154
+ class FigureLifecycle:
155
+ """``save()`` and ``plot()`` on top of a subclass's ``build()``.
156
+
157
+ Gives an artist the same three-step life cycle as the rest:
158
+ ``build()`` returns the figure in memory, ``save()`` writes it
159
+ and ``plot()`` does both and closes the figure. A subclass only
160
+ implements ``build()``.
161
+ """
162
+
163
+ out_path: Path
164
+
165
+ def build(self) -> tuple[Figure, Axes]:
166
+ raise NotImplementedError
167
+
168
+ def save(
169
+ self,
170
+ fig: Figure,
171
+ *,
172
+ path: Path | None = None,
173
+ dpi: int | None = None,
174
+ ) -> Path:
175
+ """Persist *fig* as a PNG and return the path written.
176
+
177
+ Defaults to ``self.out_path``; pass ``path`` to redirect.
178
+ ``dpi`` overrides the save resolution; when omitted it is
179
+ derived from the figure size via :func:`dynamic_dpi`.
180
+ """
181
+ target: Path = path if path is not None else self.out_path
182
+ save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
183
+ fig.savefig( # type: ignore[reportUnknownMemberType]
184
+ target, dpi=save_dpi
185
+ )
186
+ return target
187
+
188
+ def plot(self) -> Axes:
189
+ """Build the chart, persist it to ``self.out_path`` and close it."""
190
+ fig, ax = self.build()
191
+ try:
192
+ self.save(fig)
193
+ finally:
194
+ plt.close(fig)
195
+ return ax
196
+
197
+
129
198
  # ----------------------------------------------------------------------
130
199
  # Structural contract for image artists
131
200
  # ----------------------------------------------------------------------
@@ -137,13 +206,21 @@ class Artist(Protocol):
137
206
 
138
207
  The built-in artists and any third-party artist
139
208
  registered via :func:`tesorotools.register_artist`
140
- expose the same three-piece surface:
209
+ expose the same surface:
141
210
 
142
211
  * ``out_path: Path`` -- the ``.png`` destination.
143
212
  * ``from_yaml(loader, node) -> Self`` -- the YAML
144
213
  constructor bound to the ``!{name}`` tag.
145
- * ``plot() -> Axes`` -- render the chart, persist it
146
- to ``out_path``, and return the axes for inspection.
214
+ * ``build() -> (Figure, Axes)`` -- render the chart in
215
+ memory, without touching the disk.
216
+ * ``save(fig, *, path=None, dpi=None) -> Path`` -- write
217
+ a built figure (to ``out_path`` unless ``path`` says
218
+ otherwise).
219
+ * ``plot() -> Axes`` -- ``build()`` + ``save()``, close
220
+ the figure and return the axes for inspection.
221
+
222
+ :class:`FigureLifecycle` provides ``save()`` and ``plot()``
223
+ for an artist that only implements ``build()``.
147
224
 
148
225
  The Protocol is ``runtime_checkable`` so consumers can
149
226
  use ``isinstance(obj, Artist)`` to validate a candidate
@@ -156,6 +233,16 @@ class Artist(Protocol):
156
233
  @classmethod
157
234
  def from_yaml(cls, loader: Any, node: MappingNode) -> Self: ...
158
235
 
236
+ def build(self) -> tuple[Figure, Axes]: ...
237
+
238
+ def save(
239
+ self,
240
+ fig: Figure,
241
+ *,
242
+ path: Path | None = None,
243
+ dpi: int | None = None,
244
+ ) -> Path: ...
245
+
159
246
  def plot(self) -> Axes: ...
160
247
 
161
248
 
@@ -580,6 +667,12 @@ def date_span_ticks(
580
667
  10 % of the span is dropped so the two labels do not collide. Without
581
668
  it the plain in-span candidates are returned (falling back to the two
582
669
  endpoints when the locator offers nothing inside).
670
+
671
+ When the locator steps by several years, the pinned grid is anchored to
672
+ the **start year** instead of to multiples of the step: a 2-year step
673
+ over 2023-2027 gives 2023, 2025, 2027 rather than the pinned 2023 plus
674
+ the calendar 2024, 2026, whose uneven gaps (and missing odd years) read
675
+ as if the axis skipped a year.
583
676
  """
584
677
  lo: float = float(cast(Any, date2num(data_min)))
585
678
  hi: float = float(cast(Any, date2num(data_max)))
@@ -589,6 +682,18 @@ def date_span_ticks(
589
682
  )
590
683
  if not pin_start:
591
684
  return [t for t in candidates if lo <= t <= hi] or [lo, hi]
685
+ chosen: Any = locator.get_locator( # type: ignore[reportUnknownMemberType]
686
+ data_min, data_max
687
+ )
688
+ if isinstance(chosen, YearLocator):
689
+ step = int(cast(Any, chosen.base).step)
690
+ if step > 1:
691
+ first_year: int = num2date(lo).year
692
+ last_year: int = num2date(hi).year
693
+ candidates = [
694
+ float(cast(Any, date2num(pd.Timestamp(year=y, month=1, day=1))))
695
+ for y in range(first_year, last_year + 1, step)
696
+ ]
592
697
  min_gap: float = (hi - lo) * 0.10
593
698
  interior: list[float] = [
594
699
  t for t in candidates if lo < t <= hi and t - lo >= min_gap
@@ -1195,6 +1300,26 @@ def style_spines(
1195
1300
  )
1196
1301
 
1197
1302
 
1303
+ def check_y_limits(
1304
+ limits: Sequence[float] | None,
1305
+ ) -> tuple[float, float] | None:
1306
+ """Validate a ``y_limits`` pair and return it as ``(bottom, top)``.
1307
+
1308
+ YAML hands the pair over as a list, so any two-item sequence is
1309
+ accepted; ``None`` (no fixed range) passes through. Raises
1310
+ ``ValueError`` unless there are exactly two numbers with
1311
+ ``bottom < top``.
1312
+ """
1313
+ if limits is None:
1314
+ return None
1315
+ if len(limits) != 2:
1316
+ raise ValueError(f"y_limits must be (bottom, top): {limits!r}")
1317
+ bottom, top = float(limits[0]), float(limits[1])
1318
+ if not bottom < top:
1319
+ raise ValueError(f"y_limits bottom must be below top: {limits!r}")
1320
+ return bottom, top
1321
+
1322
+
1198
1323
  def style_baseline(
1199
1324
  ax: Axes,
1200
1325
  reference: float = 0,
@@ -1235,6 +1360,7 @@ def annotate_last_values(
1235
1360
  fontsize: float | None = None,
1236
1361
  right_pad_px: float = 10.0,
1237
1362
  headroom_pad_pt: float = 0.0,
1363
+ grow_top: bool = True,
1238
1364
  ) -> None:
1239
1365
  """Label the last non-NaN value of each column on the right.
1240
1366
 
@@ -1261,7 +1387,8 @@ def annotate_last_values(
1261
1387
  y-limit, that limit grows enough to keep this many points
1262
1388
  free and the labels are re-packed at the new scale, so a
1263
1389
  tall stack never spills into a legend above the axes (nor
1264
- is clipped at the top).
1390
+ is clipped at the top). ``grow_top=False`` keeps the top
1391
+ y-limit as it is (a fixed or shared y range must not move).
1265
1392
  """
1266
1393
  fig = ax.get_figure()
1267
1394
  if fig is None:
@@ -1333,7 +1460,7 @@ def annotate_last_values(
1333
1460
  pad_px = headroom_pad_pt * fig.dpi / 72.0
1334
1461
  top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
1335
1462
  needed_px = placements[-1] + text_height / 2 + pad_px
1336
- if needed_px > top_axes_px:
1463
+ if grow_top and needed_px > top_axes_px:
1337
1464
  new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
1338
1465
  (0, needed_px)
1339
1466
  )[1]
@@ -28,7 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import locale
30
30
  from pathlib import Path
31
- from typing import Any, Self
31
+ from typing import Any, ClassVar, Self
32
32
 
33
33
  import matplotlib.pyplot as plt
34
34
  import numpy as np
@@ -89,6 +89,23 @@ class BarLinePlot:
89
89
  (``None`` labels every slot).
90
90
  """
91
91
 
92
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
93
+ #: (see :func:`plotly_unsupported`); checked against real renders
94
+ #: by ``tests/test_plotly_support.py``.
95
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
96
+ {
97
+ "bar_width",
98
+ "compact",
99
+ "figsize",
100
+ "fmt.decimals",
101
+ "font_pt",
102
+ "legend.ncol",
103
+ "max_xticks",
104
+ "plot_size",
105
+ "x_rotation",
106
+ }
107
+ )
108
+
92
109
  def __init__(
93
110
  self,
94
111
  out_path: Path,
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  from enum import Enum
20
20
  from pathlib import Path
21
- from typing import Any, Self
21
+ from typing import Any, ClassVar, Self
22
22
 
23
23
  import matplotlib.patheffects as pe
24
24
  import matplotlib.pyplot as plt
@@ -32,6 +32,7 @@ from matplotlib.ticker import FuncFormatter
32
32
  from yaml.nodes import MappingNode
33
33
 
34
34
  from tesorotools.artists._common import (
35
+ FigureLifecycle,
35
36
  AX_CONFIG,
36
37
  Format,
37
38
  Legend,
@@ -39,7 +40,6 @@ from tesorotools.artists._common import (
39
40
  compact_barh_figsize,
40
41
  compact_figure_kwargs,
41
42
  compact_font_pt,
42
- dynamic_dpi,
43
43
  legend_layout,
44
44
  place_legend,
45
45
  resolve_data,
@@ -447,7 +447,7 @@ def _place_in_or_out_labels(
447
447
  return outside
448
448
 
449
449
 
450
- class HorizontalBarChart:
450
+ class HorizontalBarChart(FigureLifecycle):
451
451
  """Horizontal bar chart artist.
452
452
 
453
453
  Parameters
@@ -507,6 +507,19 @@ class HorizontalBarChart:
507
507
  outside; pragmatic but mixes the two looks).
508
508
  """
509
509
 
510
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
511
+ #: (see :func:`plotly_unsupported`); checked against real renders
512
+ #: by ``tests/test_plotly_support.py``.
513
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
514
+ {
515
+ "color_value_labels",
516
+ "compact",
517
+ "figsize",
518
+ "font_pt",
519
+ "value_label_position",
520
+ }
521
+ )
522
+
510
523
  def __init__(
511
524
  self,
512
525
  out_path: Path,
@@ -641,7 +654,11 @@ class HorizontalBarChart:
641
654
  frame = frame.sort_values(by=_Col.VALUE.value)
642
655
  return frame
643
656
 
644
- def plot(self) -> Axes:
657
+ def build(self) -> tuple[Figure, Axes]:
658
+ """Render the chart in memory; :meth:`save` writes it.
659
+
660
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
661
+ """
645
662
  """Render the chart and persist it to ``self.out_path``."""
646
663
  frame = self._build_frame()
647
664
 
@@ -689,14 +706,10 @@ class HorizontalBarChart:
689
706
  axis="both", labelsize=font_pt
690
707
  )
691
708
 
692
- fig.savefig( # type: ignore[reportUnknownMemberType]
693
- self.out_path, dpi=dynamic_dpi(fig)
694
- )
695
- plt.close(fig)
696
- return ax
709
+ return fig, ax
697
710
 
698
711
 
699
- class GroupedBarChart:
712
+ class GroupedBarChart(FigureLifecycle):
700
713
  """Grouped (clustered) horizontal bar chart artist.
701
714
 
702
715
  Compares two or more series side-by-side within each
@@ -764,6 +777,23 @@ class GroupedBarChart:
764
777
  see :class:`HorizontalBarChart`.
765
778
  """
766
779
 
780
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
781
+ #: (see :func:`plotly_unsupported`); checked against real renders
782
+ #: by ``tests/test_plotly_support.py``.
783
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
784
+ {
785
+ "annotate",
786
+ "color_value_labels",
787
+ "compact",
788
+ "figsize",
789
+ "font_pt",
790
+ "group_width",
791
+ "legend.ncol",
792
+ "plot_size",
793
+ "value_label_position",
794
+ }
795
+ )
796
+
767
797
  def __init__(
768
798
  self,
769
799
  out_path: Path,
@@ -867,7 +897,11 @@ class GroupedBarChart:
867
897
 
868
898
  return grouped_bar_to_figure(self)
869
899
 
870
- def plot(self) -> Axes:
900
+ def build(self) -> tuple[Figure, Axes]:
901
+ """Render the chart in memory; :meth:`save` writes it.
902
+
903
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
904
+ """
871
905
  """Render the chart and persist it to ``self.out_path``."""
872
906
  cat_ids = list(self.categories.keys())
873
907
  ser_ids = list(self.series.keys())
@@ -941,8 +975,4 @@ class GroupedBarChart:
941
975
  if self.plot_size is not None and not self.compact:
942
976
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
943
977
 
944
- fig.savefig( # type: ignore[reportUnknownMemberType]
945
- self.out_path, dpi=dynamic_dpi(fig)
946
- )
947
- plt.close(fig)
948
- return ax
978
+ return fig, ax
@@ -32,7 +32,7 @@ The last-value marker is a circle by default and fully configurable
32
32
  from __future__ import annotations
33
33
 
34
34
  from pathlib import Path
35
- from typing import Any, Self
35
+ from typing import Any, ClassVar, Self
36
36
 
37
37
  import matplotlib.pyplot as plt
38
38
  import numpy as np
@@ -129,6 +129,21 @@ class BoxPlot:
129
129
  * ``legend_labels`` -- override the four proxy labels.
130
130
  """
131
131
 
132
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
133
+ #: (see :func:`plotly_unsupported`); checked against real renders
134
+ #: by ``tests/test_plotly_support.py``.
135
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
136
+ {
137
+ "compact",
138
+ "figsize",
139
+ "fmt.decimals",
140
+ "font_pt",
141
+ "legend.ncol",
142
+ "plot_size",
143
+ "x_rotation",
144
+ }
145
+ )
146
+
132
147
  def __init__(
133
148
  self,
134
149
  out_path: Path,
@@ -11,8 +11,9 @@ from __future__ import annotations
11
11
 
12
12
  import datetime
13
13
  import locale
14
+ from collections.abc import Sequence
14
15
  from pathlib import Path
15
- from typing import Any, Self
16
+ from typing import Any, ClassVar, Self
16
17
 
17
18
  import matplotlib.pyplot as plt
18
19
  import pandas as pd
@@ -30,6 +31,7 @@ from tesorotools.artists._common import (
30
31
  adjust_figure_for_plot_size,
31
32
  annotate_last_values,
32
33
  apply_date_axis,
34
+ check_y_limits,
33
35
  compact_date_axis,
34
36
  compact_figure_kwargs,
35
37
  compact_font_pt,
@@ -160,12 +162,43 @@ class LinePlot:
160
162
  **height** (keeping the width) so the plot area is not
161
163
  squashed by a large legend or rotated date labels. Skipped
162
164
  when ``plot_size`` is set (that already fixes the axes size).
165
+ * ``y_limits`` -- fix the y-axis to ``(bottom, top)`` on both
166
+ backends. The range is strict: the baseline, the reference
167
+ lines and the end-of-series labels no longer widen it.
168
+ * ``y_group`` -- name of a group of charts that must share the
169
+ same y scale (e.g. US and European equities, both base 100,
170
+ in two separate charts). It does nothing on its own:
171
+ :func:`tesorotools.artists.share_y_axes` measures every chart
172
+ of each group and sets the union of their ranges as their
173
+ ``y_limits``. Mutually exclusive with ``y_limits``.
163
174
 
164
175
  Render lifecycle: ``build()`` returns ``(Figure, Axes)``
165
176
  in memory; ``save(fig)`` persists to ``out_path``;
166
177
  ``plot()`` is the thin convenience that does both.
167
178
  """
168
179
 
180
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
181
+ #: (see :func:`plotly_unsupported`); checked against real renders
182
+ #: by ``tests/test_plotly_support.py``.
183
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
184
+ {
185
+ "adapt_height",
186
+ "axis_decimals",
187
+ "compact",
188
+ "date_density",
189
+ "date_rotation",
190
+ "date_show_start",
191
+ "figsize",
192
+ "font_pt",
193
+ "legend.ncol",
194
+ "means_window",
195
+ "plot_size",
196
+ "ref_lines",
197
+ "show_means",
198
+ "vlines",
199
+ }
200
+ )
201
+
169
202
  def __init__(
170
203
  self,
171
204
  out_path: Path,
@@ -201,9 +234,16 @@ class LinePlot:
201
234
  ref_lines: list[dict[str, Any]] | None = None,
202
235
  show_means: bool = False,
203
236
  means_window: tuple[Any, Any] | None = None,
237
+ y_limits: Sequence[float] | None = None,
238
+ y_group: str | None = None,
204
239
  ) -> None:
205
240
  if out_path.suffix != ".png":
206
241
  raise ValueError(f"out_path must be .png: {out_path}")
242
+ if y_limits is not None and y_group is not None:
243
+ raise ValueError(
244
+ "y_limits and y_group are mutually exclusive: a group "
245
+ "computes its shared y_limits (see share_y_axes)"
246
+ )
207
247
  if base_100 and base_0:
208
248
  raise ValueError(
209
249
  "base_100 and base_0 are mutually exclusive: a series is "
@@ -247,6 +287,8 @@ class LinePlot:
247
287
  self.ref_lines = ref_lines or []
248
288
  self.show_means = show_means
249
289
  self.means_window = means_window
290
+ self.y_limits = check_y_limits(y_limits)
291
+ self.y_group = y_group
250
292
 
251
293
  @property
252
294
  def _pin_start_date(self) -> bool:
@@ -302,7 +344,8 @@ class LinePlot:
302
344
  ``axis_decimals``, ``axis_units``, ``annotate_units``,
303
345
  ``date_density``, ``date_rotation``, ``date_show_start``,
304
346
  ``adapt_height``,
305
- ``ref_lines``, ``show_means``, ``means_window``.
347
+ ``ref_lines``, ``show_means``, ``means_window``,
348
+ ``y_limits``, ``y_group``.
306
349
 
307
350
  Example
308
351
  -------
@@ -440,6 +483,11 @@ class LinePlot:
440
483
  # data point. annotate_last_values reopens room on the right.
441
484
  ax.margins(x=0)
442
485
 
486
+ # a fixed (or group-shared) y range goes in BEFORE the annotations:
487
+ # they pack their labels at the final scale.
488
+ if self.y_limits is not None:
489
+ ax.set_ylim(*self.y_limits)
490
+
443
491
  if self.vlines:
444
492
  draw_vlines(ax, self.vlines)
445
493
 
@@ -472,6 +520,7 @@ class LinePlot:
472
520
  headroom_pad_pt=(
473
521
  compact_headroom_pad_pt() if self.compact else 0.0
474
522
  ),
523
+ grow_top=self.y_limits is None,
475
524
  )
476
525
 
477
526
  style_spines(
@@ -514,6 +563,10 @@ class LinePlot:
514
563
  )
515
564
  lo, hi = ax.get_ylim()
516
565
  ax.set_ylim(min(ref_line["y"], lo), max(ref_line["y"], hi))
566
+ if self.y_limits is not None:
567
+ # the baseline and the reference lines widen the range to show
568
+ # themselves; a fixed range wins over them.
569
+ ax.set_ylim(*self.y_limits)
517
570
 
518
571
  # per-series historical mean as a dotted same-colour line (the old
519
572
  # `medias=True`): the mean is taken over means_window (or the whole
@@ -27,7 +27,7 @@ Styling constants come from ``PLOT_CONFIG['matrix']``.
27
27
  from __future__ import annotations
28
28
 
29
29
  from pathlib import Path
30
- from typing import Any, Self
30
+ from typing import Any, ClassVar, Self
31
31
 
32
32
  import matplotlib as mpl
33
33
  import matplotlib.pyplot as plt
@@ -40,9 +40,9 @@ from matplotlib.patches import Rectangle
40
40
  from yaml.nodes import MappingNode
41
41
 
42
42
  from tesorotools.artists._common import (
43
+ FigureLifecycle,
43
44
  FIG_CONFIG,
44
45
  Format,
45
- dynamic_dpi,
46
46
  resolve_data,
47
47
  )
48
48
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
@@ -66,7 +66,7 @@ def _text_color(rgba: tuple[float, float, float, float]) -> str:
66
66
  return MATRIX_CONFIG["dark_text"]
67
67
 
68
68
 
69
- class MatrixChart:
69
+ class MatrixChart(FigureLifecycle):
70
70
  """Annotated heatmap-table artist (any ``n_rows x n_cols``).
71
71
 
72
72
  Parameters
@@ -102,6 +102,16 @@ class MatrixChart:
102
102
  cells read as tiles.
103
103
  """
104
104
 
105
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
106
+ #: (see :func:`plotly_unsupported`); checked against real renders
107
+ #: by ``tests/test_plotly_support.py``.
108
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
109
+ {
110
+ "cmap",
111
+ "figsize",
112
+ }
113
+ )
114
+
105
115
  def __init__(
106
116
  self,
107
117
  out_path: Path,
@@ -199,7 +209,11 @@ class MatrixChart:
199
209
 
200
210
  return matrix_to_figure(self)
201
211
 
202
- def plot(self) -> Axes:
212
+ def build(self) -> tuple[Figure, Axes]:
213
+ """Render the chart in memory; :meth:`save` writes it.
214
+
215
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
216
+ """
203
217
  """Render the chart and persist it to ``self.out_path``."""
204
218
  row_ids = list(self.rows)
205
219
  col_ids = list(self.cols)
@@ -260,11 +274,7 @@ class MatrixChart:
260
274
  )
261
275
 
262
276
  self._style_axes(ax, row_ids, col_ids)
263
- fig.savefig( # type: ignore[reportUnknownMemberType]
264
- self.out_path, dpi=dynamic_dpi(fig)
265
- )
266
- plt.close(fig)
267
- return ax
277
+ return fig, ax
268
278
 
269
279
  def _annotate_cell(
270
280
  self,
@@ -35,9 +35,11 @@ backends, per the dual-backend research):
35
35
  ``adapt_height``) do nothing here.
36
36
  * Beyond those, a few per-artist knobs have no stable Plotly equivalent
37
37
  and a few others are simply not translated yet. Both kinds are
38
- accepted in silence, so which is which is written down parameter by
39
- parameter in ``docs/plotly-backend.md`` (audited for ``LinePlot``).
40
- Keep that table in sync when adding or translating a parameter.
38
+ accepted in silence; each artist lists them in ``PLOTLY_UNSUPPORTED``
39
+ (read with :func:`tesorotools.artists.plotly_unsupported`), which
40
+ ``tests/test_plotly_support.py`` checks against real renders. When
41
+ translating a key, drop it from there and from the table in
42
+ ``docs/plotly-backend.md``.
41
43
  """
42
44
 
43
45
  from __future__ import annotations
@@ -300,6 +302,9 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
300
302
  # toggles (mirrors the matplotlib artist).
301
303
  if chart.fmt.units and chart.axis_units:
302
304
  fig.update_yaxes(ticksuffix=chart.fmt.units)
305
+ # fixed or group-shared y range (see share_y_axes)
306
+ if chart.y_limits is not None:
307
+ fig.update_yaxes(range=list(chart.y_limits))
303
308
  _legend_side(fig, chart.legend)
304
309
  return fig
305
310
 
@@ -0,0 +1,59 @@
1
+ """One y scale shared by several separate charts.
2
+
3
+ Two charts that are read side by side (US and European equities, both
4
+ base 100) mislead when each picks its own y range: the same move looks
5
+ bigger in the flatter one. Charts that declare the same ``y_group``
6
+ are measured together here and all get the union of their ranges as a
7
+ fixed ``y_limits``, which both backends honour (``build()`` for the PNG,
8
+ ``to_plotly()`` for the interactive figure).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable
14
+ from typing import Any
15
+
16
+ import matplotlib.pyplot as plt
17
+
18
+ __all__ = ["share_y_axes"]
19
+
20
+
21
+ def share_y_axes(charts: Iterable[Any]) -> dict[str, tuple[float, float]]:
22
+ """Give every chart of each ``y_group`` the same ``y_limits``.
23
+
24
+ *charts* is any iterable of artists, typically the values of the
25
+ mapping loaded from a charts YAML; the ones without a ``y_group``
26
+ (or with ``None``) are left untouched. Each grouped chart is built
27
+ once with its own natural range (the one it would draw alone,
28
+ including the room its end-of-series labels open), and the group
29
+ gets the lowest bottom and the highest top among its members. Call
30
+ it before ``plot()`` / ``to_plotly()``; calling it again measures
31
+ afresh, so it is safe after the data change.
32
+
33
+ Returns the shared ``(bottom, top)`` per group name.
34
+ """
35
+ groups: dict[str, list[Any]] = {}
36
+ for chart in charts:
37
+ group: Any = getattr(chart, "y_group", None)
38
+ if group is not None:
39
+ groups.setdefault(str(group), []).append(chart)
40
+
41
+ shared: dict[str, tuple[float, float]] = {}
42
+ for name, members in groups.items():
43
+ bottoms: list[float] = []
44
+ tops: list[float] = []
45
+ for chart in members:
46
+ # measure the chart's own range, not one shared earlier
47
+ chart.y_limits = None
48
+ fig, ax = chart.build()
49
+ try:
50
+ bottom, top = ax.get_ylim()
51
+ finally:
52
+ plt.close(fig)
53
+ bottoms.append(float(bottom))
54
+ tops.append(float(top))
55
+ limits = (min(bottoms), max(tops))
56
+ for chart in members:
57
+ chart.y_limits = limits
58
+ shared[name] = limits
59
+ return shared
@@ -30,7 +30,7 @@ from __future__ import annotations
30
30
 
31
31
  from dataclasses import dataclass
32
32
  from pathlib import Path
33
- from typing import Any, Self, cast
33
+ from typing import Any, ClassVar, Self, cast
34
34
 
35
35
  import matplotlib.pyplot as plt
36
36
  import pandas as pd
@@ -124,6 +124,18 @@ class ShockChart:
124
124
  value instead of each window's own anchor; must be a shock label.
125
125
  """
126
126
 
127
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
128
+ #: (see :func:`plotly_unsupported`); checked against real renders
129
+ #: by ``tests/test_plotly_support.py``.
130
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
131
+ {
132
+ "compact",
133
+ "figsize",
134
+ "fmt.decimals",
135
+ "legend.ncol",
136
+ }
137
+ )
138
+
127
139
  def __init__(
128
140
  self,
129
141
  out_path: Path,
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import Any, Self
4
+ from typing import Any, ClassVar, Self
5
5
 
6
6
  import matplotlib.pyplot as plt
7
7
  import numpy as np
@@ -11,6 +11,7 @@ from matplotlib.figure import Figure
11
11
  from yaml.nodes import MappingNode
12
12
 
13
13
  from tesorotools.artists._common import (
14
+ FigureLifecycle,
14
15
  AX_CONFIG,
15
16
  Format,
16
17
  Legend,
@@ -22,7 +23,6 @@ from tesorotools.artists._common import (
22
23
  compact_font_pt,
23
24
  date_maxticks,
24
25
  date_show_start_default,
25
- dynamic_dpi,
26
26
  legend_layout,
27
27
  place_legend,
28
28
  style_baseline,
@@ -32,7 +32,7 @@ from tesorotools.artists._common import (
32
32
  from tesorotools.utils.template import TemplateLoader
33
33
 
34
34
 
35
- class StackedAreaPlot:
35
+ class StackedAreaPlot(FigureLifecycle):
36
36
  """Stacked area chart with the tesorotools visual style.
37
37
 
38
38
  Parameters match ``LinePlot`` where applicable so chart
@@ -47,6 +47,21 @@ class StackedAreaPlot:
47
47
  the concise style.
48
48
  """
49
49
 
50
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
51
+ #: (see :func:`plotly_unsupported`); checked against real renders
52
+ #: by ``tests/test_plotly_support.py``.
53
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
54
+ {
55
+ "compact",
56
+ "date_show_start",
57
+ "figsize",
58
+ "fmt.decimals",
59
+ "font_pt",
60
+ "legend.ncol",
61
+ "plot_size",
62
+ }
63
+ )
64
+
50
65
  def __init__(
51
66
  self,
52
67
  out_path: Path,
@@ -159,7 +174,11 @@ class StackedAreaPlot:
159
174
 
160
175
  return stacked_area_to_figure(self)
161
176
 
162
- def plot(self) -> Axes:
177
+ def build(self) -> tuple[Figure, Axes]:
178
+ """Render the chart in memory; :meth:`save` writes it.
179
+
180
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
181
+ """
163
182
  plot_data = self._prepare_data()
164
183
 
165
184
  font_pt = compact_font_pt(self.font_pt) if self.compact else None
@@ -223,14 +242,10 @@ class StackedAreaPlot:
223
242
  if self.plot_size is not None and not self.compact:
224
243
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
225
244
 
226
- fig.savefig( # type: ignore[reportUnknownMemberType]
227
- self.out_path, dpi=dynamic_dpi(fig)
228
- )
229
- plt.close(fig)
230
- return ax
245
+ return fig, ax
231
246
 
232
247
 
233
- class StackedBarPlot:
248
+ class StackedBarPlot(FigureLifecycle):
234
249
  """Stacked bar chart with the tesorotools visual style.
235
250
 
236
251
  Positive and negative values are stacked separately so
@@ -240,6 +255,22 @@ class StackedBarPlot:
240
255
  reimplementing the full render pipeline.
241
256
  """
242
257
 
258
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
259
+ #: (see :func:`plotly_unsupported`); checked against real renders
260
+ #: by ``tests/test_plotly_support.py``.
261
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
262
+ {
263
+ "bar_width",
264
+ "compact",
265
+ "figsize",
266
+ "fmt.decimals",
267
+ "font_pt",
268
+ "legend.ncol",
269
+ "plot_size",
270
+ "x_rotation",
271
+ }
272
+ )
273
+
243
274
  def __init__(
244
275
  self,
245
276
  out_path: Path,
@@ -391,7 +422,11 @@ class StackedBarPlot:
391
422
 
392
423
  return stacked_bar_to_figure(self)
393
424
 
394
- def plot(self) -> Axes:
425
+ def build(self) -> tuple[Figure, Axes]:
426
+ """Render the chart in memory; :meth:`save` writes it.
427
+
428
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
429
+ """
395
430
  plot_data = self._prepare_data()
396
431
 
397
432
  font_pt = compact_font_pt(self.font_pt) if self.compact else None
@@ -478,8 +513,4 @@ class StackedBarPlot:
478
513
  if self.plot_size is not None and not self.compact:
479
514
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
480
515
 
481
- fig.savefig( # type: ignore[reportUnknownMemberType]
482
- self.out_path, dpi=dynamic_dpi(fig)
483
- )
484
- plt.close(fig)
485
- return ax
516
+ return fig, ax
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import Any, Self
4
+ from typing import Any, ClassVar, Self
5
5
 
6
6
  import matplotlib.pyplot as plt
7
7
  import pandas as pd
@@ -11,13 +11,13 @@ from pandas import Timestamp
11
11
  from yaml.nodes import MappingNode
12
12
 
13
13
  from tesorotools.artists._common import (
14
+ FigureLifecycle,
14
15
  AX_CONFIG,
15
16
  Format,
16
17
  Legend,
17
18
  resolve_data,
18
19
  compact_figure_kwargs,
19
20
  compact_font_pt,
20
- dynamic_dpi,
21
21
  legend_band,
22
22
  legend_layout,
23
23
  place_legend,
@@ -99,7 +99,7 @@ def _format_data(data: pd.DataFrame) -> dict[str, Any]:
99
99
  }
100
100
 
101
101
 
102
- class TypeCurve:
102
+ class TypeCurve(FigureLifecycle):
103
103
  """Type-curve chart with the tesorotools visual style.
104
104
 
105
105
  Plots two ranges (last year and current year) as filled
@@ -111,6 +111,18 @@ class TypeCurve:
111
111
  width, marker) come from ``PLOT_CONFIG['type_curve']``.
112
112
  """
113
113
 
114
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
115
+ #: (see :func:`plotly_unsupported`); checked against real renders
116
+ #: by ``tests/test_plotly_support.py``.
117
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
118
+ {
119
+ "compact",
120
+ "figsize",
121
+ "font_pt",
122
+ "legend.ncol",
123
+ }
124
+ )
125
+
114
126
  def __init__(
115
127
  self,
116
128
  out_path: Path,
@@ -215,7 +227,11 @@ class TypeCurve:
215
227
 
216
228
  return type_curve_to_figure(self)
217
229
 
218
- def plot(self) -> Axes:
230
+ def build(self) -> tuple[Figure, Axes]:
231
+ """Render the chart in memory; :meth:`save` writes it.
232
+
233
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
234
+ """
219
235
  plot_data = self.data.loc[:, list(self.series.keys())]
220
236
  plot_data = plot_data.rename(columns=self.series)
221
237
  plot_data = plot_data * self.scale
@@ -327,8 +343,4 @@ class TypeCurve:
327
343
  compact=False,
328
344
  ),
329
345
  )
330
- fig.savefig( # type: ignore[reportUnknownMemberType]
331
- self.out_path, dpi=dynamic_dpi(fig)
332
- )
333
- plt.close(fig)
334
- return ax
346
+ return fig, ax
@@ -26,7 +26,7 @@ constants come from ``PLOT_CONFIG['vector']``.
26
26
  from __future__ import annotations
27
27
 
28
28
  from pathlib import Path
29
- from typing import Any, Self
29
+ from typing import Any, ClassVar, Self
30
30
 
31
31
  import matplotlib.pyplot as plt
32
32
  import pandas as pd
@@ -36,10 +36,10 @@ from matplotlib.ticker import FuncFormatter
36
36
  from yaml.nodes import MappingNode
37
37
 
38
38
  from tesorotools.artists._common import (
39
+ FigureLifecycle,
39
40
  AX_CONFIG,
40
41
  FIG_CONFIG,
41
42
  Format,
42
- dynamic_dpi,
43
43
  resolve_data,
44
44
  )
45
45
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
@@ -50,7 +50,7 @@ __all__ = ["VectorPlot"]
50
50
  VECTOR_CONFIG: dict[str, Any] = PLOT_CONFIG["vector"]
51
51
 
52
52
 
53
- class VectorPlot:
53
+ class VectorPlot(FigureLifecycle):
54
54
  """Arrow chart showing the change in two variables.
55
55
 
56
56
  Each entity in ``series`` is rendered as an arrow from a
@@ -94,6 +94,17 @@ class VectorPlot:
94
94
  reproduces the dashed arrows used for outliers).
95
95
  """
96
96
 
97
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
98
+ #: (see :func:`plotly_unsupported`); checked against real renders
99
+ #: by ``tests/test_plotly_support.py``.
100
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
101
+ {
102
+ "figsize",
103
+ "x_fmt.decimals",
104
+ "y_fmt.decimals",
105
+ }
106
+ )
107
+
97
108
  def __init__(
98
109
  self,
99
110
  out_path: Path,
@@ -205,7 +216,11 @@ class VectorPlot:
205
216
  y0 = float(row[self.y_start]) * self.y_scale if self.y_start else 0.0
206
217
  return x0, y0, x1, y1
207
218
 
208
- def plot(self) -> Axes:
219
+ def build(self) -> tuple[Figure, Axes]:
220
+ """Render the chart in memory; :meth:`save` writes it.
221
+
222
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
223
+ """
209
224
  needed = [self.x, self.y]
210
225
  if self.x_start is not None:
211
226
  needed.append(self.x_start)
@@ -270,11 +285,7 @@ class VectorPlot:
270
285
  )
271
286
 
272
287
  self._style_axes(ax, xs, ys)
273
- fig.savefig( # type: ignore[reportUnknownMemberType]
274
- self.out_path, dpi=dynamic_dpi(fig)
275
- )
276
- plt.close(fig)
277
- return ax
288
+ return fig, ax
278
289
 
279
290
  def _style_axes(self, ax: Axes, xs: list[float], ys: list[float]) -> None:
280
291
  """Frame, origin cross-hairs, tick formatters and labels."""
@@ -17,7 +17,7 @@ row inside a Word table, reusing the shared compact helpers in
17
17
  from __future__ import annotations
18
18
 
19
19
  from pathlib import Path
20
- from typing import Any, Self
20
+ from typing import Any, ClassVar, Self
21
21
 
22
22
  import matplotlib.pyplot as plt
23
23
  import numpy as np
@@ -58,6 +58,18 @@ class Waterfall:
58
58
  last categories (opening and closing balance).
59
59
  """
60
60
 
61
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
62
+ #: (see :func:`plotly_unsupported`); checked against real renders
63
+ #: by ``tests/test_plotly_support.py``.
64
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
65
+ {
66
+ "compact",
67
+ "figsize",
68
+ "font_pt",
69
+ "plot_size",
70
+ }
71
+ )
72
+
61
73
  def __init__(
62
74
  self,
63
75
  out_path: Path,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tesorotools-python
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -1,4 +1,4 @@
1
- tesorotools/__init__.py,sha256=YQGi1L0Uo1B1ZFmjXFRSUFZI4kx7jeLFpdoi5GbRkJM,5296
1
+ tesorotools/__init__.py,sha256=BV31YV_qabD4vdXzoKb89MxXO386YE0xFjXqpDZSXaE,5334
2
2
  tesorotools/_build_context.py,sha256=MH2AKjkwuubN__sBQoXbfTD1bh5RE6w9l5yUFxkJxy8,1982
3
3
  tesorotools/_registry.py,sha256=ZJOr7jACxykvzoepebZDiJdTcR37Q3X-DQ07lm9ZYB0,7975
4
4
  tesorotools/driver.py,sha256=qEVhLYanbG7VXe3WrCCD0RJV_0X4xzhalrfABPB1gSI,5016
@@ -7,21 +7,22 @@ tesorotools/orchestration.py,sha256=24C-LTM4uy7eckjXBjJbC2t92fg6yffl5H-E9Gm2W9M,
7
7
  tesorotools/palette.py,sha256=fkf1_AxweRzviwKLb5zSZnWwP_Cr4zEYoqJ4jqIfBoE,1944
8
8
  tesorotools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  tesorotools/scaffold.py,sha256=6mYNxLm_1CbEMjcYZ77m_2H1eTotbpatFJqhgyote0s,4578
10
- tesorotools/artists/__init__.py,sha256=bxzJfN_Id2fPqIFYTrqZNpV8Hge3rH5jBe9fsrrb5Ow,1972
11
- tesorotools/artists/_common.py,sha256=rqRa2fSUNtHCHoWOReLrzLBJA8Yrf5drOwUd5vkCCPk,54726
12
- tesorotools/artists/bar_line.py,sha256=CBNoQpHBjHnmNn_Kum7dZy6oD7RMRc9OsIh03YMZW9g,13969
13
- tesorotools/artists/barh_plot.py,sha256=OcucXrdbamvMRxW9jnGwuvpBR_0f9N5800GRSFTiDYM,36511
14
- tesorotools/artists/box_plot.py,sha256=UgaL23_I0VTGGRhffF3uGz1HPGM7FOjSq9szNf1EdwQ,19916
10
+ tesorotools/artists/__init__.py,sha256=-w2IzVdT7iatJien_5JU9fHepx2kTcYcYgrjQXWZYRI,2492
11
+ tesorotools/artists/_common.py,sha256=k59YvyxIlTSEUPU6-koGksOdEKvDcwjhsdPV0LxRCLo,59272
12
+ tesorotools/artists/bar_line.py,sha256=4urcPuHEfohl4WI3pkb2I__1WZ2W73lDiGoqlwFA27w,14477
13
+ tesorotools/artists/barh_plot.py,sha256=n8DjIX1APCVS7HW4zzfh6ehggdgBqb6sezQHov1k7us,37520
14
+ tesorotools/artists/box_plot.py,sha256=KPQjbY2H5JWJWF_icApqpvE1f6sE4e56neIS_dRL6w8,20373
15
15
  tesorotools/artists/compact.py,sha256=e_0tqkRSIgyiyMSwirJzj2TYSKojMxUHzug5tE_7toc,3003
16
16
  tesorotools/artists/intraday_plot.py,sha256=TO-lKwwbIPxx4T-ObYHNuGGR3F_-GZ6XQGwrYU6tyL8,13259
17
- tesorotools/artists/line_plot.py,sha256=IpuZry0nljJa8pPQsjyCte5IhKHCmEZJwHeh7XsGzjo,28973
18
- tesorotools/artists/matrix.py,sha256=NRql8mmwEdH3XOJ6CsAm_PMI4YRGdz7qMOyTgQlJO_k,11421
19
- tesorotools/artists/plotly_backend.py,sha256=FpSpihjwaj3w9aye2w4aNLx1GHp6z3ta3pqxkB2QOgE,36828
20
- tesorotools/artists/shock_plot.py,sha256=A-X7Yr1vFHwyCk4qEEMLgqxTRPy2NBRo3fgRJPdpox4,13052
21
- tesorotools/artists/stacked.py,sha256=2jrEWh211m0NsLlWkKz22uyioxMfR2d5Qs6sBQnMciY,16431
22
- tesorotools/artists/type_curve.py,sha256=h48pFhOtNkS2nMq4e8sWUBFIxrlhlnfZto2xrciefGg,12153
23
- tesorotools/artists/vector_plot.py,sha256=XzBgFDWvAws_g6X8a_fm2tAe01zgMKwDNol0zLD6qCc,11807
24
- tesorotools/artists/waterfall.py,sha256=bFr7IW3vTf7ZN64SnxhbNm2AlGOh4b9IpWkXQEbzTPE,11972
17
+ tesorotools/artists/line_plot.py,sha256=yqg7dHzzV_xfiwiWIlvNN6DVXQ9wjRBdOsiJYvG0iJY,31157
18
+ tesorotools/artists/matrix.py,sha256=cOpj9ZO5RR0-RlIP55UmD70i-738ck8wZIqRiDO22Hw,11785
19
+ tesorotools/artists/plotly_backend.py,sha256=yaRnZayxgTbNVuiJW1p5l10mo5UFMTslA_QK-nBkZsM,37067
20
+ tesorotools/artists/sharing.py,sha256=Tg4AYtv0ileKQmzBSgmT1GzxA97tdU8ji_arE0oGEZ4,2236
21
+ tesorotools/artists/shock_plot.py,sha256=Vx9RRvRaJZIXh8G7oJSRRc1XtPgGkqA6xaVk4h4oJuE,13435
22
+ tesorotools/artists/stacked.py,sha256=MyItTb8NMOpR6jDclqjmofhAB-L9mD1vnzGQjXUTFBc,17439
23
+ tesorotools/artists/type_curve.py,sha256=0j1LjfS4Zdz4_W7qp-nY8VP0H-fENV5adUg4syAOvig,12570
24
+ tesorotools/artists/vector_plot.py,sha256=P7BtLCXRTWs3eepRFYvXzbJ9OkvB-Ya3kmXB6yJSkR0,12211
25
+ tesorotools/artists/waterfall.py,sha256=S2w6LuMb9P71FA9aUygGd6KRAEQx8tfGps267YmLlSQ,12348
25
26
  tesorotools/assets/README.md,sha256=EjDuA4Xf-Omn0Khp8erNXdW-iyWBzSM2FQFexdrS6Eg,285
26
27
  tesorotools/assets/plots.yaml,sha256=shIDoQTYveZ7IWnG_5QAXESzq9trcRpkjtry2yXQsE0,11162
27
28
  tesorotools/assets/template.docx,sha256=QYilJmJ-ltGPH_GH22j6Kis7wmNaAgFQXr68d1KUSHY,94394
@@ -129,7 +130,7 @@ tesorotools/web/templates/src/$package/main.py.tmpl,sha256=GXG3AA87WOjk5nTKA5Ywm
129
130
  tesorotools/web/templates/src/$package/sections.py.tmpl,sha256=4MNZ85AW5ERG1SrkVAXIwdmAMeQEBARCYdAMqUr37mg,2084
130
131
  tesorotools/web/templates/tests/__init__.py.tmpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
131
132
  tesorotools/web/templates/tests/test_app.py.tmpl,sha256=ZGfV5GPN6WsS3a2gCNLPWejkHTX2H_N9XpurIADuUxI,615
132
- tesorotools_python-0.1.0.dist-info/METADATA,sha256=sbVZYK0lrHGJQ4VClTTOvYd3KEKpL1jXxoioHNyQHrg,993
133
- tesorotools_python-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
134
- tesorotools_python-0.1.0.dist-info/entry_points.txt,sha256=PuNrObu4SVvajBHTASuWZPqwSsV7tPHwFJBzYk2nY5o,108
135
- tesorotools_python-0.1.0.dist-info/RECORD,,
133
+ tesorotools_python-0.1.2.dist-info/METADATA,sha256=ARJoWsUX5H4GXfcoU7EH_sVTr5aUusS-9OpkhfTVm-A,993
134
+ tesorotools_python-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
135
+ tesorotools_python-0.1.2.dist-info/entry_points.txt,sha256=PuNrObu4SVvajBHTASuWZPqwSsV7tPHwFJBzYk2nY5o,108
136
+ tesorotools_python-0.1.2.dist-info/RECORD,,