tesorotools-python 0.0.44__py3-none-any.whl → 0.0.46__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
@@ -49,9 +49,11 @@ from tesorotools.artists import (
49
49
  HorizontalBarChart,
50
50
  Legend,
51
51
  LinePlot,
52
+ MatrixChart,
52
53
  StackedAreaPlot,
53
54
  StackedBarPlot,
54
55
  TypeCurve,
56
+ VectorPlot,
55
57
  )
56
58
  from tesorotools.orchestration import CompositeRegistry, iter_contexts
57
59
  from tesorotools.providers.base import (
@@ -79,6 +81,8 @@ def _register_builtins() -> None:
79
81
  register_artist("barh", HorizontalBarChart)
80
82
  register_artist("grouped_barh", GroupedBarChart)
81
83
  register_artist("type_curve", TypeCurve)
84
+ register_artist("vector_plot", VectorPlot)
85
+ register_artist("matrix", MatrixChart)
82
86
 
83
87
  register_tag("format", Format)
84
88
  register_tag("legend", Legend)
@@ -111,6 +115,7 @@ __all__ = [
111
115
  "Legend",
112
116
  "LinePlot",
113
117
  "LSEGProvider",
118
+ "MatrixChart",
114
119
  "RegistryProtocol",
115
120
  "Report",
116
121
  "Section",
@@ -121,6 +126,7 @@ __all__ = [
121
126
  "Text",
122
127
  "Title",
123
128
  "TypeCurve",
129
+ "VectorPlot",
124
130
  "YamlConstructor",
125
131
  "all_artists",
126
132
  "all_providers",
@@ -29,8 +29,10 @@ import matplotlib.style
29
29
  from tesorotools.artists._common import Artist, Format, Legend
30
30
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
31
31
  from tesorotools.artists.line_plot import LinePlot
32
+ from tesorotools.artists.matrix import MatrixChart
32
33
  from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
33
34
  from tesorotools.artists.type_curve import TypeCurve
35
+ from tesorotools.artists.vector_plot import VectorPlot
34
36
  from tesorotools.utils.globals import STYLE_SHEET
35
37
 
36
38
  matplotlib.style.use(STYLE_SHEET)
@@ -42,7 +44,9 @@ __all__ = [
42
44
  "HorizontalBarChart",
43
45
  "Legend",
44
46
  "LinePlot",
47
+ "MatrixChart",
45
48
  "StackedAreaPlot",
46
49
  "StackedBarPlot",
47
50
  "TypeCurve",
51
+ "VectorPlot",
48
52
  ]
@@ -0,0 +1,302 @@
1
+ """Annotated matrix / heatmap-table artist.
2
+
3
+ Defines :class:`MatrixChart`, the YAML-tag-aware artist
4
+ registered as ``!matrix``.
5
+
6
+ The chart is a grid of cells -- the "quadrant" or
7
+ segmentation table where each cell is shaded by its value and
8
+ carries a large number plus an optional descriptive label
9
+ (e.g. financial knowledge x digital skill -> "Capacidad
10
+ alta"). Nothing in the implementation is tied to the 2x2
11
+ case: the grid is driven by an ``n_rows x n_cols`` DataFrame,
12
+ so any rectangular layout works (a 3x3 segmentation, a
13
+ 5-bucket x 4-bucket transition matrix, ...).
14
+
15
+ Input is a flat ``pd.DataFrame`` indexed by row id with one
16
+ column per column id -- the same wide shape
17
+ :class:`GroupedBarChart` consumes -- so ``rows`` selects and
18
+ labels the index and ``cols`` selects and labels the columns.
19
+ Cell shading uses a sequential colormap normalised over the
20
+ selected values; text colour flips to stay legible on dark
21
+ cells. ``NaN`` cells are left blank (no fill, no text), which
22
+ covers triangular or sparse matrices.
23
+
24
+ Styling constants come from ``PLOT_CONFIG['matrix']``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+ from typing import Any, Self
31
+
32
+ import matplotlib as mpl
33
+ import matplotlib.pyplot as plt
34
+ import numpy as np
35
+ import pandas as pd
36
+ from matplotlib.axes import Axes
37
+ from matplotlib.colors import Colormap, Normalize
38
+ from matplotlib.figure import Figure
39
+ from matplotlib.patches import Rectangle
40
+ from yaml.nodes import MappingNode
41
+
42
+ from tesorotools.artists._common import FIG_CONFIG, Format, resolve_data
43
+ from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
44
+ from tesorotools.utils.template import TemplateLoader
45
+
46
+ __all__ = ["MatrixChart"]
47
+
48
+ MATRIX_CONFIG: dict[str, Any] = PLOT_CONFIG["matrix"]
49
+
50
+
51
+ def _text_color(rgba: tuple[float, float, float, float]) -> str:
52
+ """Pick a legible text colour for a cell filled with *rgba*.
53
+
54
+ Uses relative luminance: dark fills get the light text
55
+ colour, light fills the dark one.
56
+ """
57
+ r, g, b, _a = rgba
58
+ luminance = 0.299 * r + 0.587 * g + 0.114 * b
59
+ if luminance < MATRIX_CONFIG["luminance_threshold"]:
60
+ return MATRIX_CONFIG["light_text"]
61
+ return MATRIX_CONFIG["dark_text"]
62
+
63
+
64
+ class MatrixChart:
65
+ """Annotated heatmap-table artist (any ``n_rows x n_cols``).
66
+
67
+ Parameters
68
+ ----------
69
+ out_path
70
+ ``.png`` path the rendered chart is saved to.
71
+ data, data_path
72
+ Provide one. A wide DataFrame indexed by row id with
73
+ one column per column id.
74
+ rows
75
+ ``id -> label`` for the grid rows, top-to-bottom.
76
+ cols
77
+ ``id -> label`` for the grid columns, left-to-right.
78
+ cell_labels
79
+ Optional ``{row_id: {col_id: text}}`` descriptive text
80
+ drawn under each value. Missing entries draw the
81
+ value only.
82
+ scale
83
+ Multiplier applied to the values (e.g. ``100`` to turn
84
+ fractions into percentages).
85
+ fmt
86
+ :class:`Format` for the big per-cell value.
87
+ cmap
88
+ Matplotlib colormap name for the shading (default
89
+ ``PLOT_CONFIG['matrix']['cmap']``).
90
+ vmin, vmax
91
+ Value range mapped to the colormap ends. Default to
92
+ the min / max of the selected values.
93
+ xlabel, ylabel
94
+ Axis titles (the column / row variable names).
95
+ figsize
96
+ Override the figure size; defaults to a square so
97
+ cells read as tiles.
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ out_path: Path,
103
+ *,
104
+ data: pd.DataFrame | None = None,
105
+ data_path: Path | None = None,
106
+ rows: dict[str, str],
107
+ cols: dict[str, str],
108
+ cell_labels: dict[str, dict[str, str]] | None = None,
109
+ scale: float = 1,
110
+ fmt: Format | None = None,
111
+ cmap: str | None = None,
112
+ vmin: float | None = None,
113
+ vmax: float | None = None,
114
+ xlabel: str = "",
115
+ ylabel: str = "",
116
+ figsize: tuple[float, float] | None = None,
117
+ ) -> None:
118
+ if out_path.suffix != ".png":
119
+ raise ValueError(f"out_path must be .png: {out_path}")
120
+ if not rows or not cols:
121
+ raise ValueError("MatrixChart needs at least one row and column")
122
+ self.out_path = out_path
123
+ self.data = resolve_data(data, data_path)
124
+ self.rows = rows
125
+ self.cols = cols
126
+ self.cell_labels = cell_labels or {}
127
+ self.scale = scale
128
+ self.fmt = fmt or Format()
129
+ self.cmap = cmap or MATRIX_CONFIG["cmap"]
130
+ self.vmin = vmin
131
+ self.vmax = vmax
132
+ self.xlabel = xlabel
133
+ self.ylabel = ylabel
134
+ self.figsize = figsize
135
+
136
+ @classmethod
137
+ def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
138
+ """Build a :class:`MatrixChart` from the ``!matrix`` YAML tag.
139
+
140
+ Required keys
141
+ -------------
142
+ ``out_path``
143
+ ``.png`` destination path.
144
+ ``data_path``
145
+ ``.feather`` file with a DataFrame indexed by row
146
+ id and one column per column id.
147
+ ``rows`` / ``cols``
148
+ ``id -> label`` mappings selecting and ordering the
149
+ grid rows (top-to-bottom) and columns
150
+ (left-to-right).
151
+
152
+ Optional keys (forwarded as-is to ``__init__``)
153
+ ----------------------------------------------
154
+ ``cell_labels`` (``{row_id: {col_id: text}}``),
155
+ ``scale``, ``fmt`` (``!format``), ``cmap``, ``vmin`` /
156
+ ``vmax``, ``xlabel`` / ``ylabel``, ``figsize``.
157
+
158
+ Example
159
+ -------
160
+ .. code-block:: yaml
161
+
162
+ capacidad: !matrix
163
+ out_path: out/capacidad.png
164
+ data_path: data/capacidad.feather
165
+ xlabel: "Habilidad digital"
166
+ ylabel: "Conocimientos financieros"
167
+ fmt: !format {decimals: 1, units: "%"}
168
+ rows:
169
+ altos: "Altos"
170
+ bajos: "Bajos"
171
+ cols:
172
+ baja: "Baja"
173
+ alta: "Alta"
174
+ cell_labels:
175
+ altos: {baja: "Solo conocimiento financiero", alta: "Capacidad alta"}
176
+ bajos: {baja: "Capacidad baja", alta: "Solo habilidad digital"}
177
+ """
178
+ cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
179
+ node, deep=True
180
+ )
181
+ cfg.pop("id")
182
+ cfg["out_path"] = Path(cfg["out_path"])
183
+ if "data_path" in cfg:
184
+ cfg["data_path"] = Path(cfg["data_path"])
185
+ return cls(**cfg)
186
+
187
+ def plot(self) -> Axes:
188
+ """Render the chart and persist it to ``self.out_path``."""
189
+ row_ids = list(self.rows)
190
+ col_ids = list(self.cols)
191
+ missing_rows = [r for r in row_ids if r not in self.data.index]
192
+ missing_cols = [c for c in col_ids if c not in self.data.columns]
193
+ if missing_rows or missing_cols:
194
+ raise KeyError(
195
+ "MatrixChart ids not found in data — "
196
+ f"rows: {missing_rows}, cols: {missing_cols}"
197
+ )
198
+
199
+ matrix = self.data.loc[row_ids, col_ids] * self.scale
200
+ n_rows = len(row_ids)
201
+
202
+ values_arr = matrix.to_numpy(dtype=float)
203
+ valid = values_arr[~np.isnan(values_arr)]
204
+ vmin = self.vmin if self.vmin is not None else float(valid.min())
205
+ vmax = self.vmax if self.vmax is not None else float(valid.max())
206
+ norm = Normalize(vmin=vmin, vmax=vmax)
207
+ cmap: Colormap = mpl.colormaps[self.cmap]
208
+
209
+ fig_kw = dict(FIG_CONFIG)
210
+ fig_kw["figsize"] = self.figsize if self.figsize is not None else (6, 6)
211
+ fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
212
+ **fig_kw
213
+ )
214
+ ax: Axes = fig.add_subplot()
215
+
216
+ gap: float = MATRIX_CONFIG["gap"]
217
+ for i, row_id in enumerate(row_ids):
218
+ # Row 0 sits at the top, so its cell row is the highest y.
219
+ y_row = n_rows - 1 - i
220
+ for j, col_id in enumerate(col_ids):
221
+ value = float(values_arr[i, j])
222
+ if np.isnan(value):
223
+ continue
224
+ rgba: tuple[float, float, float, float] = cmap(norm(value))
225
+ ax.add_patch(
226
+ Rectangle(
227
+ (j + gap / 2, y_row + gap / 2),
228
+ 1 - gap,
229
+ 1 - gap,
230
+ facecolor=rgba,
231
+ edgecolor="none",
232
+ )
233
+ )
234
+ self._annotate_cell(ax, j + 0.5, y_row + 0.5, value, rgba)
235
+ label = self.cell_labels.get(row_id, {}).get(col_id)
236
+ if label:
237
+ ax.text( # type: ignore[reportUnknownMemberType]
238
+ j + 0.5,
239
+ y_row + 0.5 - MATRIX_CONFIG["label_offset"],
240
+ label,
241
+ ha="center",
242
+ va="center",
243
+ color=_text_color(rgba),
244
+ fontsize=MATRIX_CONFIG["label_fontsize"],
245
+ )
246
+
247
+ self._style_axes(ax, row_ids, col_ids)
248
+ fig.savefig( # type: ignore[reportUnknownMemberType]
249
+ self.out_path
250
+ )
251
+ plt.close(fig)
252
+ return ax
253
+
254
+ def _annotate_cell(
255
+ self,
256
+ ax: Axes,
257
+ cx: float,
258
+ cy: float,
259
+ value: float,
260
+ rgba: tuple[float, float, float, float],
261
+ ) -> None:
262
+ """Draw the big formatted value near a cell's centre."""
263
+ ax.text( # type: ignore[reportUnknownMemberType]
264
+ cx,
265
+ cy + MATRIX_CONFIG["value_offset"],
266
+ format_annotation(value, self.fmt.decimals, self.fmt.units),
267
+ ha="center",
268
+ va="center",
269
+ color=_text_color(rgba),
270
+ fontsize=MATRIX_CONFIG["value_fontsize"],
271
+ fontweight=MATRIX_CONFIG["value_weight"],
272
+ )
273
+
274
+ def _style_axes(
275
+ self, ax: Axes, row_ids: list[str], col_ids: list[str]
276
+ ) -> None:
277
+ """Category ticks, axis titles and a frameless look."""
278
+ n_rows, n_cols = len(row_ids), len(col_ids)
279
+ ax.set_xlim(0, n_cols)
280
+ ax.set_ylim(0, n_rows)
281
+ ax.set_aspect("auto")
282
+
283
+ ax.set_xticks([j + 0.5 for j in range(n_cols)]) # type: ignore[reportUnknownMemberType]
284
+ ax.set_xticklabels( # type: ignore[reportUnknownMemberType]
285
+ [self.cols[c] for c in col_ids]
286
+ )
287
+ # Row 0 is drawn at the top, so its tick is the highest y.
288
+ ax.set_yticks([n_rows - 1 - i + 0.5 for i in range(n_rows)]) # type: ignore[reportUnknownMemberType]
289
+ ax.set_yticklabels( # type: ignore[reportUnknownMemberType]
290
+ [self.rows[r] for r in row_ids]
291
+ )
292
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
293
+ axis="both", length=0
294
+ )
295
+ for spine in ax.spines.values():
296
+ spine.set_visible(False)
297
+ ax.set_xlabel( # type: ignore[reportUnknownMemberType]
298
+ self.xlabel
299
+ )
300
+ ax.set_ylabel( # type: ignore[reportUnknownMemberType]
301
+ self.ylabel
302
+ )
@@ -0,0 +1,315 @@
1
+ """Vector (arrow) chart artist.
2
+
3
+ Defines :class:`VectorPlot`, the YAML-tag-aware artist
4
+ registered as ``!vector_plot``.
5
+
6
+ Each entity is drawn as an arrow in a 2D plane from a start
7
+ point to an end point, so a single chart shows how *two*
8
+ variables moved between two periods at once. The common case
9
+ -- arrows fanning out from the origin, as in the classic
10
+ "public debt vs. bond yield since 2020" chart -- falls out
11
+ when the data already holds the *change* in each variable and
12
+ no start columns are given: the start defaults to ``(0, 0)``.
13
+
14
+ Unlike the time-series artists, the input DataFrame is indexed
15
+ by entity code (one row per arrow) and the two axes carry two
16
+ different variables, each with its own optional :class:`Format`
17
+ (``x_fmt`` / ``y_fmt``). Computing the period-over-period
18
+ change is the pipeline's job; this artist only renders the
19
+ pre-computed coordinates.
20
+
21
+ Layout and the shared ``Format`` / ``Legend`` config holders
22
+ live in :mod:`tesorotools.artists._common`; arrow styling
23
+ constants come from ``PLOT_CONFIG['vector']``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+ from typing import Any, Self
30
+
31
+ import matplotlib.pyplot as plt
32
+ import pandas as pd
33
+ from matplotlib.axes import Axes
34
+ from matplotlib.figure import Figure
35
+ from matplotlib.ticker import FuncFormatter
36
+ from yaml.nodes import MappingNode
37
+
38
+ from tesorotools.artists._common import (
39
+ AX_CONFIG,
40
+ FIG_CONFIG,
41
+ Format,
42
+ resolve_data,
43
+ )
44
+ from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
45
+ from tesorotools.utils.template import TemplateLoader
46
+
47
+ __all__ = ["VectorPlot"]
48
+
49
+ VECTOR_CONFIG: dict[str, Any] = PLOT_CONFIG["vector"]
50
+
51
+
52
+ class VectorPlot:
53
+ """Arrow chart showing the change in two variables.
54
+
55
+ Each entity in ``series`` is rendered as an arrow from a
56
+ start point to an end point. With only ``x`` / ``y``
57
+ given, arrows start at the origin and their tips encode
58
+ the change in each variable (the usual "since date X"
59
+ chart). Provide ``x_start`` / ``y_start`` to draw arrows
60
+ between two arbitrary points instead (period-to-period
61
+ migration in the plane).
62
+
63
+ Parameters
64
+ ----------
65
+ out_path
66
+ ``.png`` destination path.
67
+ data / data_path
68
+ The source DataFrame (in memory) or a ``.feather``
69
+ file; exactly one. Indexed by entity code, one row
70
+ per arrow.
71
+ series
72
+ ``entity_code -> display_label`` mapping. Selects and
73
+ orders the rows drawn; the codes must appear in the
74
+ DataFrame index.
75
+ x, y
76
+ Column names holding the arrow-tip coordinates.
77
+ x_start, y_start
78
+ Optional column names for the arrow tails. When
79
+ omitted the tail sits at ``0`` on that axis.
80
+ x_scale, y_scale
81
+ Per-axis multipliers applied to the raw values (e.g.
82
+ ``100`` to turn rates into basis points).
83
+ xlabel, ylabel
84
+ Axis titles.
85
+ x_fmt, y_fmt
86
+ Per-axis :class:`Format` controlling tick decimals and
87
+ units.
88
+ figsize
89
+ Optional ``(width, height)`` in inches.
90
+ series_styles
91
+ Per-entity matplotlib kwargs for the arrow; ``color``
92
+ and ``linestyle`` are honoured (``linestyle: dashed``
93
+ reproduces the dashed arrows used for outliers).
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ out_path: Path,
99
+ *,
100
+ data: pd.DataFrame | None = None,
101
+ data_path: Path | None = None,
102
+ series: dict[str, str],
103
+ x: str,
104
+ y: str,
105
+ x_start: str | None = None,
106
+ y_start: str | None = None,
107
+ x_scale: float = 1,
108
+ y_scale: float = 1,
109
+ xlabel: str = "",
110
+ ylabel: str = "",
111
+ x_fmt: Format | None = None,
112
+ y_fmt: Format | None = None,
113
+ figsize: tuple[float, float] | None = None,
114
+ series_styles: dict[str, dict[str, Any]] | None = None,
115
+ ) -> None:
116
+ if out_path.suffix != ".png":
117
+ raise ValueError(f"out_path must be .png: {out_path}")
118
+ if not series:
119
+ raise ValueError("VectorPlot needs at least one entity in `series`")
120
+ self.out_path = out_path
121
+ self.data = resolve_data(data, data_path)
122
+ self.series = series
123
+ self.x = x
124
+ self.y = y
125
+ self.x_start = x_start
126
+ self.y_start = y_start
127
+ self.x_scale = x_scale
128
+ self.y_scale = y_scale
129
+ self.xlabel = xlabel
130
+ self.ylabel = ylabel
131
+ self.x_fmt = x_fmt or Format()
132
+ self.y_fmt = y_fmt or Format()
133
+ self.figsize = figsize
134
+ self.series_styles = series_styles or {}
135
+
136
+ @classmethod
137
+ def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
138
+ """Build a :class:`VectorPlot` from the ``!vector_plot`` YAML tag.
139
+
140
+ Required keys
141
+ -------------
142
+ ``out_path``
143
+ ``.png`` destination path.
144
+ ``data_path``
145
+ ``.feather`` file indexed by entity code, holding
146
+ the arrow-tip columns.
147
+ ``series``
148
+ ``entity_code -> display_label`` mapping; the codes
149
+ must appear in the DataFrame index.
150
+ ``x`` / ``y``
151
+ Column names for the arrow-tip coordinates.
152
+
153
+ Optional keys (forwarded as-is to ``__init__``)
154
+ ----------------------------------------------
155
+ ``x_start`` / ``y_start``, ``x_scale`` / ``y_scale``,
156
+ ``xlabel`` / ``ylabel``, ``x_fmt`` / ``y_fmt``
157
+ (``!format``), ``figsize``, ``series_styles``.
158
+
159
+ Example
160
+ -------
161
+ .. code-block:: yaml
162
+
163
+ debt_vs_yield: !vector_plot
164
+ out_path: out/debt_yield.png
165
+ data_path: data/debt_yield.feather
166
+ x: debt_change
167
+ y: yield_change
168
+ xlabel: "Deuda pública (% PIB), dif. desde ene-2020"
169
+ ylabel: "Var. tir 10A desde ene-2020"
170
+ x_fmt: !format {decimals: 1, units: "%"}
171
+ y_fmt: !format {decimals: 1}
172
+ series:
173
+ ES: "ES"
174
+ DE: "DE"
175
+ IT: "IT"
176
+ series_styles:
177
+ IT: {linestyle: dashed}
178
+ """
179
+ cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
180
+ node, deep=True
181
+ )
182
+ cfg.pop("id")
183
+ cfg["out_path"] = Path(cfg["out_path"])
184
+ if "data_path" in cfg:
185
+ cfg["data_path"] = Path(cfg["data_path"])
186
+ return cls(**cfg)
187
+
188
+ def _coords(self, code: str) -> tuple[float, float, float, float]:
189
+ """Return the ``(x0, y0, x1, y1)`` arrow for *code*, scaled."""
190
+ row: pd.Series[Any] = self.data.loc[code] # type: ignore[assignment]
191
+ x1 = float(row[self.x]) * self.x_scale
192
+ y1 = float(row[self.y]) * self.y_scale
193
+ x0 = float(row[self.x_start]) * self.x_scale if self.x_start else 0.0
194
+ y0 = float(row[self.y_start]) * self.y_scale if self.y_start else 0.0
195
+ return x0, y0, x1, y1
196
+
197
+ def plot(self) -> Axes:
198
+ needed = [self.x, self.y]
199
+ if self.x_start is not None:
200
+ needed.append(self.x_start)
201
+ if self.y_start is not None:
202
+ needed.append(self.y_start)
203
+ missing = [c for c in needed if c not in self.data.columns]
204
+ if missing:
205
+ raise KeyError(
206
+ f"VectorPlot columns not found in data: {missing}. "
207
+ f"Available: {list(self.data.columns)}"
208
+ )
209
+ unknown = [c for c in self.series if c not in self.data.index]
210
+ if unknown:
211
+ raise KeyError(
212
+ f"VectorPlot series codes not in data index: {unknown}"
213
+ )
214
+
215
+ fig_kw = dict(FIG_CONFIG)
216
+ if self.figsize is not None:
217
+ fig_kw["figsize"] = self.figsize
218
+ fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
219
+ **fig_kw
220
+ )
221
+ ax: Axes = fig.add_subplot()
222
+
223
+ cycle: list[str] = plt.rcParams["axes.prop_cycle"].by_key()["color"]
224
+ xs: list[float] = [0.0]
225
+ ys: list[float] = [0.0]
226
+ for i, code in enumerate(self.series):
227
+ x0, y0, x1, y1 = self._coords(code)
228
+ xs.extend((x0, x1))
229
+ ys.extend((y0, y1))
230
+ style = self.series_styles.get(code, {})
231
+ color: Any = style.get("color", cycle[i % len(cycle)])
232
+ linestyle: Any = style.get("linestyle", "solid")
233
+ ax.annotate( # type: ignore[reportUnknownMemberType]
234
+ "",
235
+ xy=(x1, y1),
236
+ xytext=(x0, y0),
237
+ arrowprops={
238
+ "arrowstyle": VECTOR_CONFIG["arrowstyle"],
239
+ "color": color,
240
+ "linestyle": linestyle,
241
+ "linewidth": VECTOR_CONFIG["linewidth"],
242
+ "mutation_scale": VECTOR_CONFIG["mutation_scale"],
243
+ "shrinkA": 0,
244
+ "shrinkB": 0,
245
+ },
246
+ )
247
+ pad: float = VECTOR_CONFIG["label_pad"]
248
+ ax.annotate( # type: ignore[reportUnknownMemberType]
249
+ self.series[code],
250
+ xy=(x1, y1),
251
+ xytext=(
252
+ pad if x1 >= x0 else -pad,
253
+ pad if y1 >= y0 else -pad,
254
+ ),
255
+ textcoords="offset points",
256
+ color=color,
257
+ ha="left" if x1 >= x0 else "right",
258
+ va="bottom" if y1 >= y0 else "top",
259
+ )
260
+
261
+ self._style_axes(ax, xs, ys)
262
+ fig.savefig( # type: ignore[reportUnknownMemberType]
263
+ self.out_path
264
+ )
265
+ plt.close(fig)
266
+ return ax
267
+
268
+ def _style_axes(self, ax: Axes, xs: list[float], ys: list[float]) -> None:
269
+ """Frame, origin cross-hairs, tick formatters and labels."""
270
+ margin: float = VECTOR_CONFIG["margin"]
271
+ _set_limits(ax.set_xlim, xs, margin)
272
+ _set_limits(ax.set_ylim, ys, margin)
273
+
274
+ center = dict(VECTOR_CONFIG["center_line"])
275
+ ax.axvline( # type: ignore[reportUnknownMemberType]
276
+ x=0, **center
277
+ )
278
+ ax.axhline( # type: ignore[reportUnknownMemberType]
279
+ y=0, **AX_CONFIG["baseline"]
280
+ )
281
+
282
+ spines = AX_CONFIG["spines"]
283
+ for spine in ax.spines.values():
284
+ spine.set_color(spines["color"])
285
+ spine.set_linewidth(spines["linewidth"])
286
+
287
+ def _fmt_x(v: float, _pos: int) -> str:
288
+ return format_annotation(v, self.x_fmt.decimals, self.x_fmt.units)
289
+
290
+ def _fmt_y(v: float, _pos: int) -> str:
291
+ return format_annotation(v, self.y_fmt.decimals, self.y_fmt.units)
292
+
293
+ ax.xaxis.set_major_formatter(FuncFormatter(_fmt_x))
294
+ ax.yaxis.set_major_formatter(FuncFormatter(_fmt_y))
295
+ ax.set_xlabel( # type: ignore[reportUnknownMemberType]
296
+ self.xlabel
297
+ )
298
+ ax.set_ylabel( # type: ignore[reportUnknownMemberType]
299
+ self.ylabel
300
+ )
301
+ for tick in (*ax.get_xticklines(), *ax.get_yticklines()):
302
+ tick.set_markeredgecolor(spines["color"])
303
+
304
+
305
+ def _set_limits(setter: Any, values: list[float], margin: float) -> None:
306
+ """Set an axis range to span *values* with *margin* padding.
307
+
308
+ The origin is always included (the caller seeds ``values``
309
+ with ``0``). A degenerate span collapses to ``±1`` so the
310
+ arrow stays visible.
311
+ """
312
+ lo, hi = min(values), max(values)
313
+ span = hi - lo
314
+ pad = span * margin if span > 0 else 1.0
315
+ setter(lo - pad, hi + pad)
@@ -29,6 +29,30 @@ type_curve:
29
29
  marker: D
30
30
  color: C0
31
31
 
32
+ vector:
33
+ linewidth: 2
34
+ arrowstyle: "-|>"
35
+ mutation_scale: 14 # arrow-head size in points
36
+ label_pad: 5 # gap (points) between arrow head and its label
37
+ margin: 0.15 # fraction of the data range left as padding
38
+ center_line:
39
+ color: gray
40
+ linestyle: dashed
41
+ linewidth: 1
42
+ zorder: 1
43
+
44
+ matrix:
45
+ cmap: Blues
46
+ gap: 0.04 # white gutter between cells (fraction of a cell)
47
+ value_fontsize: 26
48
+ label_fontsize: 13
49
+ value_weight: bold
50
+ light_text: white # value/label colour on dark cells
51
+ dark_text: "#1a1a1a" # value/label colour on light cells
52
+ luminance_threshold: 0.55
53
+ value_offset: 0.08 # value sits this fraction of a cell above centre
54
+ label_offset: 0.14 # label sits this fraction of a cell below centre
55
+
32
56
  barh:
33
57
  highlight_factor: 0.4
34
58
  padding: 5
@@ -1,9 +1,10 @@
1
1
  """Public provider API.
2
2
 
3
- ``BdeProvider``, ``EcbProvider`` and ``LSEGProvider`` depend on
4
- the optional ``[bde]`` / ``[ecb]`` / ``[lseg]`` extras and are
5
- imported lazily through ``__getattr__``; importing
6
- ``tesorotools.providers`` itself does not require those extras.
3
+ ``BdeProvider``, ``EcbProvider``, ``ImfIrfclProvider`` and
4
+ ``LSEGProvider`` depend on the optional ``[bde]`` / ``[ecb]`` /
5
+ ``[imf]`` / ``[lseg]`` extras and are imported lazily through
6
+ ``__getattr__``; importing ``tesorotools.providers`` itself does not
7
+ require those extras.
7
8
  """
8
9
 
9
10
  from typing import TYPE_CHECKING, Any
@@ -17,12 +18,14 @@ from tesorotools.providers.base import (
17
18
  if TYPE_CHECKING:
18
19
  from tesorotools.providers.bde import BdeProvider
19
20
  from tesorotools.providers.ecb import EcbProvider
21
+ from tesorotools.providers.imf_irfcl import ImfIrfclProvider
20
22
  from tesorotools.providers.lseg import LSEGProvider
21
23
 
22
24
  __all__ = [
23
25
  "BdeProvider",
24
26
  "DataProvider",
25
27
  "EcbProvider",
28
+ "ImfIrfclProvider",
26
29
  "LSEGProvider",
27
30
  "RegistryProtocol",
28
31
  "bootstrap_providers",
@@ -38,6 +41,10 @@ def __getattr__(name: str) -> Any:
38
41
  from tesorotools.providers.ecb import EcbProvider
39
42
 
40
43
  return EcbProvider
44
+ if name == "ImfIrfclProvider":
45
+ from tesorotools.providers.imf_irfcl import ImfIrfclProvider
46
+
47
+ return ImfIrfclProvider
41
48
  if name == "LSEGProvider":
42
49
  from tesorotools.providers.lseg import LSEGProvider
43
50
 
@@ -0,0 +1,242 @@
1
+ """IMF IRFCL data provider via the official SDMX 3.0 REST API.
2
+
3
+ Downloads time series from the IMF's IRFCL dataflow (International
4
+ Reserves and Foreign Currency Liquidity), which publishes official
5
+ reserve assets per country, gold included. No authentication
6
+ required.
7
+
8
+ Install with the ``imf`` optional extra::
9
+
10
+ uv pip install "tesorotools-python[imf]"
11
+
12
+ Indicators (verified against the API in jun-2026):
13
+
14
+ * ``IRFCLDT1_IRFCL56V_FTO`` — gold, VOLUME in fine troy ounces. This
15
+ is the **physical stock** (despite the IMF label saying "millions of
16
+ ounces", the raw value comes in ounces: Spain ≈ 9,053,750 oz ≈
17
+ 281.6 t).
18
+ * ``IRFCLDT1_IRFCL56_USD`` — gold value in USD (approx. market value).
19
+ * ``IRFCLDT1_IRFCL65_USD`` — TOTAL official reserve assets in USD
20
+ (denominator of the % gold represents over reserves).
21
+
22
+ API base
23
+ --------
24
+ ``https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IRFCL/~/{key}``
25
+
26
+ Code convention (4 dimensions of DSD_IRFCL_PUB)
27
+ -----------------------------------------------
28
+ ::
29
+
30
+ {COUNTRY}.{INDICATOR}.{SECTOR}.{FREQUENCY}
31
+
32
+ * COUNTRY ISO-3 country code (``ESP``, ``USA``, ``DEU`` …).
33
+ * INDICATOR the IRFCL indicator (``IRFCLDT1_IRFCL56V_FTO`` …).
34
+ * SECTOR ``*`` (wildcard). Countries report gold under different
35
+ sectors (Spain ``S1X``, the US ``S1XS1311`` …); the
36
+ wildcard captures them all. When a key returns several
37
+ series (same gold total under different sectors) they are
38
+ collapsed by date in :meth:`_fetch_one` (last write wins;
39
+ values coincide).
40
+ * FREQUENCY ``M`` (monthly; reserves are published every month).
41
+
42
+ Keys are passed verbatim to the API; this provider does not parse them.
43
+
44
+ Date label
45
+ ----------
46
+ IRFCL labels the period and the observation is the end-of-period
47
+ balance. We map to the real end of period:
48
+
49
+ * ``"2026"`` → 2026-12-31
50
+ * ``"2026-Q1"`` → 2026-03-31
51
+ * ``"2026-M04"`` → 2026-04-30
52
+
53
+ Corporate TLS
54
+ -------------
55
+ ``truststore`` is enabled on import (if installed) so the provider
56
+ works behind MINECO's TLS proxy without ``verify=False``.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import logging
62
+ from typing import ClassVar
63
+
64
+ import pandas as pd
65
+ import requests
66
+
67
+ from tesorotools.providers.base import DataProvider
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+
72
+ def _enable_truststore_once() -> bool:
73
+ """Enable truststore (the OS cert store) if available."""
74
+ try:
75
+ import truststore # type: ignore[import-not-found]
76
+ except ImportError:
77
+ return False
78
+ truststore.inject_into_ssl() # pyright: ignore[reportUnknownMemberType]
79
+ return True
80
+
81
+
82
+ _TRUSTSTORE_ACTIVE = _enable_truststore_once()
83
+
84
+ _API_BASE = (
85
+ "https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IRFCL/~"
86
+ )
87
+ _DEFAULT_TIMEOUT = 60
88
+ _DEFAULT_START = "2000"
89
+ _HEADERS = {"Accept": "application/vnd.sdmx.data+json"}
90
+
91
+
92
+ class ImfIrfclProvider(DataProvider):
93
+ """Provider that downloads IMF IRFCL series via SDMX 3.0.
94
+
95
+ One HTTP request per key. Series with no observations (a country
96
+ that does not report that indicator) are skipped with a warning
97
+ instead of aborting the whole download.
98
+
99
+ Parameters
100
+ ----------
101
+ timeout
102
+ Max seconds per HTTP request.
103
+ session
104
+ Optional pre-built ``requests.Session``. Useful for tests or
105
+ for custom retry policies.
106
+ """
107
+
108
+ PROVIDER_NAME: ClassVar[str] = "imf_irfcl"
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ timeout: int = _DEFAULT_TIMEOUT,
114
+ session: requests.Session | None = None,
115
+ ) -> None:
116
+ self._timeout = timeout
117
+ self._session = session or requests.Session()
118
+
119
+ def fetch(
120
+ self,
121
+ codes: list[str],
122
+ start: str | None = None,
123
+ end: str | None = None,
124
+ ) -> pd.DataFrame:
125
+ """Download a list of IRFCL keys and return a wide DataFrame.
126
+
127
+ Parameters
128
+ ----------
129
+ codes
130
+ List of IRFCL keys (``"{COUNTRY}.{INDICATOR}.{SECTOR}.{FREQ}"``).
131
+ start
132
+ Start period (e.g. ``"2024"``). If ``None`` defaults to
133
+ ``"2000"``.
134
+ end
135
+ End date as ISO string. If ``None`` up to latest.
136
+
137
+ Returns
138
+ -------
139
+ pd.DataFrame
140
+ Wide DataFrame. Index = dates (tz-naive), columns = the
141
+ requested codes. Missing observations are NaN.
142
+ """
143
+ if not codes:
144
+ return pd.DataFrame()
145
+
146
+ cols: dict[str, pd.Series[float]] = {}
147
+ for code in codes:
148
+ try:
149
+ series = self._fetch_one(code, start=start)
150
+ except requests.RequestException as e:
151
+ logger.warning("IRFCL: failed downloading %s (%s)", code, e)
152
+ continue
153
+ if series is not None and not series.empty:
154
+ cols[code] = series
155
+
156
+ if not cols:
157
+ return pd.DataFrame()
158
+
159
+ df = pd.DataFrame(cols).sort_index()
160
+ df.index.name = "date"
161
+ if end:
162
+ df = df.loc[df.index <= pd.Timestamp(end)]
163
+ return df
164
+
165
+ def is_available(self) -> bool:
166
+ """Check whether the IMF API responds.
167
+
168
+ Hits a small ESP gold-volume query.
169
+ """
170
+ try:
171
+ r = self._session.get(
172
+ f"{_API_BASE}/ESP.IRFCLDT1_IRFCL56V_FTO.*.M?startPeriod=2024",
173
+ timeout=10,
174
+ headers=_HEADERS,
175
+ )
176
+ return r.status_code == 200
177
+ except requests.RequestException:
178
+ return False
179
+
180
+ def _fetch_one(
181
+ self, code: str, start: str | None = None
182
+ ) -> "pd.Series[float] | None":
183
+ """Download a single IRFCL series and return it as a Series."""
184
+ url = f"{_API_BASE}/{code}?startPeriod={start or _DEFAULT_START}"
185
+ r = self._session.get(url, timeout=self._timeout, headers=_HEADERS)
186
+ if r.status_code != 200:
187
+ logger.warning("IRFCL: HTTP %d for %s", r.status_code, code)
188
+ return None
189
+ payload = r.json()
190
+ data_sets = payload.get("data", {}).get("dataSets", [])
191
+ structures = payload.get("data", {}).get("structures", [])
192
+ if not data_sets or not structures:
193
+ return None
194
+ time_dim = structures[0].get("dimensions", {}).get("observation", [])
195
+ if not time_dim:
196
+ return None
197
+ time_values = [t["value"] for t in time_dim[0].get("values", [])]
198
+ series_dict = data_sets[0].get("series", {})
199
+
200
+ data: dict[pd.Timestamp, float] = {}
201
+ for _, series in series_dict.items():
202
+ observations = series.get("observations", {})
203
+ for idx_str, payload_obs in observations.items():
204
+ if not payload_obs or payload_obs[0] is None:
205
+ continue
206
+ try:
207
+ value = float(payload_obs[0])
208
+ except (TypeError, ValueError):
209
+ continue
210
+ idx = int(idx_str)
211
+ if idx >= len(time_values):
212
+ continue
213
+ ts = _parse_period(time_values[idx])
214
+ if ts is not None:
215
+ data[ts] = value
216
+
217
+ if not data:
218
+ return None
219
+ return pd.Series(data, dtype="float64").sort_index()
220
+
221
+
222
+ def _parse_period(label: str) -> pd.Timestamp | None:
223
+ """Convert an IRFCL label to the **real end of period**.
224
+
225
+ * ``"YYYY"`` → December 31 of that year.
226
+ * ``"YYYY-QN"`` → last day of the quarter.
227
+ * ``"YYYY-MNN"`` → last day of the month.
228
+ """
229
+ try:
230
+ if "-Q" in label:
231
+ period = pd.Period(label.replace("-Q", "Q"), freq="Q")
232
+ return period.end_time.normalize()
233
+ if "-M" in label: # "YYYY-MNN"
234
+ year_str, month_str = label.split("-M")
235
+ period = pd.Period(
236
+ freq="M", year=int(year_str), month=int(month_str)
237
+ )
238
+ return period.end_time.normalize()
239
+ year = int(label)
240
+ return pd.Timestamp(year=year, month=12, day=31)
241
+ except (ValueError, TypeError):
242
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.44
3
+ Version: 0.0.46
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -16,5 +16,8 @@ Provides-Extra: bde
16
16
  Requires-Dist: requests>=2.31; extra == 'bde'
17
17
  Provides-Extra: ecb
18
18
  Requires-Dist: requests>=2.31; extra == 'ecb'
19
+ Provides-Extra: imf
20
+ Requires-Dist: requests>=2.31; extra == 'imf'
21
+ Requires-Dist: truststore>=0.10; extra == 'imf'
19
22
  Provides-Extra: lseg
20
23
  Requires-Dist: lseg-data>=2.1; extra == 'lseg'
@@ -1,18 +1,20 @@
1
- tesorotools/__init__.py,sha256=a7IzgMbmbJYnaS3vEQ_lNacqrS0cSBoU4Uz5qIAhb_4,3761
1
+ tesorotools/__init__.py,sha256=YsyE_aq7DdDs__6VPkKsy0exmXZpig0ieoD-ejuT0p0,3921
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
5
5
  tesorotools/manifest.py,sha256=UeUURo_yQtCnXaYDUhNyxHaRlH4eNSh6szIYlv-PQZs,3346
6
6
  tesorotools/orchestration.py,sha256=24C-LTM4uy7eckjXBjJbC2t92fg6yffl5H-E9Gm2W9M,3442
7
7
  tesorotools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- tesorotools/artists/__init__.py,sha256=iabMiHj7ZzXt5myhIcnYew7W_xc0IJc-NiBy9UnDEs8,1487
8
+ tesorotools/artists/__init__.py,sha256=GR9_wnpH8dDCdIBIB-Psd4_NtdL9nu9vYpCHcU9IdUE,1630
9
9
  tesorotools/artists/_common.py,sha256=y5XfDcsRxH3ydFgyphE2icppPAGsxUEPoxH5ikOoI3c,16403
10
10
  tesorotools/artists/barh_plot.py,sha256=T68Vs8lPkhTVgkQZkK4X9s1-Eeipumm7Li7YOgItHxM,20187
11
11
  tesorotools/artists/line_plot.py,sha256=EnILDic5T5keBOpKQ9Hzrb5ErcMEzNm8IFR_lXxINDE,9553
12
+ tesorotools/artists/matrix.py,sha256=mx7fRIydttXuZmDzj2jzN3cUm98oawAwjSvjS50xrpE,11005
12
13
  tesorotools/artists/stacked.py,sha256=gcY0DNxDAnF9laGMLFMxYDuG95Ab8SykCQfPTzeQync,13447
13
14
  tesorotools/artists/type_curve.py,sha256=7yidpNOs4Q7IFMwKRM2-6lNKRTV54A0ImM0DK1xSSoM,8968
15
+ tesorotools/artists/vector_plot.py,sha256=q0Rww-wUZuZQuB3IV9bQx8v0yrYWJj3Ns7oOrq-UwFo,11393
14
16
  tesorotools/assets/README.md,sha256=EjDuA4Xf-Omn0Khp8erNXdW-iyWBzSM2FQFexdrS6Eg,285
15
- tesorotools/assets/plots.yaml,sha256=rE4NuMe3v0uTDnFt8GfJ3jC4WfXxu2wlr--tdkfWLi4,648
17
+ tesorotools/assets/plots.yaml,sha256=yfpK9TGznZzOqhNteM253Aq8sOmn8s8fKVerfAC-Ais,1426
16
18
  tesorotools/assets/tesoro.mplstyle,sha256=HN7o04zzgwL2R6GeMvQlj-rBI_lutCwdvqwPKYyv9Tg,456
17
19
  tesorotools/assets/fonts/CabinetGrotesk-Black.otf,sha256=b7M_yFxz5IcGtmWWPMzLVkfx4RLhVALQDFeoq4T1YD0,37728
18
20
  tesorotools/assets/fonts/CabinetGrotesk-Bold.otf,sha256=86oM6a9QVAjZoEIFFcbCbcIVEwtWrMBX0ks7_CzaKS4,38148
@@ -39,10 +41,11 @@ tesorotools/pipeline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
39
41
  tesorotools/pipeline/diagnose.py,sha256=Ji_T3rAn-z4NhSdkpyPsnwQ1CTl4u5-1gnJZC8d1Sxg,1470
40
42
  tesorotools/pipeline/engine.py,sha256=D4gKIv1bO6pw2gUMWJ7CVRv7e_kWWrWpZCzupeZCx0k,2929
41
43
  tesorotools/pipeline/rules.py,sha256=OXi6dk2Gx63aO-SGjYnblhAah7tQIynNq2DKtfMGIEk,10041
42
- tesorotools/providers/__init__.py,sha256=uSer9cXywNBA4K7gfkon7d5JoXEkrP5sBEIw5YoW35I,1235
44
+ tesorotools/providers/__init__.py,sha256=7iEs_BeNeVSMwzjsVd16b6GRmj3bgzMD30rNLEKwupo,1495
43
45
  tesorotools/providers/base.py,sha256=6PA1HKhbc-Ehy27Ekm4wn-jWthEdL39Q9qxLCoqH_Jo,7666
44
46
  tesorotools/providers/bde.py,sha256=wVfaKzgInTjThJ_twQ-ycA6FUMAKLx3BWL19P03LR0g,7226
45
47
  tesorotools/providers/ecb.py,sha256=8UIW-NFLlGVIM7E2Sd_9UEWryt5wwilY3Hv13dnjY68,6422
48
+ tesorotools/providers/imf_irfcl.py,sha256=jGUDFGJBpNRsWKRI90BzI5AzIOmrSj6XwBS6ZUThc8A,8041
46
49
  tesorotools/providers/lseg.py,sha256=paKdSxb1tWv26YCwMD0dEUfF4GLFJWzHukKII9WPges,30183
47
50
  tesorotools/render/__init__.py,sha256=cqC5hoOwt_ZEE7gNG8nBbvf59YI99Lro8e18WsMKVT0,589
48
51
  tesorotools/render/report.py,sha256=_7QnmDgUScRtUWSuaKYoTOVk2TTGUCkRWxAo1haxnek,1051
@@ -64,6 +67,6 @@ tesorotools/utils/matplotlib.py,sha256=lItlkJwJEDGALt0J1UZioTz2GOOFPPe7ffQ4Hzxl7
64
67
  tesorotools/utils/series.py,sha256=AEf5DhneYjzQ0nvZD5b1IU3hop0Xgb3Bw2xWs_G3Lhw,1207
65
68
  tesorotools/utils/shortcuts.py,sha256=9fpTLgfDFm6_1kh2W0Zk2atn3ZIZMvPfuOjlPpjVy4c,1718
66
69
  tesorotools/utils/template.py,sha256=owYVB_dDlkIcKGmT6muH_4UjKDD7JYn_I1pxjQ9VIB8,5211
67
- tesorotools_python-0.0.44.dist-info/METADATA,sha256=hsbxz4y6lPP0IplST5yGfmOCjIltXx1rFMh0XFTk96w,610
68
- tesorotools_python-0.0.44.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
69
- tesorotools_python-0.0.44.dist-info/RECORD,,
70
+ tesorotools_python-0.0.46.dist-info/METADATA,sha256=9hoqw47cZa0TQiDwpT93d6TDnWHOCWi2nM_KxFuXhfU,724
71
+ tesorotools_python-0.0.46.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
72
+ tesorotools_python-0.0.46.dist-info/RECORD,,