tesorotools-python 0.1.0__tar.gz → 0.1.2__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 (135) hide show
  1. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/PKG-INFO +1 -1
  2. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/__init__.py +2 -0
  3. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/__init__.py +21 -4
  4. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/_common.py +133 -6
  5. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/bar_line.py +18 -1
  6. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/barh_plot.py +46 -16
  7. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/box_plot.py +16 -1
  8. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/line_plot.py +55 -2
  9. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/matrix.py +19 -9
  10. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/plotly_backend.py +8 -3
  11. tesorotools_python-0.1.2/src/tesorotools/artists/sharing.py +59 -0
  12. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/shock_plot.py +13 -1
  13. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/stacked.py +47 -16
  14. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/type_curve.py +21 -9
  15. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/vector_plot.py +20 -9
  16. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/waterfall.py +13 -1
  17. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/.gitignore +0 -0
  18. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/pyproject.toml +0 -0
  19. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/_build_context.py +0 -0
  20. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/_registry.py +0 -0
  21. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/compact.py +0 -0
  22. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/artists/intraday_plot.py +0 -0
  23. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/README.md +0 -0
  24. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  25. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  26. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  27. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  28. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  29. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  30. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  31. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  32. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/fonts/README.md +0 -0
  33. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/plots.yaml +0 -0
  34. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/template.docx +0 -0
  35. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  36. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/data_sources/__init__.py +0 -0
  37. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/data_sources/debug.py +0 -0
  38. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/database/__init__.py +0 -0
  39. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/database/local.py +0 -0
  40. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/database/push.py +0 -0
  41. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/database/shared.py +0 -0
  42. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/dependencies/__init__.py +0 -0
  43. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/dependencies/node.py +0 -0
  44. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/dependencies/resolution.py +0 -0
  45. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/driver.py +0 -0
  46. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/__init__.py +0 -0
  47. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/cli.py +0 -0
  48. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/scaffold.py +0 -0
  49. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/article/main.tex.tmpl +0 -0
  50. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/article/sections/01-introduccion.tex +0 -0
  51. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/article/sections/02-analisis.tex +0 -0
  52. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/article/sections/03-conclusiones.tex +0 -0
  53. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/beamer/main.tex.tmpl +0 -0
  54. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/beamer/slides/01-contexto.tex +0 -0
  55. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/beamer/slides/02-analisis.tex +0 -0
  56. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/beamer/slides/03-conclusiones.tex +0 -0
  57. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/beamer/style/beamerthemeTesoro.sty +0 -0
  58. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/book/appendices/a-fuentes.tex +0 -0
  59. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/book/chapters/01-introduccion.tex +0 -0
  60. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/book/chapters/02-analisis.tex +0 -0
  61. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/book/frontmatter/resumen.tex +0 -0
  62. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/book/main.tex.tmpl +0 -0
  63. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/README.md.tmpl +0 -0
  64. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/dot-gitignore +0 -0
  65. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/dot-vscode/settings.json +0 -0
  66. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/figures/dot-gitkeep +0 -0
  67. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/fonts/README.md +0 -0
  68. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/latexmkrc +0 -0
  69. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/refs.bib +0 -0
  70. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/style/tesoro.sty +0 -0
  71. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/style/tesorobrand.sty.tmpl +0 -0
  72. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/latex/templates/common/style/tesorolocal.sty.tmpl +0 -0
  73. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/manifest.py +0 -0
  74. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/offsets/__init__.py +0 -0
  75. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/offsets/offsets.py +0 -0
  76. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/offsets/outliers.py +0 -0
  77. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/orchestration.py +0 -0
  78. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/palette.py +0 -0
  79. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/pipeline/__init__.py +0 -0
  80. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/pipeline/diagnose.py +0 -0
  81. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/pipeline/engine.py +0 -0
  82. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/pipeline/rules.py +0 -0
  83. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/providers/__init__.py +0 -0
  84. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/providers/base.py +0 -0
  85. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/providers/bde.py +0 -0
  86. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/providers/ecb.py +0 -0
  87. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/providers/fred.py +0 -0
  88. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/providers/imf_irfcl.py +0 -0
  89. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/providers/lseg.py +0 -0
  90. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/py.typed +0 -0
  91. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/__init__.py +0 -0
  92. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/__init__.py +0 -0
  93. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/content.py +0 -0
  94. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/images.py +0 -0
  95. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/section.py +0 -0
  96. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/subtitle.py +0 -0
  97. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/table.py +0 -0
  98. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/text.py +0 -0
  99. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/content/title.py +0 -0
  100. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/document.py +0 -0
  101. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/render/report.py +0 -0
  102. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/scaffold.py +0 -0
  103. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/testing/__init__.py +0 -0
  104. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/testing/compare.py +0 -0
  105. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/__init__.py +0 -0
  106. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/config.py +0 -0
  107. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/format.py +0 -0
  108. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/globals.py +0 -0
  109. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/matplotlib.py +0 -0
  110. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/series.py +0 -0
  111. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/shortcuts.py +0 -0
  112. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/utils/template.py +0 -0
  113. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/__init__.py +0 -0
  114. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/app.py +0 -0
  115. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/brand.py +0 -0
  116. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/cli.py +0 -0
  117. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/components.py +0 -0
  118. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/formatting.py +0 -0
  119. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/launcher.py +0 -0
  120. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/palette.py +0 -0
  121. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/plotting.py +0 -0
  122. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/scaffold.py +0 -0
  123. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/Dockerfile.tmpl +0 -0
  124. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/README.md.tmpl +0 -0
  125. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/dot-dockerignore.tmpl +0 -0
  126. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/dot-gitignore.tmpl +0 -0
  127. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/dot-streamlit/config.toml.tmpl +0 -0
  128. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/pyproject.toml.tmpl +0 -0
  129. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/src/$package/__init__.py.tmpl +0 -0
  130. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/src/$package/cli.py.tmpl +0 -0
  131. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/src/$package/main.py.tmpl +0 -0
  132. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/src/$package/sections.py.tmpl +0 -0
  133. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/tests/__init__.py.tmpl +0 -0
  134. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/templates/tests/test_app.py.tmpl +0 -0
  135. {tesorotools_python-0.1.0 → tesorotools_python-0.1.2}/src/tesorotools/web/theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tesorotools-python
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -72,6 +72,7 @@ from tesorotools.artists import (
72
72
  TypeCurve,
73
73
  VectorPlot,
74
74
  Waterfall,
75
+ share_y_axes,
75
76
  )
76
77
  from tesorotools.orchestration import CompositeRegistry, iter_contexts
77
78
  from tesorotools.providers.base import (
@@ -177,6 +178,7 @@ __all__ = [
177
178
  "register_providers",
178
179
  "register_tag",
179
180
  "register_tags",
181
+ "share_y_axes",
180
182
  ]
181
183
 
182
184
 
@@ -12,9 +12,16 @@ Each class follows the same shape:
12
12
  ``data_path`` (``.feather`` on disk), never both.
13
13
  * ``from_yaml(loader, node)`` -- builds the same instance
14
14
  from a ``!{name}`` YAML tag.
15
- * ``plot()`` -- renders the chart and writes the PNG; some
16
- classes also expose ``build()`` / ``save()`` for
17
- fine-grained control.
15
+ * ``build()`` -- renders the chart in memory and returns
16
+ ``(Figure, Axes)``; ``save(fig, path=..., dpi=...)``
17
+ writes it; ``plot()`` does both and closes the figure.
18
+ Every artist exposes the three (see :class:`Artist`).
19
+ * ``to_plotly()`` -- the same chart as an interactive
20
+ Plotly figure (all but ``IntradayPlot``);
21
+ :func:`plotly_unsupported` lists the keys it drops.
22
+
23
+ :func:`share_y_axes` gives the charts that declare the same
24
+ ``y_group`` one common y scale.
18
25
 
19
26
  Shared layout, annotation and styling helpers (plus the
20
27
  ``Format`` and ``Legend`` config holders) live in
@@ -25,13 +32,20 @@ live under :mod:`tesorotools.render`; this package is
25
32
  strictly image output.
26
33
  """
27
34
 
28
- from tesorotools.artists._common import Artist, Format, Legend
35
+ from tesorotools.artists._common import (
36
+ Artist,
37
+ FigureLifecycle,
38
+ Format,
39
+ Legend,
40
+ plotly_unsupported,
41
+ )
29
42
  from tesorotools.artists.bar_line import BarLinePlot
30
43
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
31
44
  from tesorotools.artists.box_plot import BoxPlot
32
45
  from tesorotools.artists.intraday_plot import IntradayPlot
33
46
  from tesorotools.artists.line_plot import LinePlot
34
47
  from tesorotools.artists.matrix import MatrixChart
48
+ from tesorotools.artists.sharing import share_y_axes
35
49
  from tesorotools.artists.shock_plot import ShockChart
36
50
  from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
37
51
  from tesorotools.artists.type_curve import TypeCurve
@@ -42,6 +56,7 @@ __all__ = [
42
56
  "Artist",
43
57
  "BarLinePlot",
44
58
  "BoxPlot",
59
+ "FigureLifecycle",
45
60
  "Format",
46
61
  "GroupedBarChart",
47
62
  "HorizontalBarChart",
@@ -55,4 +70,6 @@ __all__ = [
55
70
  "TypeCurve",
56
71
  "VectorPlot",
57
72
  "Waterfall",
73
+ "plotly_unsupported",
74
+ "share_y_axes",
58
75
  ]
@@ -23,13 +23,18 @@ overrides only need to update ``plots.yaml``.
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ from collections.abc import Sequence
26
27
  from pathlib import Path
27
28
  from typing import Any, Literal, Protocol, Self, cast, runtime_checkable
28
29
 
29
30
  import matplotlib.pyplot as plt
30
31
  import pandas as pd
31
32
  from matplotlib.axes import Axes
32
- from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
33
+ from matplotlib.dates import (
34
+ AutoDateLocator,
35
+ ConciseDateFormatter,
36
+ YearLocator,
37
+ )
33
38
  from matplotlib.dates import (
34
39
  date2num, # type: ignore[reportUnknownVariableType]
35
40
  )
@@ -126,6 +131,70 @@ def dynamic_dpi(fig: Figure) -> int:
126
131
  return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
127
132
 
128
133
 
134
+ def plotly_unsupported(artist: object) -> frozenset[str]:
135
+ """Configuration keys *artist* accepts but ``to_plotly()`` drops.
136
+
137
+ *artist* is an artist class or instance. The keys change the PNG
138
+ but not the interactive figure; nested ones are dotted
139
+ (``"legend.ncol"``, ``"fmt.signed"``). A UI built on top can tell
140
+ the user why moving such a control leaves the figure as it is.
141
+ Needs no ``plotly``: it reads the class attribute
142
+ ``PLOTLY_UNSUPPORTED``, which the test suite checks against real
143
+ renders.
144
+ """
145
+ cls = artist if isinstance(artist, type) else type(artist)
146
+ if not hasattr(cls, "to_plotly"):
147
+ raise TypeError(f"{cls.__name__} has no to_plotly()")
148
+ declared: frozenset[str] | None = getattr(cls, "PLOTLY_UNSUPPORTED", None)
149
+ if declared is None:
150
+ raise TypeError(f"{cls.__name__} does not declare PLOTLY_UNSUPPORTED")
151
+ return frozenset(declared)
152
+
153
+
154
+ class FigureLifecycle:
155
+ """``save()`` and ``plot()`` on top of a subclass's ``build()``.
156
+
157
+ Gives an artist the same three-step life cycle as the rest:
158
+ ``build()`` returns the figure in memory, ``save()`` writes it
159
+ and ``plot()`` does both and closes the figure. A subclass only
160
+ implements ``build()``.
161
+ """
162
+
163
+ out_path: Path
164
+
165
+ def build(self) -> tuple[Figure, Axes]:
166
+ raise NotImplementedError
167
+
168
+ def save(
169
+ self,
170
+ fig: Figure,
171
+ *,
172
+ path: Path | None = None,
173
+ dpi: int | None = None,
174
+ ) -> Path:
175
+ """Persist *fig* as a PNG and return the path written.
176
+
177
+ Defaults to ``self.out_path``; pass ``path`` to redirect.
178
+ ``dpi`` overrides the save resolution; when omitted it is
179
+ derived from the figure size via :func:`dynamic_dpi`.
180
+ """
181
+ target: Path = path if path is not None else self.out_path
182
+ save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
183
+ fig.savefig( # type: ignore[reportUnknownMemberType]
184
+ target, dpi=save_dpi
185
+ )
186
+ return target
187
+
188
+ def plot(self) -> Axes:
189
+ """Build the chart, persist it to ``self.out_path`` and close it."""
190
+ fig, ax = self.build()
191
+ try:
192
+ self.save(fig)
193
+ finally:
194
+ plt.close(fig)
195
+ return ax
196
+
197
+
129
198
  # ----------------------------------------------------------------------
130
199
  # Structural contract for image artists
131
200
  # ----------------------------------------------------------------------
@@ -137,13 +206,21 @@ class Artist(Protocol):
137
206
 
138
207
  The built-in artists and any third-party artist
139
208
  registered via :func:`tesorotools.register_artist`
140
- expose the same three-piece surface:
209
+ expose the same surface:
141
210
 
142
211
  * ``out_path: Path`` -- the ``.png`` destination.
143
212
  * ``from_yaml(loader, node) -> Self`` -- the YAML
144
213
  constructor bound to the ``!{name}`` tag.
145
- * ``plot() -> Axes`` -- render the chart, persist it
146
- to ``out_path``, and return the axes for inspection.
214
+ * ``build() -> (Figure, Axes)`` -- render the chart in
215
+ memory, without touching the disk.
216
+ * ``save(fig, *, path=None, dpi=None) -> Path`` -- write
217
+ a built figure (to ``out_path`` unless ``path`` says
218
+ otherwise).
219
+ * ``plot() -> Axes`` -- ``build()`` + ``save()``, close
220
+ the figure and return the axes for inspection.
221
+
222
+ :class:`FigureLifecycle` provides ``save()`` and ``plot()``
223
+ for an artist that only implements ``build()``.
147
224
 
148
225
  The Protocol is ``runtime_checkable`` so consumers can
149
226
  use ``isinstance(obj, Artist)`` to validate a candidate
@@ -156,6 +233,16 @@ class Artist(Protocol):
156
233
  @classmethod
157
234
  def from_yaml(cls, loader: Any, node: MappingNode) -> Self: ...
158
235
 
236
+ def build(self) -> tuple[Figure, Axes]: ...
237
+
238
+ def save(
239
+ self,
240
+ fig: Figure,
241
+ *,
242
+ path: Path | None = None,
243
+ dpi: int | None = None,
244
+ ) -> Path: ...
245
+
159
246
  def plot(self) -> Axes: ...
160
247
 
161
248
 
@@ -580,6 +667,12 @@ def date_span_ticks(
580
667
  10 % of the span is dropped so the two labels do not collide. Without
581
668
  it the plain in-span candidates are returned (falling back to the two
582
669
  endpoints when the locator offers nothing inside).
670
+
671
+ When the locator steps by several years, the pinned grid is anchored to
672
+ the **start year** instead of to multiples of the step: a 2-year step
673
+ over 2023-2027 gives 2023, 2025, 2027 rather than the pinned 2023 plus
674
+ the calendar 2024, 2026, whose uneven gaps (and missing odd years) read
675
+ as if the axis skipped a year.
583
676
  """
584
677
  lo: float = float(cast(Any, date2num(data_min)))
585
678
  hi: float = float(cast(Any, date2num(data_max)))
@@ -589,6 +682,18 @@ def date_span_ticks(
589
682
  )
590
683
  if not pin_start:
591
684
  return [t for t in candidates if lo <= t <= hi] or [lo, hi]
685
+ chosen: Any = locator.get_locator( # type: ignore[reportUnknownMemberType]
686
+ data_min, data_max
687
+ )
688
+ if isinstance(chosen, YearLocator):
689
+ step = int(cast(Any, chosen.base).step)
690
+ if step > 1:
691
+ first_year: int = num2date(lo).year
692
+ last_year: int = num2date(hi).year
693
+ candidates = [
694
+ float(cast(Any, date2num(pd.Timestamp(year=y, month=1, day=1))))
695
+ for y in range(first_year, last_year + 1, step)
696
+ ]
592
697
  min_gap: float = (hi - lo) * 0.10
593
698
  interior: list[float] = [
594
699
  t for t in candidates if lo < t <= hi and t - lo >= min_gap
@@ -1195,6 +1300,26 @@ def style_spines(
1195
1300
  )
1196
1301
 
1197
1302
 
1303
+ def check_y_limits(
1304
+ limits: Sequence[float] | None,
1305
+ ) -> tuple[float, float] | None:
1306
+ """Validate a ``y_limits`` pair and return it as ``(bottom, top)``.
1307
+
1308
+ YAML hands the pair over as a list, so any two-item sequence is
1309
+ accepted; ``None`` (no fixed range) passes through. Raises
1310
+ ``ValueError`` unless there are exactly two numbers with
1311
+ ``bottom < top``.
1312
+ """
1313
+ if limits is None:
1314
+ return None
1315
+ if len(limits) != 2:
1316
+ raise ValueError(f"y_limits must be (bottom, top): {limits!r}")
1317
+ bottom, top = float(limits[0]), float(limits[1])
1318
+ if not bottom < top:
1319
+ raise ValueError(f"y_limits bottom must be below top: {limits!r}")
1320
+ return bottom, top
1321
+
1322
+
1198
1323
  def style_baseline(
1199
1324
  ax: Axes,
1200
1325
  reference: float = 0,
@@ -1235,6 +1360,7 @@ def annotate_last_values(
1235
1360
  fontsize: float | None = None,
1236
1361
  right_pad_px: float = 10.0,
1237
1362
  headroom_pad_pt: float = 0.0,
1363
+ grow_top: bool = True,
1238
1364
  ) -> None:
1239
1365
  """Label the last non-NaN value of each column on the right.
1240
1366
 
@@ -1261,7 +1387,8 @@ def annotate_last_values(
1261
1387
  y-limit, that limit grows enough to keep this many points
1262
1388
  free and the labels are re-packed at the new scale, so a
1263
1389
  tall stack never spills into a legend above the axes (nor
1264
- is clipped at the top).
1390
+ is clipped at the top). ``grow_top=False`` keeps the top
1391
+ y-limit as it is (a fixed or shared y range must not move).
1265
1392
  """
1266
1393
  fig = ax.get_figure()
1267
1394
  if fig is None:
@@ -1333,7 +1460,7 @@ def annotate_last_values(
1333
1460
  pad_px = headroom_pad_pt * fig.dpi / 72.0
1334
1461
  top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
1335
1462
  needed_px = placements[-1] + text_height / 2 + pad_px
1336
- if needed_px > top_axes_px:
1463
+ if grow_top and needed_px > top_axes_px:
1337
1464
  new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
1338
1465
  (0, needed_px)
1339
1466
  )[1]
@@ -28,7 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import locale
30
30
  from pathlib import Path
31
- from typing import Any, Self
31
+ from typing import Any, ClassVar, Self
32
32
 
33
33
  import matplotlib.pyplot as plt
34
34
  import numpy as np
@@ -89,6 +89,23 @@ class BarLinePlot:
89
89
  (``None`` labels every slot).
90
90
  """
91
91
 
92
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
93
+ #: (see :func:`plotly_unsupported`); checked against real renders
94
+ #: by ``tests/test_plotly_support.py``.
95
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
96
+ {
97
+ "bar_width",
98
+ "compact",
99
+ "figsize",
100
+ "fmt.decimals",
101
+ "font_pt",
102
+ "legend.ncol",
103
+ "max_xticks",
104
+ "plot_size",
105
+ "x_rotation",
106
+ }
107
+ )
108
+
92
109
  def __init__(
93
110
  self,
94
111
  out_path: Path,
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  from enum import Enum
20
20
  from pathlib import Path
21
- from typing import Any, Self
21
+ from typing import Any, ClassVar, Self
22
22
 
23
23
  import matplotlib.patheffects as pe
24
24
  import matplotlib.pyplot as plt
@@ -32,6 +32,7 @@ from matplotlib.ticker import FuncFormatter
32
32
  from yaml.nodes import MappingNode
33
33
 
34
34
  from tesorotools.artists._common import (
35
+ FigureLifecycle,
35
36
  AX_CONFIG,
36
37
  Format,
37
38
  Legend,
@@ -39,7 +40,6 @@ from tesorotools.artists._common import (
39
40
  compact_barh_figsize,
40
41
  compact_figure_kwargs,
41
42
  compact_font_pt,
42
- dynamic_dpi,
43
43
  legend_layout,
44
44
  place_legend,
45
45
  resolve_data,
@@ -447,7 +447,7 @@ def _place_in_or_out_labels(
447
447
  return outside
448
448
 
449
449
 
450
- class HorizontalBarChart:
450
+ class HorizontalBarChart(FigureLifecycle):
451
451
  """Horizontal bar chart artist.
452
452
 
453
453
  Parameters
@@ -507,6 +507,19 @@ class HorizontalBarChart:
507
507
  outside; pragmatic but mixes the two looks).
508
508
  """
509
509
 
510
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
511
+ #: (see :func:`plotly_unsupported`); checked against real renders
512
+ #: by ``tests/test_plotly_support.py``.
513
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
514
+ {
515
+ "color_value_labels",
516
+ "compact",
517
+ "figsize",
518
+ "font_pt",
519
+ "value_label_position",
520
+ }
521
+ )
522
+
510
523
  def __init__(
511
524
  self,
512
525
  out_path: Path,
@@ -641,7 +654,11 @@ class HorizontalBarChart:
641
654
  frame = frame.sort_values(by=_Col.VALUE.value)
642
655
  return frame
643
656
 
644
- def plot(self) -> Axes:
657
+ def build(self) -> tuple[Figure, Axes]:
658
+ """Render the chart in memory; :meth:`save` writes it.
659
+
660
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
661
+ """
645
662
  """Render the chart and persist it to ``self.out_path``."""
646
663
  frame = self._build_frame()
647
664
 
@@ -689,14 +706,10 @@ class HorizontalBarChart:
689
706
  axis="both", labelsize=font_pt
690
707
  )
691
708
 
692
- fig.savefig( # type: ignore[reportUnknownMemberType]
693
- self.out_path, dpi=dynamic_dpi(fig)
694
- )
695
- plt.close(fig)
696
- return ax
709
+ return fig, ax
697
710
 
698
711
 
699
- class GroupedBarChart:
712
+ class GroupedBarChart(FigureLifecycle):
700
713
  """Grouped (clustered) horizontal bar chart artist.
701
714
 
702
715
  Compares two or more series side-by-side within each
@@ -764,6 +777,23 @@ class GroupedBarChart:
764
777
  see :class:`HorizontalBarChart`.
765
778
  """
766
779
 
780
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
781
+ #: (see :func:`plotly_unsupported`); checked against real renders
782
+ #: by ``tests/test_plotly_support.py``.
783
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
784
+ {
785
+ "annotate",
786
+ "color_value_labels",
787
+ "compact",
788
+ "figsize",
789
+ "font_pt",
790
+ "group_width",
791
+ "legend.ncol",
792
+ "plot_size",
793
+ "value_label_position",
794
+ }
795
+ )
796
+
767
797
  def __init__(
768
798
  self,
769
799
  out_path: Path,
@@ -867,7 +897,11 @@ class GroupedBarChart:
867
897
 
868
898
  return grouped_bar_to_figure(self)
869
899
 
870
- def plot(self) -> Axes:
900
+ def build(self) -> tuple[Figure, Axes]:
901
+ """Render the chart in memory; :meth:`save` writes it.
902
+
903
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
904
+ """
871
905
  """Render the chart and persist it to ``self.out_path``."""
872
906
  cat_ids = list(self.categories.keys())
873
907
  ser_ids = list(self.series.keys())
@@ -941,8 +975,4 @@ class GroupedBarChart:
941
975
  if self.plot_size is not None and not self.compact:
942
976
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
943
977
 
944
- fig.savefig( # type: ignore[reportUnknownMemberType]
945
- self.out_path, dpi=dynamic_dpi(fig)
946
- )
947
- plt.close(fig)
948
- return ax
978
+ return fig, ax
@@ -32,7 +32,7 @@ The last-value marker is a circle by default and fully configurable
32
32
  from __future__ import annotations
33
33
 
34
34
  from pathlib import Path
35
- from typing import Any, Self
35
+ from typing import Any, ClassVar, Self
36
36
 
37
37
  import matplotlib.pyplot as plt
38
38
  import numpy as np
@@ -129,6 +129,21 @@ class BoxPlot:
129
129
  * ``legend_labels`` -- override the four proxy labels.
130
130
  """
131
131
 
132
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
133
+ #: (see :func:`plotly_unsupported`); checked against real renders
134
+ #: by ``tests/test_plotly_support.py``.
135
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
136
+ {
137
+ "compact",
138
+ "figsize",
139
+ "fmt.decimals",
140
+ "font_pt",
141
+ "legend.ncol",
142
+ "plot_size",
143
+ "x_rotation",
144
+ }
145
+ )
146
+
132
147
  def __init__(
133
148
  self,
134
149
  out_path: Path,
@@ -11,8 +11,9 @@ from __future__ import annotations
11
11
 
12
12
  import datetime
13
13
  import locale
14
+ from collections.abc import Sequence
14
15
  from pathlib import Path
15
- from typing import Any, Self
16
+ from typing import Any, ClassVar, Self
16
17
 
17
18
  import matplotlib.pyplot as plt
18
19
  import pandas as pd
@@ -30,6 +31,7 @@ from tesorotools.artists._common import (
30
31
  adjust_figure_for_plot_size,
31
32
  annotate_last_values,
32
33
  apply_date_axis,
34
+ check_y_limits,
33
35
  compact_date_axis,
34
36
  compact_figure_kwargs,
35
37
  compact_font_pt,
@@ -160,12 +162,43 @@ class LinePlot:
160
162
  **height** (keeping the width) so the plot area is not
161
163
  squashed by a large legend or rotated date labels. Skipped
162
164
  when ``plot_size`` is set (that already fixes the axes size).
165
+ * ``y_limits`` -- fix the y-axis to ``(bottom, top)`` on both
166
+ backends. The range is strict: the baseline, the reference
167
+ lines and the end-of-series labels no longer widen it.
168
+ * ``y_group`` -- name of a group of charts that must share the
169
+ same y scale (e.g. US and European equities, both base 100,
170
+ in two separate charts). It does nothing on its own:
171
+ :func:`tesorotools.artists.share_y_axes` measures every chart
172
+ of each group and sets the union of their ranges as their
173
+ ``y_limits``. Mutually exclusive with ``y_limits``.
163
174
 
164
175
  Render lifecycle: ``build()`` returns ``(Figure, Axes)``
165
176
  in memory; ``save(fig)`` persists to ``out_path``;
166
177
  ``plot()`` is the thin convenience that does both.
167
178
  """
168
179
 
180
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
181
+ #: (see :func:`plotly_unsupported`); checked against real renders
182
+ #: by ``tests/test_plotly_support.py``.
183
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
184
+ {
185
+ "adapt_height",
186
+ "axis_decimals",
187
+ "compact",
188
+ "date_density",
189
+ "date_rotation",
190
+ "date_show_start",
191
+ "figsize",
192
+ "font_pt",
193
+ "legend.ncol",
194
+ "means_window",
195
+ "plot_size",
196
+ "ref_lines",
197
+ "show_means",
198
+ "vlines",
199
+ }
200
+ )
201
+
169
202
  def __init__(
170
203
  self,
171
204
  out_path: Path,
@@ -201,9 +234,16 @@ class LinePlot:
201
234
  ref_lines: list[dict[str, Any]] | None = None,
202
235
  show_means: bool = False,
203
236
  means_window: tuple[Any, Any] | None = None,
237
+ y_limits: Sequence[float] | None = None,
238
+ y_group: str | None = None,
204
239
  ) -> None:
205
240
  if out_path.suffix != ".png":
206
241
  raise ValueError(f"out_path must be .png: {out_path}")
242
+ if y_limits is not None and y_group is not None:
243
+ raise ValueError(
244
+ "y_limits and y_group are mutually exclusive: a group "
245
+ "computes its shared y_limits (see share_y_axes)"
246
+ )
207
247
  if base_100 and base_0:
208
248
  raise ValueError(
209
249
  "base_100 and base_0 are mutually exclusive: a series is "
@@ -247,6 +287,8 @@ class LinePlot:
247
287
  self.ref_lines = ref_lines or []
248
288
  self.show_means = show_means
249
289
  self.means_window = means_window
290
+ self.y_limits = check_y_limits(y_limits)
291
+ self.y_group = y_group
250
292
 
251
293
  @property
252
294
  def _pin_start_date(self) -> bool:
@@ -302,7 +344,8 @@ class LinePlot:
302
344
  ``axis_decimals``, ``axis_units``, ``annotate_units``,
303
345
  ``date_density``, ``date_rotation``, ``date_show_start``,
304
346
  ``adapt_height``,
305
- ``ref_lines``, ``show_means``, ``means_window``.
347
+ ``ref_lines``, ``show_means``, ``means_window``,
348
+ ``y_limits``, ``y_group``.
306
349
 
307
350
  Example
308
351
  -------
@@ -440,6 +483,11 @@ class LinePlot:
440
483
  # data point. annotate_last_values reopens room on the right.
441
484
  ax.margins(x=0)
442
485
 
486
+ # a fixed (or group-shared) y range goes in BEFORE the annotations:
487
+ # they pack their labels at the final scale.
488
+ if self.y_limits is not None:
489
+ ax.set_ylim(*self.y_limits)
490
+
443
491
  if self.vlines:
444
492
  draw_vlines(ax, self.vlines)
445
493
 
@@ -472,6 +520,7 @@ class LinePlot:
472
520
  headroom_pad_pt=(
473
521
  compact_headroom_pad_pt() if self.compact else 0.0
474
522
  ),
523
+ grow_top=self.y_limits is None,
475
524
  )
476
525
 
477
526
  style_spines(
@@ -514,6 +563,10 @@ class LinePlot:
514
563
  )
515
564
  lo, hi = ax.get_ylim()
516
565
  ax.set_ylim(min(ref_line["y"], lo), max(ref_line["y"], hi))
566
+ if self.y_limits is not None:
567
+ # the baseline and the reference lines widen the range to show
568
+ # themselves; a fixed range wins over them.
569
+ ax.set_ylim(*self.y_limits)
517
570
 
518
571
  # per-series historical mean as a dotted same-colour line (the old
519
572
  # `medias=True`): the mean is taken over means_window (or the whole
@@ -27,7 +27,7 @@ Styling constants come from ``PLOT_CONFIG['matrix']``.
27
27
  from __future__ import annotations
28
28
 
29
29
  from pathlib import Path
30
- from typing import Any, Self
30
+ from typing import Any, ClassVar, Self
31
31
 
32
32
  import matplotlib as mpl
33
33
  import matplotlib.pyplot as plt
@@ -40,9 +40,9 @@ from matplotlib.patches import Rectangle
40
40
  from yaml.nodes import MappingNode
41
41
 
42
42
  from tesorotools.artists._common import (
43
+ FigureLifecycle,
43
44
  FIG_CONFIG,
44
45
  Format,
45
- dynamic_dpi,
46
46
  resolve_data,
47
47
  )
48
48
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
@@ -66,7 +66,7 @@ def _text_color(rgba: tuple[float, float, float, float]) -> str:
66
66
  return MATRIX_CONFIG["dark_text"]
67
67
 
68
68
 
69
- class MatrixChart:
69
+ class MatrixChart(FigureLifecycle):
70
70
  """Annotated heatmap-table artist (any ``n_rows x n_cols``).
71
71
 
72
72
  Parameters
@@ -102,6 +102,16 @@ class MatrixChart:
102
102
  cells read as tiles.
103
103
  """
104
104
 
105
+ #: Keys that change the PNG but never the ``to_plotly()`` figure
106
+ #: (see :func:`plotly_unsupported`); checked against real renders
107
+ #: by ``tests/test_plotly_support.py``.
108
+ PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
109
+ {
110
+ "cmap",
111
+ "figsize",
112
+ }
113
+ )
114
+
105
115
  def __init__(
106
116
  self,
107
117
  out_path: Path,
@@ -199,7 +209,11 @@ class MatrixChart:
199
209
 
200
210
  return matrix_to_figure(self)
201
211
 
202
- def plot(self) -> Axes:
212
+ def build(self) -> tuple[Figure, Axes]:
213
+ """Render the chart in memory; :meth:`save` writes it.
214
+
215
+ :meth:`plot` (from :class:`FigureLifecycle`) does both.
216
+ """
203
217
  """Render the chart and persist it to ``self.out_path``."""
204
218
  row_ids = list(self.rows)
205
219
  col_ids = list(self.cols)
@@ -260,11 +274,7 @@ class MatrixChart:
260
274
  )
261
275
 
262
276
  self._style_axes(ax, row_ids, col_ids)
263
- fig.savefig( # type: ignore[reportUnknownMemberType]
264
- self.out_path, dpi=dynamic_dpi(fig)
265
- )
266
- plt.close(fig)
267
- return ax
277
+ return fig, ax
268
278
 
269
279
  def _annotate_cell(
270
280
  self,