tesorotools-python 0.0.25__tar.gz → 0.0.26__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 (68) hide show
  1. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/pyproject.toml +1 -1
  3. tesorotools_python-0.0.26/src/tesorotools/artists/__init__.py +5 -0
  4. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/artists/line_plot.py +131 -4
  5. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/artists/stacked.py +50 -6
  6. tesorotools_python-0.0.26/src/tesorotools/pipeline/diagnose.py +54 -0
  7. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/pipeline/rules.py +47 -3
  8. tesorotools_python-0.0.26/src/tesorotools/render/__init__.py +0 -0
  9. tesorotools_python-0.0.26/src/tesorotools/render/content/__init__.py +0 -0
  10. tesorotools_python-0.0.26/src/tesorotools/utils/config.py +98 -0
  11. tesorotools_python-0.0.25/src/tesorotools/__init__.py +0 -9
  12. tesorotools_python-0.0.25/src/tesorotools/artists/__init__.py +0 -9
  13. tesorotools_python-0.0.25/src/tesorotools/render/__init__.py +0 -17
  14. tesorotools_python-0.0.25/src/tesorotools/utils/config.py +0 -38
  15. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/.gitignore +0 -0
  16. {tesorotools_python-0.0.25/src/tesorotools/data_sources → tesorotools_python-0.0.26/src/tesorotools}/__init__.py +0 -0
  17. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/artists/barh.md +0 -0
  18. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/artists/barh_plot.py +0 -0
  19. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/artists/table.py +0 -0
  20. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/artists/type_curve.py +0 -0
  21. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/README.md +0 -0
  22. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  23. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  24. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  25. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  26. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  27. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  28. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  29. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  30. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/fonts/README.md +0 -0
  31. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/plots.yaml +0 -0
  32. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  33. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/convert.py +0 -0
  34. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/README.md +0 -0
  35. {tesorotools_python-0.0.25/src/tesorotools/dependencies → tesorotools_python-0.0.26/src/tesorotools/data_sources}/__init__.py +0 -0
  36. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/debug.py +0 -0
  37. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/data_sources/lseg.py +0 -0
  38. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/database/__init__.py +0 -0
  39. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/database/local.py +0 -0
  40. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/database/push.py +0 -0
  41. {tesorotools_python-0.0.25/src/tesorotools/offsets → tesorotools_python-0.0.26/src/tesorotools/dependencies}/__init__.py +0 -0
  42. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/dependencies/node.py +0 -0
  43. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/dependencies/resolution.py +0 -0
  44. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/main.py +0 -0
  45. {tesorotools_python-0.0.25/src/tesorotools/pipeline → tesorotools_python-0.0.26/src/tesorotools/offsets}/__init__.py +0 -0
  46. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/offsets/offsets.py +0 -0
  47. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/offsets/outliers.py +0 -0
  48. {tesorotools_python-0.0.25/src/tesorotools/providers → tesorotools_python-0.0.26/src/tesorotools/pipeline}/__init__.py +0 -0
  49. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/pipeline/engine.py +0 -0
  50. {tesorotools_python-0.0.25/src/tesorotools/render/content → tesorotools_python-0.0.26/src/tesorotools/providers}/__init__.py +0 -0
  51. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/providers/base.py +0 -0
  52. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/providers/bde.py +0 -0
  53. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/py.typed +0 -0
  54. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/content/content.py +0 -0
  55. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/content/images.py +0 -0
  56. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/content/section.py +0 -0
  57. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/content/subtitle.py +0 -0
  58. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/content/table.py +0 -0
  59. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/content/text.py +0 -0
  60. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/content/title.py +0 -0
  61. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/render/report.py +0 -0
  62. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/utils/__init__.py +0 -0
  63. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/utils/format.py +0 -0
  64. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/utils/globals.py +0 -0
  65. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/utils/matplotlib.py +0 -0
  66. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/utils/series.py +0 -0
  67. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/utils/shortcuts.py +0 -0
  68. {tesorotools_python-0.0.25 → tesorotools_python-0.0.26}/src/tesorotools/utils/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.25
3
+ Version: 0.0.26
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: eikon>=1.1
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "tesorotools-python"
3
3
  requires-python = ">=3.13"
4
- version = "0.0.25"
4
+ version = "0.0.26"
5
5
  dependencies = [
6
6
  # database and ORM
7
7
  "psycopg[binary]>=3.1",
@@ -0,0 +1,5 @@
1
+ import matplotlib.style
2
+
3
+ from ..utils.globals import STYLE_SHEET
4
+
5
+ matplotlib.style.use(STYLE_SHEET)
@@ -26,6 +26,76 @@ AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
26
26
  FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
27
27
 
28
28
 
29
+ def adjust_figure_for_plot_size(
30
+ fig: Figure,
31
+ ax: Axes,
32
+ plot_size: tuple[float, float],
33
+ ) -> None:
34
+ """Resize the figure so the axes area matches *plot_size*.
35
+
36
+ Call this **after** adding the legend. The figure
37
+ height grows to accommodate the legend while keeping
38
+ the plot area at the requested size.
39
+
40
+ Parameters
41
+ ----------
42
+ fig
43
+ The figure to resize.
44
+ ax
45
+ The axes whose area should match *plot_size*.
46
+ plot_size
47
+ Desired ``(width, height)`` of the axes area in
48
+ inches.
49
+ """
50
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
51
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
52
+ ax_bbox = ax.get_tightbbox(renderer) # type: ignore[reportUnknownArgumentType]
53
+ fig_bbox = fig.get_tightbbox(renderer) # type: ignore[reportUnknownArgumentType]
54
+ if ax_bbox is None or fig_bbox is None: # type: ignore[reportUnnecessaryComparison]
55
+ return
56
+ fig_w, fig_h = fig.get_size_inches()
57
+ ax_w_in = ax_bbox.width / fig.dpi
58
+ ax_h_in = ax_bbox.height / fig.dpi
59
+ if ax_w_in <= 0 or ax_h_in <= 0:
60
+ return
61
+ new_w = fig_w * (plot_size[0] / ax_w_in)
62
+ new_h = fig_h * (plot_size[1] / ax_h_in)
63
+ fig.set_size_inches(new_w, new_h)
64
+
65
+
66
+ def auto_ncol(ax: Axes, labels: list[str]) -> int:
67
+ """Choose the maximum legend ncol that fits the axes.
68
+
69
+ Renders each label as a temporary Text to measure its
70
+ width, then packs as many columns as the axes width
71
+ allows, with padding for the legend handle + spacing.
72
+ Falls back to ``len(labels)`` (single row) when all
73
+ labels are short enough.
74
+ """
75
+ fig = ax.get_figure()
76
+ if fig is None:
77
+ return len(labels)
78
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
79
+ ax_width_px = ax.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
80
+
81
+ # Measure widest label in pixels.
82
+ handle_pad_px = 40.0 # handle icon + spacing estimate
83
+ max_label_px = 0.0
84
+ for label in labels:
85
+ t = ax.text( # type: ignore[reportUnknownMemberType]
86
+ 0, 0, label
87
+ )
88
+ bbox = t.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
89
+ max_label_px = max(max_label_px, bbox.width)
90
+ t.remove()
91
+
92
+ col_width = max_label_px + handle_pad_px
93
+ if col_width <= 0:
94
+ return len(labels)
95
+ ncol = max(1, int(ax_width_px / col_width))
96
+ return min(ncol, len(labels))
97
+
98
+
29
99
  def style_spines(
30
100
  ax: Axes,
31
101
  decimals: int,
@@ -62,6 +132,36 @@ def style_spines(
62
132
  tick.set_markeredgecolor(color)
63
133
 
64
134
 
135
+ def export_legend(
136
+ ax: Axes,
137
+ out_path: Path,
138
+ *,
139
+ ncol: int = 5,
140
+ dpi: int = 500,
141
+ ) -> None:
142
+ """Save the legend of *ax* as a standalone PNG.
143
+
144
+ The plot's own legend is removed after export.
145
+ """
146
+ handles, labels = ax.get_legend_handles_labels()
147
+ fig_leg = plt.figure( # type: ignore[reportUnknownMemberType]
148
+ figsize=(6, 0.5), dpi=dpi
149
+ )
150
+ fig_leg.legend( # type: ignore[reportUnknownMemberType]
151
+ handles,
152
+ labels,
153
+ loc="center",
154
+ ncol=ncol,
155
+ )
156
+ fig_leg.savefig( # type: ignore[reportUnknownMemberType]
157
+ out_path, bbox_inches="tight"
158
+ )
159
+ plt.close(fig_leg)
160
+ legend = ax.get_legend()
161
+ if legend is not None:
162
+ legend.remove()
163
+
164
+
65
165
  def style_baseline(
66
166
  ax: Axes,
67
167
  reference: float = 0,
@@ -161,7 +261,11 @@ class Format:
161
261
 
162
262
 
163
263
  class Legend:
164
- def __init__(self, ncol: int = 5, sep: float = -0.125) -> None:
264
+ def __init__(
265
+ self,
266
+ ncol: int | None = None,
267
+ sep: float = -0.125,
268
+ ) -> None:
165
269
  self.ncol = ncol
166
270
  self.sep = sep
167
271
 
@@ -190,6 +294,9 @@ class LinePlot:
190
294
  format: Format | None = None,
191
295
  legend: Legend | None = None,
192
296
  data: pd.DataFrame | None = None,
297
+ figsize: tuple[float, float] | None = None,
298
+ series_styles: dict[str, dict[str, Any]] | None = None,
299
+ plot_size: tuple[float, float] | None = None,
193
300
  ) -> None:
194
301
 
195
302
  if out_path.suffix != ".png":
@@ -221,6 +328,9 @@ class LinePlot:
221
328
  self.legend = legend
222
329
  self.baseline = baseline
223
330
  self.scale = scale
331
+ self.figsize = figsize
332
+ self.series_styles = series_styles or {}
333
+ self.plot_size = plot_size
224
334
 
225
335
  @classmethod
226
336
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -255,11 +365,19 @@ class LinePlot:
255
365
  if self.base_100: # maybe more flexible in the future
256
366
  plot_data = plot_data / plot_data.iloc[0, :] * 100
257
367
 
368
+ fig_kw = dict(FIG_CONFIG)
369
+ if self.figsize is not None:
370
+ fig_kw["figsize"] = self.figsize
258
371
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
259
- **FIG_CONFIG
372
+ **fig_kw
260
373
  )
261
374
  ax = fig.add_subplot()
262
- plot_data.plot(ax=ax)
375
+ if self.series_styles:
376
+ for col in plot_data.columns:
377
+ style = self.series_styles.get(col, {})
378
+ plot_data[col].plot(ax=ax, label=col, **style)
379
+ else:
380
+ plot_data.plot(ax=ax)
263
381
 
264
382
  if self.annotate: # not implemented yet
265
383
  pass
@@ -276,19 +394,28 @@ class LinePlot:
276
394
  style_baseline(ax, reference, **AX_CONFIG["baseline"])
277
395
 
278
396
  if self.legend is not None:
397
+ labels = list(plot_data.columns)
398
+ ncol = (
399
+ self.legend.ncol
400
+ if self.legend.ncol is not None
401
+ else auto_ncol(ax, labels)
402
+ )
279
403
  ax.legend( # type: ignore[reportUnknownMemberType]
280
404
  loc="upper center",
281
405
  bbox_to_anchor=(
282
406
  0.5,
283
407
  LINE_PLOT_CONFIG["legend_sep"],
284
408
  ),
285
- ncol=self.legend.ncol,
409
+ ncol=ncol,
286
410
  )
287
411
  else:
288
412
  ax.legend().set_visible( # type: ignore[reportUnknownMemberType]
289
413
  False
290
414
  )
291
415
 
416
+ if self.plot_size is not None:
417
+ adjust_figure_for_plot_size(fig, ax, self.plot_size)
418
+
292
419
  fig.savefig( # type: ignore[reportUnknownMemberType]
293
420
  self.out_path
294
421
  )
@@ -15,12 +15,13 @@ from tesorotools.artists.line_plot import (
15
15
  FIG_CONFIG,
16
16
  Format,
17
17
  Legend,
18
+ adjust_figure_for_plot_size,
19
+ auto_ncol,
18
20
  style_baseline,
19
21
  style_spines,
20
22
  )
21
23
  from tesorotools.utils.config import TemplateLoader
22
24
 
23
- _DEFAULT_NCOL = 5
24
25
  _DEFAULT_SEP = -0.125
25
26
 
26
27
 
@@ -44,6 +45,8 @@ class StackedAreaPlot:
44
45
  baseline: bool = False,
45
46
  format: Format | None = None,
46
47
  legend: Legend | None = None,
48
+ figsize: tuple[float, float] | None = None,
49
+ plot_size: tuple[float, float] | None = None,
47
50
  ) -> None:
48
51
  if out_path.suffix != ".png":
49
52
  raise ValueError(f"out_path must be .png: {out_path}")
@@ -56,6 +59,8 @@ class StackedAreaPlot:
56
59
  self.baseline = baseline
57
60
  self.format = format or Format()
58
61
  self.legend = legend
62
+ self.figsize = figsize
63
+ self.plot_size = plot_size
59
64
 
60
65
  @classmethod
61
66
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -82,8 +87,11 @@ class StackedAreaPlot:
82
87
  plot_data = self.data.loc[start:end, list(self.series.keys())].dropna()
83
88
  plot_data = plot_data * self.scale
84
89
 
90
+ fig_kw = dict(FIG_CONFIG)
91
+ if self.figsize is not None:
92
+ fig_kw["figsize"] = self.figsize
85
93
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
86
- **FIG_CONFIG
94
+ **fig_kw
87
95
  )
88
96
  ax: Axes = fig.add_subplot()
89
97
 
@@ -107,7 +115,8 @@ class StackedAreaPlot:
107
115
  if self.baseline:
108
116
  style_baseline(ax, 0, **AX_CONFIG["baseline"])
109
117
 
110
- ncol = self.legend.ncol if self.legend else _DEFAULT_NCOL
118
+ legend_ncol = self.legend.ncol if self.legend else None
119
+ ncol = legend_ncol if legend_ncol is not None else auto_ncol(ax, labels)
111
120
  sep = self.legend.sep if self.legend else _DEFAULT_SEP
112
121
  ax.legend( # type: ignore[reportUnknownMemberType]
113
122
  loc="upper center",
@@ -115,6 +124,9 @@ class StackedAreaPlot:
115
124
  ncol=ncol,
116
125
  )
117
126
 
127
+ if self.plot_size is not None:
128
+ adjust_figure_for_plot_size(fig, ax, self.plot_size)
129
+
118
130
  fig.savefig( # type: ignore[reportUnknownMemberType]
119
131
  self.out_path
120
132
  )
@@ -141,6 +153,9 @@ class StackedBarPlot:
141
153
  baseline: bool = True,
142
154
  format: Format | None = None,
143
155
  legend: Legend | None = None,
156
+ figsize: tuple[float, float] | None = None,
157
+ overlay_series: dict[str, str] | None = None,
158
+ plot_size: tuple[float, float] | None = None,
144
159
  ) -> None:
145
160
  if out_path.suffix != ".png":
146
161
  raise ValueError(f"out_path must be .png: {out_path}")
@@ -153,6 +168,9 @@ class StackedBarPlot:
153
168
  self.baseline = baseline
154
169
  self.format = format or Format()
155
170
  self.legend = legend
171
+ self.plot_size = plot_size
172
+ self.figsize = figsize
173
+ self.overlay_series = overlay_series or {}
156
174
 
157
175
  @classmethod
158
176
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -176,11 +194,15 @@ class StackedBarPlot:
176
194
  else self.data.index.max()
177
195
  )
178
196
 
179
- plot_data = self.data.loc[start:end, list(self.series.keys())].dropna()
197
+ all_cols = list(self.series.keys()) + list(self.overlay_series.keys())
198
+ plot_data = self.data.loc[start:end, all_cols].dropna()
180
199
  plot_data = plot_data * self.scale
181
200
 
201
+ fig_kw = dict(FIG_CONFIG)
202
+ if self.figsize is not None:
203
+ fig_kw["figsize"] = self.figsize
182
204
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
183
- figsize=(12, 6), **FIG_CONFIG
205
+ **fig_kw
184
206
  )
185
207
  ax: Axes = fig.add_subplot()
186
208
 
@@ -229,6 +251,17 @@ class StackedBarPlot:
229
251
  pos_bottom = pos_bottom + pos
230
252
  neg_bottom = neg_bottom + neg
231
253
 
254
+ for o_col, o_label in self.overlay_series.items():
255
+ o_vals = plot_data[o_col].to_numpy(dtype=np.float64)
256
+ ax.plot( # type: ignore[reportUnknownMemberType]
257
+ x,
258
+ o_vals,
259
+ color="black",
260
+ linewidth=1.5,
261
+ label=o_label,
262
+ zorder=10,
263
+ )
264
+
232
265
  dates = plot_data.index
233
266
  step = max(1, len(dates) // 12)
234
267
  tick_pos = list(range(0, len(dates), step))
@@ -249,7 +282,15 @@ class StackedBarPlot:
249
282
  if self.baseline:
250
283
  style_baseline(ax, 0, **AX_CONFIG["baseline"])
251
284
 
252
- ncol = self.legend.ncol if self.legend else _DEFAULT_NCOL
285
+ all_labels = list(self.series.values()) + list(
286
+ self.overlay_series.values()
287
+ )
288
+ legend_ncol = self.legend.ncol if self.legend else None
289
+ ncol = (
290
+ legend_ncol
291
+ if legend_ncol is not None
292
+ else auto_ncol(ax, all_labels)
293
+ )
253
294
  sep = self.legend.sep if self.legend else _DEFAULT_SEP
254
295
  ax.legend( # type: ignore[reportUnknownMemberType]
255
296
  loc="upper center",
@@ -257,6 +298,9 @@ class StackedBarPlot:
257
298
  ncol=ncol,
258
299
  )
259
300
 
301
+ if self.plot_size is not None:
302
+ adjust_figure_for_plot_size(fig, ax, self.plot_size)
303
+
260
304
  fig.savefig( # type: ignore[reportUnknownMemberType]
261
305
  self.out_path
262
306
  )
@@ -0,0 +1,54 @@
1
+ """Diagnostic utilities for pipeline configuration.
2
+
3
+ Helps identify unused series in a catalog — series that
4
+ are downloaded but never appear as a dependency of any
5
+ rule or in any chart configuration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from tesorotools.dependencies.resolution import (
13
+ collect_document_series,
14
+ )
15
+ from tesorotools.pipeline.engine import TransformationRule
16
+
17
+
18
+ def unused_series(
19
+ catalog: dict[str, Any],
20
+ rules: list[TransformationRule],
21
+ charts: dict[str, Any] | list[dict[str, Any]] | None = None,
22
+ ) -> set[str]:
23
+ """Find catalog series not referenced by rules or charts.
24
+
25
+ Parameters
26
+ ----------
27
+ catalog
28
+ Instrument catalog (canonical_id -> metadata).
29
+ rules
30
+ All transformation rules for the project.
31
+ charts
32
+ Chart config dict(s). Can be a single dict or a
33
+ list of dicts (barh, line, type_curve, tables).
34
+
35
+ Returns
36
+ -------
37
+ set[str]
38
+ Canonical IDs present in the catalog but not used
39
+ as a dependency of any rule nor referenced in any
40
+ chart config.
41
+ """
42
+ all_ids = set(catalog.keys())
43
+
44
+ used: set[str] = set()
45
+ for rule in rules:
46
+ used.add(rule.output_name)
47
+ used.update(rule.dependencies)
48
+
49
+ if charts is not None:
50
+ chart_list = charts if isinstance(charts, list) else [charts]
51
+ for name in collect_document_series(chart_list):
52
+ used.add(name)
53
+
54
+ return all_ids - used
@@ -91,8 +91,10 @@ def inverse_rule(
91
91
  )
92
92
 
93
93
 
94
- def yoy_rule(output: str, source: str, periods: int = 12) -> TransformationRule:
95
- """Year-over-year growth rate.
94
+ def pct_change_rule(
95
+ output: str, source: str, periods: int = 12
96
+ ) -> TransformationRule:
97
+ """Periodic percentage change: source_t / source_{t-N} - 1.
96
98
 
97
99
  Drops NaN before shifting so that *periods* counts
98
100
  actual observations, not DataFrame rows.
@@ -137,6 +139,46 @@ def rolling_sum_rule(
137
139
  )
138
140
 
139
141
 
142
+ def delta_rule(
143
+ output: str, source: str, periods: int = 1
144
+ ) -> TransformationRule:
145
+ """Level change: source_t - source_{t-periods}.
146
+
147
+ Drops NaN before shifting (mixed-frequency safe).
148
+ """
149
+
150
+ def _compute(
151
+ df: pd.DataFrame,
152
+ s: str = source,
153
+ p: int = periods,
154
+ ) -> pd.Series[float]:
155
+ clean: pd.Series[float] = df[s].dropna()
156
+ return clean - clean.shift(p)
157
+
158
+ return TransformationRule(
159
+ output_name=output,
160
+ dependencies=[source],
161
+ compute=_compute,
162
+ )
163
+
164
+
165
+ def cumsum_rule(output: str, source: str) -> TransformationRule:
166
+ """Cumulative sum of a column.
167
+
168
+ Drops NaN before cumsum (mixed-frequency safe).
169
+ """
170
+
171
+ def _compute(df: pd.DataFrame, s: str = source) -> pd.Series[float]:
172
+ clean: pd.Series[float] = df[s].dropna()
173
+ return clean.cumsum()
174
+
175
+ return TransformationRule(
176
+ output_name=output,
177
+ dependencies=[source],
178
+ compute=_compute,
179
+ )
180
+
181
+
140
182
  #: Registry of factory functions, keyed by YAML function name.
141
183
  #: Projects can add custom factories at runtime.
142
184
  FACTORIES: dict[
@@ -148,6 +190,8 @@ FACTORIES: dict[
148
190
  "ratio": ratio_rule,
149
191
  "difference": difference_rule,
150
192
  "inverse": inverse_rule,
151
- "yoy": yoy_rule,
193
+ "pct_change": pct_change_rule,
152
194
  "rolling_sum": rolling_sum_rule,
195
+ "delta": delta_rule,
196
+ "cumsum": cumsum_rule,
153
197
  }
@@ -0,0 +1,98 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ import yaml
5
+
6
+ from tesorotools.utils.template import TemplateLoader
7
+
8
+ _tags_registered = False
9
+
10
+
11
+ def _register_all_tags() -> None:
12
+ """Register every YAML tag on TemplateLoader.
13
+
14
+ Called once, lazily, the first time ``read_config`` is
15
+ invoked with ``loader=TemplateLoader``. This removes
16
+ the need for consumers to import ``tesorotools.render``
17
+ or ``tesorotools.artists`` just for their side effects.
18
+ """
19
+ global _tags_registered # noqa: PLW0603
20
+ if _tags_registered:
21
+ return
22
+ _tags_registered = True
23
+
24
+ # -- artists tags --
25
+ from tesorotools.artists.barh_plot import (
26
+ HorizontalBarChart,
27
+ )
28
+ from tesorotools.artists.line_plot import (
29
+ Format,
30
+ Legend,
31
+ LinePlot,
32
+ )
33
+ from tesorotools.artists.stacked import (
34
+ StackedAreaPlot,
35
+ StackedBarPlot,
36
+ )
37
+
38
+ TemplateLoader.add_constructor("!line_plot", LinePlot.from_yaml)
39
+ TemplateLoader.add_constructor("!format", Format.from_yaml)
40
+ TemplateLoader.add_constructor("!legend", Legend.from_yaml)
41
+ TemplateLoader.add_constructor("!stacked_area", StackedAreaPlot.from_yaml)
42
+ TemplateLoader.add_constructor("!stacked_bar", StackedBarPlot.from_yaml)
43
+ TemplateLoader.add_constructor("!barh", HorizontalBarChart.from_yaml)
44
+
45
+ # -- render tags --
46
+ from tesorotools.render.content.images import (
47
+ Image,
48
+ Images,
49
+ )
50
+ from tesorotools.render.content.section import Section
51
+ from tesorotools.render.content.subtitle import Subtitle
52
+ from tesorotools.render.content.table import Table
53
+ from tesorotools.render.content.text import Text
54
+ from tesorotools.render.content.title import Title
55
+ from tesorotools.render.report import Report
56
+
57
+ TemplateLoader.add_constructor("!report", Report.from_yaml)
58
+ TemplateLoader.add_constructor("!section", Section.from_yaml)
59
+ TemplateLoader.add_constructor("!image", Image.from_yaml)
60
+ TemplateLoader.add_constructor("!images", Images.from_yaml)
61
+ TemplateLoader.add_constructor("!table", Table.from_yaml)
62
+ TemplateLoader.add_constructor("!text", Text.from_yaml)
63
+ TemplateLoader.add_constructor("!title", Title.from_yaml)
64
+ TemplateLoader.add_constructor("!subtitle", Subtitle.from_yaml)
65
+
66
+
67
+ def clean_config_dicts(
68
+ config_dicts: dict[str, Any],
69
+ ) -> dict[str, Any]:
70
+ return {k: v for k, v in config_dicts.items() if not k.startswith(".")}
71
+
72
+
73
+ def read_config(
74
+ config_file: Path,
75
+ loader: type[yaml.FullLoader] | None = None,
76
+ clean: bool = True,
77
+ ) -> Any:
78
+ actual_loader: type[yaml.FullLoader] = (
79
+ yaml.FullLoader if loader is None else TemplateLoader
80
+ )
81
+ if actual_loader is TemplateLoader:
82
+ _register_all_tags()
83
+ with open(config_file, encoding="utf8") as file:
84
+ config_dict: Any = yaml.load(file, Loader=actual_loader)
85
+ if clean and isinstance(config_dict, dict):
86
+ config_dict = clean_config_dicts(config_dict) # type: ignore[reportUnknownArgumentType]
87
+ return config_dict
88
+
89
+
90
+ def merge(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
91
+ # a overrides
92
+ for key in b:
93
+ if key in a:
94
+ if isinstance(a[key], dict) and isinstance(b[key], dict):
95
+ merge(a[key], b[key])
96
+ else:
97
+ a[key] = b[key]
98
+ return a
@@ -1,9 +0,0 @@
1
- from tesorotools.artists.line_plot import Format, Legend, LinePlot
2
- from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
3
- from tesorotools.utils.config import TemplateLoader
4
-
5
- TemplateLoader.add_constructor("!line_plot", LinePlot.from_yaml)
6
- TemplateLoader.add_constructor("!format", Format.from_yaml)
7
- TemplateLoader.add_constructor("!legend", Legend.from_yaml)
8
- TemplateLoader.add_constructor("!stacked_area", StackedAreaPlot.from_yaml)
9
- TemplateLoader.add_constructor("!stacked_bar", StackedBarPlot.from_yaml)
@@ -1,9 +0,0 @@
1
- import matplotlib.style
2
-
3
- from tesorotools.artists.barh_plot import HorizontalBarChart
4
- from tesorotools.utils.config import TemplateLoader
5
-
6
- from ..utils.globals import STYLE_SHEET
7
-
8
- matplotlib.style.use(STYLE_SHEET)
9
- TemplateLoader.add_constructor("!barh", HorizontalBarChart.from_yaml)
@@ -1,17 +0,0 @@
1
- from tesorotools.render.content.images import Image, Images
2
- from tesorotools.render.content.section import Section
3
- from tesorotools.render.content.subtitle import Subtitle
4
- from tesorotools.render.content.table import Table
5
- from tesorotools.render.content.text import Text
6
- from tesorotools.render.content.title import Title
7
- from tesorotools.render.report import Report
8
- from tesorotools.utils.template import TemplateLoader
9
-
10
- TemplateLoader.add_constructor("!report", Report.from_yaml)
11
- TemplateLoader.add_constructor("!section", Section.from_yaml)
12
- TemplateLoader.add_constructor("!image", Image.from_yaml)
13
- TemplateLoader.add_constructor("!images", Images.from_yaml)
14
- TemplateLoader.add_constructor("!table", Table.from_yaml)
15
- TemplateLoader.add_constructor("!text", Text.from_yaml)
16
- TemplateLoader.add_constructor("!title", Title.from_yaml)
17
- TemplateLoader.add_constructor("!subtitle", Subtitle.from_yaml)
@@ -1,38 +0,0 @@
1
- from pathlib import Path
2
- from typing import Any
3
-
4
- import yaml
5
-
6
- from tesorotools.utils.template import TemplateLoader
7
-
8
-
9
- def clean_config_dicts(
10
- config_dicts: dict[str, Any],
11
- ) -> dict[str, Any]:
12
- return {k: v for k, v in config_dicts.items() if not k.startswith(".")}
13
-
14
-
15
- def read_config(
16
- config_file: Path,
17
- loader: type[yaml.FullLoader] | None = None,
18
- clean: bool = True,
19
- ) -> Any:
20
- actual_loader: type[yaml.FullLoader] = (
21
- yaml.FullLoader if loader is None else TemplateLoader
22
- )
23
- with open(config_file, encoding="utf8") as file:
24
- config_dict: Any = yaml.load(file, Loader=actual_loader)
25
- if clean and isinstance(config_dict, dict):
26
- config_dict = clean_config_dicts(config_dict) # type: ignore[reportUnknownArgumentType]
27
- return config_dict
28
-
29
-
30
- def merge(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
31
- # a overrides
32
- for key in b:
33
- if key in a:
34
- if isinstance(a[key], dict) and isinstance(b[key], dict):
35
- merge(a[key], b[key])
36
- else:
37
- a[key] = b[key]
38
- return a