tesorotools-python 0.0.34__tar.gz → 0.0.35__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.34 → tesorotools_python-0.0.35}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/pyproject.toml +1 -1
  3. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/artists/line_plot.py +31 -38
  4. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/artists/stacked.py +25 -27
  5. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/artists/type_curve.py +5 -3
  6. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/plots.yaml +0 -2
  7. tesorotools_python-0.0.35/src/tesorotools/driver.py +138 -0
  8. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/table.py +42 -7
  9. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/.gitignore +0 -0
  10. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/__init__.py +0 -0
  11. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/artists/__init__.py +0 -0
  12. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/artists/barh.md +0 -0
  13. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/artists/barh_plot.py +0 -0
  14. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/artists/table.py +0 -0
  15. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/README.md +0 -0
  16. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  17. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  18. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  19. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  20. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  21. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  22. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  23. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  24. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/README.md +0 -0
  25. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  26. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/convert.py +0 -0
  27. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/data_sources/__init__.py +0 -0
  28. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/data_sources/debug.py +0 -0
  29. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/database/__init__.py +0 -0
  30. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/database/local.py +0 -0
  31. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/database/push.py +0 -0
  32. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/database/shared.py +0 -0
  33. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/dependencies/__init__.py +0 -0
  34. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/dependencies/node.py +0 -0
  35. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/dependencies/resolution.py +0 -0
  36. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/main.py +0 -0
  37. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/manifest.py +0 -0
  38. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/offsets/__init__.py +0 -0
  39. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/offsets/offsets.py +0 -0
  40. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/offsets/outliers.py +0 -0
  41. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/__init__.py +0 -0
  42. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/diagnose.py +0 -0
  43. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/engine.py +0 -0
  44. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/rules.py +0 -0
  45. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/providers/__init__.py +0 -0
  46. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/providers/base.py +0 -0
  47. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/providers/bde.py +0 -0
  48. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/providers/ecb.py +0 -0
  49. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/py.typed +0 -0
  50. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/__init__.py +0 -0
  51. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/__init__.py +0 -0
  52. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/content.py +0 -0
  53. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/images.py +0 -0
  54. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/section.py +0 -0
  55. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/subtitle.py +0 -0
  56. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/text.py +0 -0
  57. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/content/title.py +0 -0
  58. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/render/report.py +0 -0
  59. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/testing/__init__.py +0 -0
  60. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/testing/compare.py +0 -0
  61. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/utils/__init__.py +0 -0
  62. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/utils/config.py +0 -0
  63. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/utils/format.py +0 -0
  64. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/utils/globals.py +0 -0
  65. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/utils/matplotlib.py +0 -0
  66. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/utils/series.py +0 -0
  67. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/src/tesorotools/utils/shortcuts.py +0 -0
  68. {tesorotools_python-0.0.34 → tesorotools_python-0.0.35}/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.34
3
+ Version: 0.0.35
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "tesorotools-python"
3
3
  requires-python = ">=3.13"
4
- version = "0.0.34"
4
+ version = "0.0.35"
5
5
  dependencies = [
6
6
  # database and ORM
7
7
  "psycopg[binary]>=3.1",
@@ -310,21 +310,20 @@ def style_baseline(
310
310
  reference: float = 0,
311
311
  **baseline_config: Any,
312
312
  ) -> None:
313
- color: str = baseline_config["color"]
313
+ """Draw a horizontal baseline at *reference*.
314
+
315
+ Always uses ``axhline`` (with high zorder) so callers that
316
+ later restyle the spines do not silently erase the baseline.
317
+ """
314
318
  bottom_lim, top_lim = ax.get_ylim()
315
319
  ax.set_ylim(
316
320
  bottom=min(reference, bottom_lim),
317
321
  top=max(reference, top_lim),
318
322
  )
319
- bottom_lim, top_lim = ax.get_ylim()
320
- if bottom_lim == reference:
321
- ax.spines["bottom"].set_edgecolor(color)
322
- elif top_lim == reference:
323
- ax.spines["top"].set_edgecolor(color)
324
- else:
325
- ax.axhline( # type: ignore[reportUnknownMemberType]
326
- y=reference, **baseline_config
327
- )
323
+ baseline_config.setdefault("zorder", 2.5)
324
+ ax.axhline( # type: ignore[reportUnknownMemberType]
325
+ y=reference, **baseline_config
326
+ )
328
327
 
329
328
 
330
329
  def plot_line_chart(
@@ -333,12 +332,12 @@ def plot_line_chart(
333
332
  *,
334
333
  base_100: bool,
335
334
  annotate: bool,
336
- format: dict[str, Any],
335
+ fmt: dict[str, Any],
337
336
  **kwargs: Any,
338
337
  ) -> None:
339
338
  if base_100:
340
339
  data = data / data.iloc[0, :] * 100
341
- if format["units"] == "p.b.":
340
+ if fmt["units"] == "p.b.":
342
341
  data = data * 100
343
342
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
344
343
  **FIG_CONFIG
@@ -349,11 +348,13 @@ def plot_line_chart(
349
348
  pass
350
349
 
351
350
  reference = 100 if base_100 else 0
352
- style_spines(ax, **format, **AX_CONFIG["spines"])
351
+ style_spines(ax, **fmt, **AX_CONFIG["spines"])
353
352
  style_baseline(ax, reference, **AX_CONFIG["baseline"])
354
- ax.legend( # type: ignore[reportUnknownMemberType]
355
- loc="upper center",
356
- bbox_to_anchor=(0.5, LINE_PLOT_CONFIG["legend_sep"]),
353
+ handles, label_strs = ax.get_legend_handles_labels()
354
+ fig.legend( # type: ignore[reportUnknownMemberType]
355
+ handles,
356
+ label_strs,
357
+ loc="outside lower center",
357
358
  ncol=(
358
359
  kwargs["legend"]["ncol"]
359
360
  if kwargs.get("legend", None) is not None
@@ -407,10 +408,8 @@ class Legend:
407
408
  def __init__(
408
409
  self,
409
410
  ncol: int | None = None,
410
- sep: float | None = None,
411
411
  ) -> None:
412
412
  self.ncol = ncol
413
- self.sep = sep
414
413
 
415
414
  @classmethod
416
415
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -436,7 +435,7 @@ class LinePlot:
436
435
  annotate: bool = False,
437
436
  annotate_color: str | None = None,
438
437
  baseline: bool = False,
439
- format: Format | None = None,
438
+ fmt: Format | None = None,
440
439
  legend: Legend | None = None,
441
440
  data: pd.DataFrame | None = None,
442
441
  figsize: tuple[float, float] | None = None,
@@ -469,7 +468,7 @@ class LinePlot:
469
468
  self.base_100_date = base_100_date
470
469
  self.annotate = annotate
471
470
  self.annotate_color = annotate_color
472
- self.format = format
471
+ self.fmt = fmt
473
472
  self.start_date = start_date
474
473
  self.end_date = end_date
475
474
  self.series = series
@@ -545,13 +544,13 @@ class LinePlot:
545
544
  if self.vlines:
546
545
  draw_vlines(ax, self.vlines)
547
546
 
548
- assert self.format is not None
547
+ assert self.fmt is not None
549
548
  if self.annotate:
550
549
  annotate_last_values(
551
550
  ax,
552
551
  plot_data,
553
- decimals=self.format.decimals,
554
- units=self.format.units,
552
+ decimals=self.fmt.decimals,
553
+ units=self.fmt.units,
555
554
  labels=self.series,
556
555
  series_styles=self.series_styles,
557
556
  annotate_color=self.annotate_color,
@@ -559,8 +558,8 @@ class LinePlot:
559
558
 
560
559
  style_spines( # maybe make this function accept a Format object
561
560
  ax,
562
- decimals=self.format.decimals,
563
- units=self.format.units,
561
+ decimals=self.fmt.decimals,
562
+ units=self.fmt.units,
564
563
  **AX_CONFIG["spines"],
565
564
  )
566
565
  if self.baseline:
@@ -569,25 +568,19 @@ class LinePlot:
569
568
 
570
569
  if self.legend is not None:
571
570
  labels = [self.series[c] for c in plot_data.columns]
571
+ handles, label_strs = ax.get_legend_handles_labels()
572
+ fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
572
573
  ncol = (
573
574
  self.legend.ncol
574
575
  if self.legend.ncol is not None
575
- else auto_ncol(ax, labels)
576
- )
577
- sep = (
578
- self.legend.sep
579
- if self.legend.sep is not None
580
- else LINE_PLOT_CONFIG["legend_sep"]
576
+ else auto_ncol(ax, labels, available_width_px=fig_width_px)
581
577
  )
582
- ax.legend( # type: ignore[reportUnknownMemberType]
583
- loc="upper center",
584
- bbox_to_anchor=(0.5, sep),
578
+ fig.legend( # type: ignore[reportUnknownMemberType]
579
+ handles,
580
+ label_strs,
581
+ loc="outside lower center",
585
582
  ncol=ncol,
586
583
  )
587
- else:
588
- ax.legend().set_visible( # type: ignore[reportUnknownMemberType]
589
- False
590
- )
591
584
 
592
585
  if self.plot_size is not None:
593
586
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
@@ -22,8 +22,6 @@ from tesorotools.artists.line_plot import (
22
22
  )
23
23
  from tesorotools.utils.config import TemplateLoader
24
24
 
25
- _DEFAULT_SEP = -0.125
26
-
27
25
 
28
26
  class StackedAreaPlot:
29
27
  """Stacked area chart with the tesorotools visual style.
@@ -43,7 +41,7 @@ class StackedAreaPlot:
43
41
  start_date: str | None = None,
44
42
  end_date: str | None = None,
45
43
  baseline: bool = False,
46
- format: Format | None = None,
44
+ fmt: Format | None = None,
47
45
  legend: Legend | None = None,
48
46
  figsize: tuple[float, float] | None = None,
49
47
  plot_size: tuple[float, float] | None = None,
@@ -57,7 +55,7 @@ class StackedAreaPlot:
57
55
  self.start_date = start_date
58
56
  self.end_date = end_date
59
57
  self.baseline = baseline
60
- self.format = format or Format()
58
+ self.fmt = fmt or Format()
61
59
  self.legend = legend
62
60
  self.figsize = figsize
63
61
  self.plot_size = plot_size
@@ -109,23 +107,25 @@ class StackedAreaPlot:
109
107
 
110
108
  style_spines(
111
109
  ax,
112
- decimals=self.format.decimals,
113
- units=self.format.units,
110
+ decimals=self.fmt.decimals,
111
+ units=self.fmt.units,
114
112
  **AX_CONFIG["spines"],
115
113
  )
116
114
  if self.baseline:
117
115
  style_baseline(ax, 0, **AX_CONFIG["baseline"])
118
116
 
117
+ fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
119
118
  legend_ncol = self.legend.ncol if self.legend else None
120
- ncol = legend_ncol if legend_ncol is not None else auto_ncol(ax, labels)
121
- sep = (
122
- self.legend.sep
123
- if self.legend and self.legend.sep is not None
124
- else _DEFAULT_SEP
119
+ ncol = (
120
+ legend_ncol
121
+ if legend_ncol is not None
122
+ else auto_ncol(ax, labels, available_width_px=fig_width_px)
125
123
  )
126
- ax.legend( # type: ignore[reportUnknownMemberType]
127
- loc="upper center",
128
- bbox_to_anchor=(0.5, sep),
124
+ handles, label_strs = ax.get_legend_handles_labels()
125
+ fig.legend( # type: ignore[reportUnknownMemberType]
126
+ handles,
127
+ label_strs,
128
+ loc="outside lower center",
129
129
  ncol=ncol,
130
130
  )
131
131
 
@@ -163,7 +163,7 @@ class StackedBarPlot:
163
163
  start_date: str | None = None,
164
164
  end_date: str | None = None,
165
165
  baseline: bool = True,
166
- format: Format | None = None,
166
+ fmt: Format | None = None,
167
167
  legend: Legend | None = None,
168
168
  figsize: tuple[float, float] | None = None,
169
169
  overlay_series: dict[str, str] | None = None,
@@ -180,7 +180,7 @@ class StackedBarPlot:
180
180
  self.start_date = start_date
181
181
  self.end_date = end_date
182
182
  self.baseline = baseline
183
- self.format = format or Format()
183
+ self.fmt = fmt or Format()
184
184
  self.legend = legend
185
185
  self.plot_size = plot_size
186
186
  self.figsize = figsize
@@ -313,8 +313,8 @@ class StackedBarPlot:
313
313
 
314
314
  style_spines(
315
315
  ax,
316
- decimals=self.format.decimals,
317
- units=self.format.units,
316
+ decimals=self.fmt.decimals,
317
+ units=self.fmt.units,
318
318
  **AX_CONFIG["spines"],
319
319
  )
320
320
  if self.baseline:
@@ -323,20 +323,18 @@ class StackedBarPlot:
323
323
  all_labels = list(self.series.values()) + list(
324
324
  self.overlay_series.values()
325
325
  )
326
+ fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
326
327
  legend_ncol = self.legend.ncol if self.legend else None
327
328
  ncol = (
328
329
  legend_ncol
329
330
  if legend_ncol is not None
330
- else auto_ncol(ax, all_labels)
331
- )
332
- sep = (
333
- self.legend.sep
334
- if self.legend and self.legend.sep is not None
335
- else _DEFAULT_SEP
331
+ else auto_ncol(ax, all_labels, available_width_px=fig_width_px)
336
332
  )
337
- ax.legend( # type: ignore[reportUnknownMemberType]
338
- loc="upper center",
339
- bbox_to_anchor=(0.5, sep),
333
+ handles, label_strs = ax.get_legend_handles_labels()
334
+ fig.legend( # type: ignore[reportUnknownMemberType]
335
+ handles,
336
+ label_strs,
337
+ loc="outside lower center",
340
338
  ncol=ncol,
341
339
  )
342
340
 
@@ -167,9 +167,11 @@ def plot_type_curve(
167
167
  style_spines(ax, **merged_config["yaxis"], **AX_CONFIG["spines"])
168
168
  _rotate_xticks(ax)
169
169
  style_baseline(ax, 0, **AX_CONFIG["baseline"])
170
- ax.legend( # type: ignore[reportUnknownMemberType]
171
- loc="upper center",
172
- bbox_to_anchor=(0.5, merged_config["legend_sep"]),
170
+ handles, labels = ax.get_legend_handles_labels()
171
+ fig.legend( # type: ignore[reportUnknownMemberType]
172
+ handles,
173
+ labels,
174
+ loc="outside lower center",
173
175
  ncol=3,
174
176
  )
175
177
  fig.savefig( # type: ignore[reportUnknownMemberType]
@@ -28,14 +28,12 @@ type_curve:
28
28
  linewidth: 2
29
29
  marker: D
30
30
  color: C0
31
- legend_sep: -0.1
32
31
 
33
32
  barh:
34
33
  highlight_factor: 0.4
35
34
  padding: 5
36
35
 
37
36
  line:
38
- legend_sep: -0.125
39
37
  ncol: 5
40
38
 
41
39
  table:
@@ -0,0 +1,138 @@
1
+ """Schema-agnostic YAML → docx driver with a plug-in renderer registry.
2
+
3
+ Each project that consumes tesorotools wraps its data sources in a
4
+ small adapter, and then declares its charts/tables/text blocks in a
5
+ YAML file. This module dispatches those declarations to the right
6
+ renderer at runtime, so projects share the orchestration code instead
7
+ of each one growing its own ``driver.py``.
8
+
9
+ Typical usage::
10
+
11
+ from tesorotools import driver
12
+ from tesorotools.artists.line_plot import LinePlot
13
+
14
+ def render_line(document, cfg, ctx):
15
+ chart = LinePlot(out_path=Path(cfg["out_path"]), ...)
16
+ chart.plot()
17
+ # then embed the PNG into ``document``...
18
+
19
+ driver.register_renderer("line", render_line)
20
+ items = driver.load(yaml_path)["charts"]
21
+ driver.render(document, items, ctx={"year": 2026})
22
+
23
+ Renderers are looked up by the ``type`` key on each item; selecting
24
+ which items to render in a given pass is done with the ``section``
25
+ and ``on_table`` filters, mirroring the diary/reports use case where
26
+ some charts go before the tables and others embed inside them.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from pathlib import Path
32
+ from typing import Any, Callable, Mapping
33
+
34
+ from docx.document import Document
35
+
36
+ from tesorotools.utils.config import read_config
37
+
38
+ Renderer = Callable[[Document, dict[str, Any], Mapping[str, Any]], None]
39
+
40
+ _REGISTRY: dict[str, Renderer] = {}
41
+
42
+
43
+ def register_renderer(type_name: str, renderer: Renderer) -> None:
44
+ """Register *renderer* for items declaring ``type: <type_name>``.
45
+
46
+ Calling twice with the same name overwrites the previous entry,
47
+ so projects can swap implementations in tests.
48
+ """
49
+ _REGISTRY[type_name] = renderer
50
+
51
+
52
+ def unregister_renderer(type_name: str) -> None:
53
+ """Remove a previously registered renderer; no-op if absent."""
54
+ _REGISTRY.pop(type_name, None)
55
+
56
+
57
+ def registered_types() -> list[str]:
58
+ """Names currently in the registry, sorted alphabetically."""
59
+ return sorted(_REGISTRY)
60
+
61
+
62
+ def load(path: Path) -> dict[str, Any]:
63
+ """Read a driver YAML file. Pure I/O wrapper around ``read_config``."""
64
+ return read_config(path)
65
+
66
+
67
+ def _substitute_tokens(value: Any, ctx: Mapping[str, Any]) -> Any:
68
+ """Recursively substitute ``{token}`` placeholders in strings.
69
+
70
+ Unknown tokens are left literal so partially-templated YAML is
71
+ still readable when ctx is incomplete (useful in tests). Lists
72
+ and dicts are walked; non-string leaves pass through untouched.
73
+ """
74
+ if isinstance(value, str):
75
+ try:
76
+ return value.format_map(_SafeMapping(ctx))
77
+ except (KeyError, IndexError, ValueError):
78
+ return value
79
+ if isinstance(value, list):
80
+ return [_substitute_tokens(v, ctx) for v in value] # type: ignore[reportUnknownVariableType]
81
+ if isinstance(value, dict):
82
+ return {k: _substitute_tokens(v, ctx) for k, v in value.items()} # type: ignore[reportUnknownVariableType]
83
+ return value
84
+
85
+
86
+ class _SafeMapping(dict[str, Any]):
87
+ """``str.format_map`` mapping that leaves unknown keys literal."""
88
+
89
+ def __init__(self, base: Mapping[str, Any]) -> None:
90
+ super().__init__(base)
91
+
92
+ def __missing__(self, key: str) -> str:
93
+ return "{" + key + "}"
94
+
95
+
96
+ def render(
97
+ document: Document,
98
+ items: Mapping[str, dict[str, Any]],
99
+ ctx: Mapping[str, Any] | None = None,
100
+ *,
101
+ section: str | None = None,
102
+ on_table: str | None = None,
103
+ ) -> None:
104
+ """Render every item in *items* whose filters match.
105
+
106
+ *items* maps item ids to configuration dicts; each dict must
107
+ declare a ``type`` key that resolves to a registered renderer.
108
+ String values inside the config (titles, labels, paths) are
109
+ formatted against *ctx*: ``"Bolsas {year}"`` with
110
+ ``ctx={"year": 2026}`` becomes ``"Bolsas 2026"``. Missing tokens
111
+ are left as literals.
112
+
113
+ Filters:
114
+
115
+ - ``section`` — only render items whose ``section`` field equals
116
+ this value (e.g. ``"pre_tables"`` vs ``"intro"``).
117
+ - ``on_table`` — only render items whose ``on_table`` field
118
+ equals this value, used to embed charts beside specific tables.
119
+
120
+ When both filters are ``None`` every item is rendered.
121
+ Items declaring an unknown ``type`` raise ``KeyError`` so the
122
+ misconfiguration surfaces immediately.
123
+ """
124
+ ctx_resolved: Mapping[str, Any] = ctx if ctx is not None else {}
125
+ for item_id, raw_cfg in items.items():
126
+ if section is not None and raw_cfg.get("section") != section:
127
+ continue
128
+ if on_table is not None and raw_cfg.get("on_table") != on_table:
129
+ continue
130
+ cfg: dict[str, Any] = _substitute_tokens(dict(raw_cfg), ctx_resolved)
131
+ cfg["id"] = item_id
132
+ type_name: str = cfg.get("type", "")
133
+ if type_name not in _REGISTRY:
134
+ raise KeyError(
135
+ f"No renderer registered for type {type_name!r} "
136
+ f"(item {item_id!r}). Available: {registered_types()}"
137
+ )
138
+ _REGISTRY[type_name](document, cfg, ctx_resolved)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  # pyright: reportPrivateUsage=false
4
+ from dataclasses import dataclass
4
5
  from pathlib import Path
5
6
  from typing import Any, Self
6
7
 
@@ -22,6 +23,27 @@ from tesorotools.utils.template import TemplateLoader
22
23
 
23
24
  RENDER_CONFIG: dict[str, Any] = read_config(PLOT_CONFIG_FILE)["table"]
24
25
 
26
+
27
+ @dataclass(frozen=True)
28
+ class RenderConfig:
29
+ """Per-call rendering options for :func:`render_table`.
30
+
31
+ Attributes match the ``table`` section of the bundled
32
+ ``plots.yaml``; defaults are read from there. Pass an instance
33
+ explicitly to override the global config without monkey-patching.
34
+ """
35
+
36
+ style: str | None = None
37
+ autofit: bool = False
38
+
39
+ @classmethod
40
+ def from_global(cls) -> RenderConfig:
41
+ return cls(
42
+ style=RENDER_CONFIG.get("style"),
43
+ autofit=bool(RENDER_CONFIG["autofit"]),
44
+ )
45
+
46
+
25
47
  TEXTO_TABLAS: int = 9
26
48
 
27
49
  CENTER = WD_ALIGN_PARAGRAPH.CENTER
@@ -250,9 +272,9 @@ def _fill_content(
250
272
  _style_content(cell)
251
273
 
252
274
 
253
- def _style_table(table_docx: TableDocx) -> None:
254
- table_docx.style = RENDER_CONFIG.get("style", None)
255
- table_docx.autofit = RENDER_CONFIG["autofit"]
275
+ def _style_table(table_docx: TableDocx, config: RenderConfig) -> None:
276
+ table_docx.style = config.style
277
+ table_docx.autofit = config.autofit
256
278
 
257
279
 
258
280
  def render_table(
@@ -261,16 +283,29 @@ def render_table(
261
283
  shade_table: pd.DataFrame | None,
262
284
  document: Document,
263
285
  block_sep: bool,
286
+ *,
287
+ config: RenderConfig | None = None,
264
288
  **kwargs: Any,
265
- ) -> Document:
266
-
289
+ ) -> TableDocx:
290
+ """Append a styled table to *document* and return the ``Table``.
291
+
292
+ Returning the ``docx.table.Table`` mirrors python-docx's own
293
+ ``add_*`` helpers (``add_paragraph``, ``add_heading``) and lets
294
+ callers post-process the table (alignment overrides, custom
295
+ borders, references for navigation) without resorting to
296
+ ``document.tables[-1]``. Pass *config* to override the global
297
+ rendering style/autofit on a per-call basis.
298
+ """
299
+ cfg: RenderConfig = (
300
+ config if config is not None else RenderConfig.from_global()
301
+ )
267
302
  horizontal: bool = isinstance(table.columns, pd.MultiIndex)
268
303
  table_docx: TableDocx = document.add_table(
269
304
  rows=len(table.index) + table.columns.nlevels,
270
305
  cols=len(table.columns) + 1,
271
306
  )
272
307
 
273
- _style_table(table_docx)
308
+ _style_table(table_docx, cfg)
274
309
  _fill_column_names(table, table_docx, horizontal)
275
310
  _fill_index_names(
276
311
  index=table.index,
@@ -282,7 +317,7 @@ def render_table(
282
317
  _separate_blocks(table.index, table_docx)
283
318
  _fill_content(table, color_table, shade_table, table_docx, horizontal)
284
319
  table_docx.alignment = WD_TABLE_ALIGNMENT.CENTER
285
- return document
320
+ return table_docx
286
321
 
287
322
 
288
323
  class Table: