tesorotools-python 0.0.56__tar.gz → 0.1.1__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 (134) hide show
  1. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/PKG-INFO +4 -1
  2. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/pyproject.toml +7 -0
  3. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/__init__.py +16 -4
  4. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/_common.py +108 -4
  5. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/bar_line.py +18 -1
  6. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/barh_plot.py +46 -16
  7. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/box_plot.py +16 -1
  8. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/line_plot.py +23 -1
  9. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/matrix.py +19 -9
  10. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/plotly_backend.py +5 -3
  11. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/shock_plot.py +13 -1
  12. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/stacked.py +47 -16
  13. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/type_curve.py +21 -9
  14. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/vector_plot.py +20 -9
  15. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/waterfall.py +13 -1
  16. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/template.docx +0 -0
  17. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/database/shared.py +6 -6
  18. tesorotools_python-0.1.1/src/tesorotools/latex/__init__.py +13 -0
  19. tesorotools_python-0.1.1/src/tesorotools/latex/cli.py +119 -0
  20. tesorotools_python-0.1.1/src/tesorotools/latex/scaffold.py +185 -0
  21. tesorotools_python-0.1.1/src/tesorotools/latex/templates/article/main.tex.tmpl +33 -0
  22. tesorotools_python-0.1.1/src/tesorotools/latex/templates/article/sections/01-introduccion.tex +18 -0
  23. tesorotools_python-0.1.1/src/tesorotools/latex/templates/article/sections/02-analisis.tex +51 -0
  24. tesorotools_python-0.1.1/src/tesorotools/latex/templates/article/sections/03-conclusiones.tex +10 -0
  25. tesorotools_python-0.1.1/src/tesorotools/latex/templates/beamer/main.tex.tmpl +47 -0
  26. tesorotools_python-0.1.1/src/tesorotools/latex/templates/beamer/slides/01-contexto.tex +28 -0
  27. tesorotools_python-0.1.1/src/tesorotools/latex/templates/beamer/slides/02-analisis.tex +39 -0
  28. tesorotools_python-0.1.1/src/tesorotools/latex/templates/beamer/slides/03-conclusiones.tex +13 -0
  29. tesorotools_python-0.1.1/src/tesorotools/latex/templates/beamer/style/beamerthemeTesoro.sty +123 -0
  30. tesorotools_python-0.1.1/src/tesorotools/latex/templates/book/appendices/a-fuentes.tex +10 -0
  31. tesorotools_python-0.1.1/src/tesorotools/latex/templates/book/chapters/01-introduccion.tex +25 -0
  32. tesorotools_python-0.1.1/src/tesorotools/latex/templates/book/chapters/02-analisis.tex +49 -0
  33. tesorotools_python-0.1.1/src/tesorotools/latex/templates/book/frontmatter/resumen.tex +10 -0
  34. tesorotools_python-0.1.1/src/tesorotools/latex/templates/book/main.tex.tmpl +41 -0
  35. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/README.md.tmpl +50 -0
  36. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/dot-gitignore +21 -0
  37. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/dot-vscode/settings.json +20 -0
  38. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/fonts/README.md +15 -0
  39. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/latexmkrc +9 -0
  40. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/refs.bib +17 -0
  41. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/style/tesoro.sty +207 -0
  42. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/style/tesorobrand.sty.tmpl +10 -0
  43. tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/style/tesorolocal.sty.tmpl +10 -0
  44. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/manifest.py +7 -7
  45. tesorotools_python-0.1.1/src/tesorotools/palette.py +59 -0
  46. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/providers/fred.py +3 -4
  47. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/providers/imf_irfcl.py +2 -1
  48. tesorotools_python-0.1.1/src/tesorotools/render/content/__init__.py +0 -0
  49. tesorotools_python-0.1.1/src/tesorotools/scaffold.py +127 -0
  50. tesorotools_python-0.1.1/src/tesorotools/web/__init__.py +112 -0
  51. tesorotools_python-0.1.1/src/tesorotools/web/app.py +221 -0
  52. tesorotools_python-0.1.1/src/tesorotools/web/brand.py +355 -0
  53. tesorotools_python-0.1.1/src/tesorotools/web/cli.py +92 -0
  54. tesorotools_python-0.1.1/src/tesorotools/web/components.py +210 -0
  55. tesorotools_python-0.1.1/src/tesorotools/web/formatting.py +45 -0
  56. tesorotools_python-0.1.1/src/tesorotools/web/launcher.py +42 -0
  57. tesorotools_python-0.1.1/src/tesorotools/web/palette.py +16 -0
  58. tesorotools_python-0.1.1/src/tesorotools/web/plotting.py +117 -0
  59. tesorotools_python-0.1.1/src/tesorotools/web/scaffold.py +95 -0
  60. tesorotools_python-0.1.1/src/tesorotools/web/templates/Dockerfile.tmpl +34 -0
  61. tesorotools_python-0.1.1/src/tesorotools/web/templates/README.md.tmpl +43 -0
  62. tesorotools_python-0.1.1/src/tesorotools/web/templates/dot-dockerignore.tmpl +6 -0
  63. tesorotools_python-0.1.1/src/tesorotools/web/templates/dot-gitignore.tmpl +6 -0
  64. tesorotools_python-0.1.1/src/tesorotools/web/templates/dot-streamlit/config.toml.tmpl +14 -0
  65. tesorotools_python-0.1.1/src/tesorotools/web/templates/pyproject.toml.tmpl +35 -0
  66. tesorotools_python-0.1.1/src/tesorotools/web/templates/src/$package/__init__.py.tmpl +1 -0
  67. tesorotools_python-0.1.1/src/tesorotools/web/templates/src/$package/cli.py.tmpl +11 -0
  68. tesorotools_python-0.1.1/src/tesorotools/web/templates/src/$package/main.py.tmpl +30 -0
  69. tesorotools_python-0.1.1/src/tesorotools/web/templates/src/$package/sections.py.tmpl +82 -0
  70. tesorotools_python-0.1.1/src/tesorotools/web/templates/tests/__init__.py.tmpl +0 -0
  71. tesorotools_python-0.1.1/src/tesorotools/web/templates/tests/test_app.py.tmpl +22 -0
  72. tesorotools_python-0.1.1/src/tesorotools/web/theme.py +295 -0
  73. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/.gitignore +0 -0
  74. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/__init__.py +0 -0
  75. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/_build_context.py +0 -0
  76. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/_registry.py +0 -0
  77. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/compact.py +0 -0
  78. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/artists/intraday_plot.py +0 -0
  79. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/README.md +0 -0
  80. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  81. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  82. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  83. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  84. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  85. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  86. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  87. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  88. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/fonts/README.md +0 -0
  89. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/plots.yaml +0 -0
  90. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  91. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/data_sources/__init__.py +0 -0
  92. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/data_sources/debug.py +0 -0
  93. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/database/__init__.py +0 -0
  94. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/database/local.py +0 -0
  95. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/database/push.py +0 -0
  96. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/dependencies/__init__.py +0 -0
  97. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/dependencies/node.py +0 -0
  98. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/dependencies/resolution.py +0 -0
  99. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/driver.py +0 -0
  100. /tesorotools_python-0.0.56/src/tesorotools/offsets/__init__.py → /tesorotools_python-0.1.1/src/tesorotools/latex/templates/common/figures/dot-gitkeep +0 -0
  101. {tesorotools_python-0.0.56/src/tesorotools/pipeline → tesorotools_python-0.1.1/src/tesorotools/offsets}/__init__.py +0 -0
  102. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/offsets/offsets.py +0 -0
  103. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/offsets/outliers.py +0 -0
  104. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/orchestration.py +0 -0
  105. {tesorotools_python-0.0.56/src/tesorotools/render/content → tesorotools_python-0.1.1/src/tesorotools/pipeline}/__init__.py +0 -0
  106. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/pipeline/diagnose.py +0 -0
  107. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/pipeline/engine.py +0 -0
  108. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/pipeline/rules.py +0 -0
  109. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/providers/__init__.py +0 -0
  110. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/providers/base.py +0 -0
  111. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/providers/bde.py +0 -0
  112. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/providers/ecb.py +0 -0
  113. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/providers/lseg.py +0 -0
  114. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/py.typed +0 -0
  115. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/__init__.py +0 -0
  116. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/content/content.py +0 -0
  117. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/content/images.py +0 -0
  118. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/content/section.py +0 -0
  119. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/content/subtitle.py +0 -0
  120. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/content/table.py +0 -0
  121. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/content/text.py +0 -0
  122. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/content/title.py +0 -0
  123. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/document.py +0 -0
  124. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/render/report.py +0 -0
  125. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/testing/__init__.py +0 -0
  126. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/testing/compare.py +0 -0
  127. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/__init__.py +0 -0
  128. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/config.py +0 -0
  129. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/format.py +0 -0
  130. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/globals.py +0 -0
  131. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/matplotlib.py +0 -0
  132. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/series.py +0 -0
  133. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/shortcuts.py +0 -0
  134. {tesorotools_python-0.0.56 → tesorotools_python-0.1.1}/src/tesorotools/utils/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tesorotools-python
3
- Version: 0.0.56
3
+ Version: 0.1.1
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -25,3 +25,6 @@ Provides-Extra: interactive-plots
25
25
  Requires-Dist: plotly>=5.0; extra == 'interactive-plots'
26
26
  Provides-Extra: lseg
27
27
  Requires-Dist: lseg-data>=2.1; extra == 'lseg'
28
+ Provides-Extra: web
29
+ Requires-Dist: plotly>=5.24; extra == 'web'
30
+ Requires-Dist: streamlit>=1.62; extra == 'web'
@@ -31,6 +31,13 @@ fred = ["requests>=2.31"]
31
31
  imf = ["requests>=2.31", "truststore>=0.10"]
32
32
  lseg = ["lseg-data>=2.1"]
33
33
  interactive_plots = ["plotly>=5.0"]
34
+ # Streamlit dashboards scaffolding (tesorotools.web). The CSS targets
35
+ # Streamlit's DOM (data-testid), so keep the floor at a tested version.
36
+ web = ["streamlit>=1.62", "plotly>=5.24"]
37
+
38
+ [project.scripts]
39
+ tesorotools-web = "tesorotools.web.cli:main"
40
+ tesorotools-latex = "tesorotools.latex.cli:main"
34
41
 
35
42
  [dependency-groups]
36
43
  dev = [
@@ -12,9 +12,13 @@ Each class follows the same shape:
12
12
  ``data_path`` (``.feather`` on disk), never both.
13
13
  * ``from_yaml(loader, node)`` -- builds the same instance
14
14
  from a ``!{name}`` YAML tag.
15
- * ``plot()`` -- renders the chart and writes the PNG; some
16
- classes also expose ``build()`` / ``save()`` for
17
- fine-grained control.
15
+ * ``build()`` -- renders the chart in memory and returns
16
+ ``(Figure, Axes)``; ``save(fig, path=..., dpi=...)``
17
+ writes it; ``plot()`` does both and closes the figure.
18
+ Every artist exposes the three (see :class:`Artist`).
19
+ * ``to_plotly()`` -- the same chart as an interactive
20
+ Plotly figure (all but ``IntradayPlot``);
21
+ :func:`plotly_unsupported` lists the keys it drops.
18
22
 
19
23
  Shared layout, annotation and styling helpers (plus the
20
24
  ``Format`` and ``Legend`` config holders) live in
@@ -25,7 +29,13 @@ live under :mod:`tesorotools.render`; this package is
25
29
  strictly image output.
26
30
  """
27
31
 
28
- from tesorotools.artists._common import Artist, Format, Legend
32
+ from tesorotools.artists._common import (
33
+ Artist,
34
+ FigureLifecycle,
35
+ Format,
36
+ Legend,
37
+ plotly_unsupported,
38
+ )
29
39
  from tesorotools.artists.bar_line import BarLinePlot
30
40
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
31
41
  from tesorotools.artists.box_plot import BoxPlot
@@ -42,6 +52,7 @@ __all__ = [
42
52
  "Artist",
43
53
  "BarLinePlot",
44
54
  "BoxPlot",
55
+ "FigureLifecycle",
45
56
  "Format",
46
57
  "GroupedBarChart",
47
58
  "HorizontalBarChart",
@@ -55,4 +66,5 @@ __all__ = [
55
66
  "TypeCurve",
56
67
  "VectorPlot",
57
68
  "Waterfall",
69
+ "plotly_unsupported",
58
70
  ]
@@ -29,7 +29,11 @@ from typing import Any, Literal, Protocol, Self, cast, runtime_checkable
29
29
  import matplotlib.pyplot as plt
30
30
  import pandas as pd
31
31
  from matplotlib.axes import Axes
32
- from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
32
+ from matplotlib.dates import (
33
+ AutoDateLocator,
34
+ ConciseDateFormatter,
35
+ YearLocator,
36
+ )
33
37
  from matplotlib.dates import (
34
38
  date2num, # type: ignore[reportUnknownVariableType]
35
39
  )
@@ -126,6 +130,70 @@ def dynamic_dpi(fig: Figure) -> int:
126
130
  return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
127
131
 
128
132
 
133
+ def plotly_unsupported(artist: object) -> frozenset[str]:
134
+ """Configuration keys *artist* accepts but ``to_plotly()`` drops.
135
+
136
+ *artist* is an artist class or instance. The keys change the PNG
137
+ but not the interactive figure; nested ones are dotted
138
+ (``"legend.ncol"``, ``"fmt.signed"``). A UI built on top can tell
139
+ the user why moving such a control leaves the figure as it is.
140
+ Needs no ``plotly``: it reads the class attribute
141
+ ``PLOTLY_UNSUPPORTED``, which the test suite checks against real
142
+ renders.
143
+ """
144
+ cls = artist if isinstance(artist, type) else type(artist)
145
+ if not hasattr(cls, "to_plotly"):
146
+ raise TypeError(f"{cls.__name__} has no to_plotly()")
147
+ declared: frozenset[str] | None = getattr(cls, "PLOTLY_UNSUPPORTED", None)
148
+ if declared is None:
149
+ raise TypeError(f"{cls.__name__} does not declare PLOTLY_UNSUPPORTED")
150
+ return frozenset(declared)
151
+
152
+
153
+ class FigureLifecycle:
154
+ """``save()`` and ``plot()`` on top of a subclass's ``build()``.
155
+
156
+ Gives an artist the same three-step life cycle as the rest:
157
+ ``build()`` returns the figure in memory, ``save()`` writes it
158
+ and ``plot()`` does both and closes the figure. A subclass only
159
+ implements ``build()``.
160
+ """
161
+
162
+ out_path: Path
163
+
164
+ def build(self) -> tuple[Figure, Axes]:
165
+ raise NotImplementedError
166
+
167
+ def save(
168
+ self,
169
+ fig: Figure,
170
+ *,
171
+ path: Path | None = None,
172
+ dpi: int | None = None,
173
+ ) -> Path:
174
+ """Persist *fig* as a PNG and return the path written.
175
+
176
+ Defaults to ``self.out_path``; pass ``path`` to redirect.
177
+ ``dpi`` overrides the save resolution; when omitted it is
178
+ derived from the figure size via :func:`dynamic_dpi`.
179
+ """
180
+ target: Path = path if path is not None else self.out_path
181
+ save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
182
+ fig.savefig( # type: ignore[reportUnknownMemberType]
183
+ target, dpi=save_dpi
184
+ )
185
+ return target
186
+
187
+ def plot(self) -> Axes:
188
+ """Build the chart, persist it to ``self.out_path`` and close it."""
189
+ fig, ax = self.build()
190
+ try:
191
+ self.save(fig)
192
+ finally:
193
+ plt.close(fig)
194
+ return ax
195
+
196
+
129
197
  # ----------------------------------------------------------------------
130
198
  # Structural contract for image artists
131
199
  # ----------------------------------------------------------------------
@@ -137,13 +205,21 @@ class Artist(Protocol):
137
205
 
138
206
  The built-in artists and any third-party artist
139
207
  registered via :func:`tesorotools.register_artist`
140
- expose the same three-piece surface:
208
+ expose the same surface:
141
209
 
142
210
  * ``out_path: Path`` -- the ``.png`` destination.
143
211
  * ``from_yaml(loader, node) -> Self`` -- the YAML
144
212
  constructor bound to the ``!{name}`` tag.
145
- * ``plot() -> Axes`` -- render the chart, persist it
146
- to ``out_path``, and return the axes for inspection.
213
+ * ``build() -> (Figure, Axes)`` -- render the chart in
214
+ memory, without touching the disk.
215
+ * ``save(fig, *, path=None, dpi=None) -> Path`` -- write
216
+ a built figure (to ``out_path`` unless ``path`` says
217
+ otherwise).
218
+ * ``plot() -> Axes`` -- ``build()`` + ``save()``, close
219
+ the figure and return the axes for inspection.
220
+
221
+ :class:`FigureLifecycle` provides ``save()`` and ``plot()``
222
+ for an artist that only implements ``build()``.
147
223
 
148
224
  The Protocol is ``runtime_checkable`` so consumers can
149
225
  use ``isinstance(obj, Artist)`` to validate a candidate
@@ -156,6 +232,16 @@ class Artist(Protocol):
156
232
  @classmethod
157
233
  def from_yaml(cls, loader: Any, node: MappingNode) -> Self: ...
158
234
 
235
+ def build(self) -> tuple[Figure, Axes]: ...
236
+
237
+ def save(
238
+ self,
239
+ fig: Figure,
240
+ *,
241
+ path: Path | None = None,
242
+ dpi: int | None = None,
243
+ ) -> Path: ...
244
+
159
245
  def plot(self) -> Axes: ...
160
246
 
161
247
 
@@ -580,6 +666,12 @@ def date_span_ticks(
580
666
  10 % of the span is dropped so the two labels do not collide. Without
581
667
  it the plain in-span candidates are returned (falling back to the two
582
668
  endpoints when the locator offers nothing inside).
669
+
670
+ When the locator steps by several years, the pinned grid is anchored to
671
+ the **start year** instead of to multiples of the step: a 2-year step
672
+ over 2023-2027 gives 2023, 2025, 2027 rather than the pinned 2023 plus
673
+ the calendar 2024, 2026, whose uneven gaps (and missing odd years) read
674
+ as if the axis skipped a year.
583
675
  """
584
676
  lo: float = float(cast(Any, date2num(data_min)))
585
677
  hi: float = float(cast(Any, date2num(data_max)))
@@ -589,6 +681,18 @@ def date_span_ticks(
589
681
  )
590
682
  if not pin_start:
591
683
  return [t for t in candidates if lo <= t <= hi] or [lo, hi]
684
+ chosen: Any = locator.get_locator( # type: ignore[reportUnknownMemberType]
685
+ data_min, data_max
686
+ )
687
+ if isinstance(chosen, YearLocator):
688
+ step = int(cast(Any, chosen.base).step)
689
+ if step > 1:
690
+ first_year: int = num2date(lo).year
691
+ last_year: int = num2date(hi).year
692
+ candidates = [
693
+ float(cast(Any, date2num(pd.Timestamp(year=y, month=1, day=1))))
694
+ for y in range(first_year, last_year + 1, step)
695
+ ]
592
696
  min_gap: float = (hi - lo) * 0.10
593
697
  interior: list[float] = [
594
698
  t for t in candidates if lo < t <= hi and t - lo >= min_gap
@@ -28,7 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import locale
30
30
  from pathlib import Path
31
- from typing import Any, Self
31
+ from typing import Any, ClassVar, Self
32
32
 
33
33
  import matplotlib.pyplot as plt
34
34
  import numpy as np
@@ -89,6 +89,23 @@ class BarLinePlot:
89
89
  (``None`` labels every slot).
90
90
  """
91
91
 
92
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
93
+ #: (see :func:`plotly_unsupported`); checked against real renders
94
+ #: by ``tests/test_plotly_support.py``.
95
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
96
+ {
97
+ "bar_width",
98
+ "compact",
99
+ "figsize",
100
+ "fmt.decimals",
101
+ "font_pt",
102
+ "legend.ncol",
103
+ "max_xticks",
104
+ "plot_size",
105
+ "x_rotation",
106
+ }
107
+ )
108
+
92
109
  def __init__(
93
110
  self,
94
111
  out_path: Path,
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  from enum import Enum
20
20
  from pathlib import Path
21
- from typing import Any, Self
21
+ from typing import Any, ClassVar, Self
22
22
 
23
23
  import matplotlib.patheffects as pe
24
24
  import matplotlib.pyplot as plt
@@ -32,6 +32,7 @@ from matplotlib.ticker import FuncFormatter
32
32
  from yaml.nodes import MappingNode
33
33
 
34
34
  from tesorotools.artists._common import (
35
+ FigureLifecycle,
35
36
  AX_CONFIG,
36
37
  Format,
37
38
  Legend,
@@ -39,7 +40,6 @@ from tesorotools.artists._common import (
39
40
  compact_barh_figsize,
40
41
  compact_figure_kwargs,
41
42
  compact_font_pt,
42
- dynamic_dpi,
43
43
  legend_layout,
44
44
  place_legend,
45
45
  resolve_data,
@@ -447,7 +447,7 @@ def _place_in_or_out_labels(
447
447
  return outside
448
448
 
449
449
 
450
- class HorizontalBarChart:
450
+ class HorizontalBarChart(FigureLifecycle):
451
451
  """Horizontal bar chart artist.
452
452
 
453
453
  Parameters
@@ -507,6 +507,19 @@ class HorizontalBarChart:
507
507
  outside; pragmatic but mixes the two looks).
508
508
  """
509
509
 
510
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
511
+ #: (see :func:`plotly_unsupported`); checked against real renders
512
+ #: by ``tests/test_plotly_support.py``.
513
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
514
+ {
515
+ "color_value_labels",
516
+ "compact",
517
+ "figsize",
518
+ "font_pt",
519
+ "value_label_position",
520
+ }
521
+ )
522
+
510
523
  def __init__(
511
524
  self,
512
525
  out_path: Path,
@@ -641,7 +654,11 @@ class HorizontalBarChart:
641
654
  frame = frame.sort_values(by=_Col.VALUE.value)
642
655
  return frame
643
656
 
644
- def plot(self) -> Axes:
657
+ def build(self) -> tuple[Figure, Axes]:
658
+ """Render the chart in memory; :meth:`save` writes it.
659
+
660
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
661
+ """
645
662
  """Render the chart and persist it to ``self.out_path``."""
646
663
  frame = self._build_frame()
647
664
 
@@ -689,14 +706,10 @@ class HorizontalBarChart:
689
706
  axis="both", labelsize=font_pt
690
707
  )
691
708
 
692
- fig.savefig( # type: ignore[reportUnknownMemberType]
693
- self.out_path, dpi=dynamic_dpi(fig)
694
- )
695
- plt.close(fig)
696
- return ax
709
+ return fig, ax
697
710
 
698
711
 
699
- class GroupedBarChart:
712
+ class GroupedBarChart(FigureLifecycle):
700
713
  """Grouped (clustered) horizontal bar chart artist.
701
714
 
702
715
  Compares two or more series side-by-side within each
@@ -764,6 +777,23 @@ class GroupedBarChart:
764
777
  see :class:`HorizontalBarChart`.
765
778
  """
766
779
 
780
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
781
+ #: (see :func:`plotly_unsupported`); checked against real renders
782
+ #: by ``tests/test_plotly_support.py``.
783
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
784
+ {
785
+ "annotate",
786
+ "color_value_labels",
787
+ "compact",
788
+ "figsize",
789
+ "font_pt",
790
+ "group_width",
791
+ "legend.ncol",
792
+ "plot_size",
793
+ "value_label_position",
794
+ }
795
+ )
796
+
767
797
  def __init__(
768
798
  self,
769
799
  out_path: Path,
@@ -867,7 +897,11 @@ class GroupedBarChart:
867
897
 
868
898
  return grouped_bar_to_figure(self)
869
899
 
870
- def plot(self) -> Axes:
900
+ def build(self) -> tuple[Figure, Axes]:
901
+ """Render the chart in memory; :meth:`save` writes it.
902
+
903
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
904
+ """
871
905
  """Render the chart and persist it to ``self.out_path``."""
872
906
  cat_ids = list(self.categories.keys())
873
907
  ser_ids = list(self.series.keys())
@@ -941,8 +975,4 @@ class GroupedBarChart:
941
975
  if self.plot_size is not None and not self.compact:
942
976
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
943
977
 
944
- fig.savefig( # type: ignore[reportUnknownMemberType]
945
- self.out_path, dpi=dynamic_dpi(fig)
946
- )
947
- plt.close(fig)
948
- return ax
978
+ return fig, ax
@@ -32,7 +32,7 @@ The last-value marker is a circle by default and fully configurable
32
32
  from __future__ import annotations
33
33
 
34
34
  from pathlib import Path
35
- from typing import Any, Self
35
+ from typing import Any, ClassVar, Self
36
36
 
37
37
  import matplotlib.pyplot as plt
38
38
  import numpy as np
@@ -129,6 +129,21 @@ class BoxPlot:
129
129
  * ``legend_labels`` -- override the four proxy labels.
130
130
  """
131
131
 
132
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
133
+ #: (see :func:`plotly_unsupported`); checked against real renders
134
+ #: by ``tests/test_plotly_support.py``.
135
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
136
+ {
137
+ "compact",
138
+ "figsize",
139
+ "fmt.decimals",
140
+ "font_pt",
141
+ "legend.ncol",
142
+ "plot_size",
143
+ "x_rotation",
144
+ }
145
+ )
146
+
132
147
  def __init__(
133
148
  self,
134
149
  out_path: Path,
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  import datetime
13
13
  import locale
14
14
  from pathlib import Path
15
- from typing import Any, Self
15
+ from typing import Any, ClassVar, Self
16
16
 
17
17
  import matplotlib.pyplot as plt
18
18
  import pandas as pd
@@ -166,6 +166,28 @@ class LinePlot:
166
166
  ``plot()`` is the thin convenience that does both.
167
167
  """
168
168
 
169
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
170
+ #: (see :func:`plotly_unsupported`); checked against real renders
171
+ #: by ``tests/test_plotly_support.py``.
172
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
173
+ {
174
+ "adapt_height",
175
+ "axis_decimals",
176
+ "compact",
177
+ "date_density",
178
+ "date_rotation",
179
+ "date_show_start",
180
+ "figsize",
181
+ "font_pt",
182
+ "legend.ncol",
183
+ "means_window",
184
+ "plot_size",
185
+ "ref_lines",
186
+ "show_means",
187
+ "vlines",
188
+ }
189
+ )
190
+
169
191
  def __init__(
170
192
  self,
171
193
  out_path: Path,
@@ -27,7 +27,7 @@ Styling constants come from ``PLOT_CONFIG['matrix']``.
27
27
  from __future__ import annotations
28
28
 
29
29
  from pathlib import Path
30
- from typing import Any, Self
30
+ from typing import Any, ClassVar, Self
31
31
 
32
32
  import matplotlib as mpl
33
33
  import matplotlib.pyplot as plt
@@ -40,9 +40,9 @@ from matplotlib.patches import Rectangle
40
40
  from yaml.nodes import MappingNode
41
41
 
42
42
  from tesorotools.artists._common import (
43
+ FigureLifecycle,
43
44
  FIG_CONFIG,
44
45
  Format,
45
- dynamic_dpi,
46
46
  resolve_data,
47
47
  )
48
48
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
@@ -66,7 +66,7 @@ def _text_color(rgba: tuple[float, float, float, float]) -> str:
66
66
  return MATRIX_CONFIG["dark_text"]
67
67
 
68
68
 
69
- class MatrixChart:
69
+ class MatrixChart(FigureLifecycle):
70
70
  """Annotated heatmap-table artist (any ``n_rows x n_cols``).
71
71
 
72
72
  Parameters
@@ -102,6 +102,16 @@ class MatrixChart:
102
102
  cells read as tiles.
103
103
  """
104
104
 
105
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
106
+ #: (see :func:`plotly_unsupported`); checked against real renders
107
+ #: by ``tests/test_plotly_support.py``.
108
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
109
+ {
110
+ "cmap",
111
+ "figsize",
112
+ }
113
+ )
114
+
105
115
  def __init__(
106
116
  self,
107
117
  out_path: Path,
@@ -199,7 +209,11 @@ class MatrixChart:
199
209
 
200
210
  return matrix_to_figure(self)
201
211
 
202
- def plot(self) -> Axes:
212
+ def build(self) -> tuple[Figure, Axes]:
213
+ """Render the chart in memory; :meth:`save` writes it.
214
+
215
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
216
+ """
203
217
  """Render the chart and persist it to ``self.out_path``."""
204
218
  row_ids = list(self.rows)
205
219
  col_ids = list(self.cols)
@@ -260,11 +274,7 @@ class MatrixChart:
260
274
  )
261
275
 
262
276
  self._style_axes(ax, row_ids, col_ids)
263
- fig.savefig( # type: ignore[reportUnknownMemberType]
264
- self.out_path, dpi=dynamic_dpi(fig)
265
- )
266
- plt.close(fig)
267
- return ax
277
+ return fig, ax
268
278
 
269
279
  def _annotate_cell(
270
280
  self,
@@ -35,9 +35,11 @@ backends, per the dual-backend research):
35
35
  ``adapt_height``) do nothing here.
36
36
  * Beyond those, a few per-artist knobs have no stable Plotly equivalent
37
37
  and a few others are simply not translated yet. Both kinds are
38
- accepted in silence, so which is which is written down parameter by
39
- parameter in ``docs/plotly-backend.md`` (audited for ``LinePlot``).
40
- Keep that table in sync when adding or translating a parameter.
38
+ accepted in silence; each artist lists them in ``PLOTLY_UNSUPPORTED``
39
+ (read with :func:`tesorotools.artists.plotly_unsupported`), which
40
+ ``tests/test_plotly_support.py`` checks against real renders. When
41
+ translating a key, drop it from there and from the table in
42
+ ``docs/plotly-backend.md``.
41
43
  """
42
44
 
43
45
  from __future__ import annotations
@@ -30,7 +30,7 @@ from __future__ import annotations
30
30
 
31
31
  from dataclasses import dataclass
32
32
  from pathlib import Path
33
- from typing import Any, Self, cast
33
+ from typing import Any, ClassVar, Self, cast
34
34
 
35
35
  import matplotlib.pyplot as plt
36
36
  import pandas as pd
@@ -124,6 +124,18 @@ class ShockChart:
124
124
  value instead of each window's own anchor; must be a shock label.
125
125
  """
126
126
 
127
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
128
+ #: (see :func:`plotly_unsupported`); checked against real renders
129
+ #: by ``tests/test_plotly_support.py``.
130
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
131
+ {
132
+ "compact",
133
+ "figsize",
134
+ "fmt.decimals",
135
+ "legend.ncol",
136
+ }
137
+ )
138
+
127
139
  def __init__(
128
140
  self,
129
141
  out_path: Path,