tesorotools-python 0.1.6__tar.gz → 0.1.8__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 (165) hide show
  1. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/PKG-INFO +2 -1
  2. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/pyproject.toml +2 -0
  3. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/_common.py +322 -4
  4. tesorotools_python-0.1.8/src/tesorotools/artists/_dates.py +238 -0
  5. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/line_plot.py +54 -16
  6. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/plotly_backend.py +17 -0
  7. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/stacked.py +22 -7
  8. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/plots.yaml +27 -0
  9. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/lint.py +5 -1
  10. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/palette.py +4 -0
  11. tesorotools_python-0.1.8/src/tesorotools/presentation/__init__.py +82 -0
  12. tesorotools_python-0.1.8/src/tesorotools/presentation/builder.py +1175 -0
  13. tesorotools_python-0.1.8/src/tesorotools/presentation/charts.py +61 -0
  14. tesorotools_python-0.1.8/src/tesorotools/presentation/cli.py +283 -0
  15. tesorotools_python-0.1.8/src/tesorotools/presentation/house.py +651 -0
  16. tesorotools_python-0.1.8/src/tesorotools/presentation/lint.py +183 -0
  17. tesorotools_python-0.1.8/src/tesorotools/presentation/markdown.py +950 -0
  18. tesorotools_python-0.1.8/src/tesorotools/presentation/pdf.py +68 -0
  19. tesorotools_python-0.1.8/src/tesorotools/presentation/skeleton.md +81 -0
  20. tesorotools_python-0.1.8/src/tesorotools/presentation/skill/SKILL.md +423 -0
  21. tesorotools_python-0.1.8/src/tesorotools/presentation/skill/reglas.md +159 -0
  22. tesorotools_python-0.1.8/src/tesorotools/presentation/template.py +78 -0
  23. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/providers/lseg.py +42 -14
  24. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/.gitignore +0 -0
  25. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/__init__.py +0 -0
  26. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/_build_context.py +0 -0
  27. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/_registry.py +0 -0
  28. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/__init__.py +0 -0
  29. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/bar_line.py +0 -0
  30. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/barh_plot.py +0 -0
  31. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/box_plot.py +0 -0
  32. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/compact.py +0 -0
  33. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/intraday_plot.py +0 -0
  34. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/matrix.py +0 -0
  35. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/sankey.py +0 -0
  36. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/scatter.py +0 -0
  37. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/sharing.py +0 -0
  38. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/shock_plot.py +0 -0
  39. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/tikz_backend.py +0 -0
  40. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/type_curve.py +0 -0
  41. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/vector_plot.py +0 -0
  42. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/artists/waterfall.py +0 -0
  43. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/README.md +0 -0
  44. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  45. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  46. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  47. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  48. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  49. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  50. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  51. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  52. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/fonts/README.md +0 -0
  53. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/nota_template.dotx +0 -0
  54. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/template.docx +0 -0
  55. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  56. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/data_sources/__init__.py +0 -0
  57. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/data_sources/debug.py +0 -0
  58. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/database/__init__.py +0 -0
  59. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/database/local.py +0 -0
  60. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/database/push.py +0 -0
  61. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/database/shared.py +0 -0
  62. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/dependencies/__init__.py +0 -0
  63. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/dependencies/node.py +0 -0
  64. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/dependencies/resolution.py +0 -0
  65. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/driver.py +0 -0
  66. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/__init__.py +0 -0
  67. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/cli.py +0 -0
  68. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/scaffold.py +0 -0
  69. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/article/main.tex.tmpl +0 -0
  70. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/article/sections/01-introduccion.tex +0 -0
  71. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/article/sections/02-analisis.tex +0 -0
  72. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/article/sections/03-conclusiones.tex +0 -0
  73. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/beamer/main.tex.tmpl +0 -0
  74. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/beamer/slides/01-contexto.tex +0 -0
  75. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/beamer/slides/02-analisis.tex +0 -0
  76. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/beamer/slides/03-conclusiones.tex +0 -0
  77. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/beamer/style/beamerthemeTesoro.sty +0 -0
  78. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/book/appendices/a-fuentes.tex +0 -0
  79. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/book/chapters/01-introduccion.tex +0 -0
  80. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/book/chapters/02-analisis.tex +0 -0
  81. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/book/frontmatter/resumen.tex +0 -0
  82. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/book/main.tex.tmpl +0 -0
  83. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/README.md.tmpl +0 -0
  84. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/dot-gitignore +0 -0
  85. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/dot-vscode/settings.json +0 -0
  86. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/figures/dot-gitkeep +0 -0
  87. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/fonts/README.md +0 -0
  88. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/latexmkrc +0 -0
  89. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/refs.bib +0 -0
  90. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/style/tesoro.sty +0 -0
  91. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/style/tesorobrand.sty.tmpl +0 -0
  92. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/latex/templates/common/style/tesorolocal.sty.tmpl +0 -0
  93. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/manifest.py +0 -0
  94. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/__init__.py +0 -0
  95. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/builder.py +0 -0
  96. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/charts.py +0 -0
  97. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/cli.py +0 -0
  98. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/inline.py +0 -0
  99. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/issues.py +0 -0
  100. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/markdown.py +0 -0
  101. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/pdf.py +0 -0
  102. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/skeleton.md +0 -0
  103. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/skill/SKILL.md +0 -0
  104. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/skill/reglas.md +0 -0
  105. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/note/template.py +0 -0
  106. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/offsets/__init__.py +0 -0
  107. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/offsets/offsets.py +0 -0
  108. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/offsets/outliers.py +0 -0
  109. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/orchestration.py +0 -0
  110. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/pipeline/__init__.py +0 -0
  111. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/pipeline/diagnose.py +0 -0
  112. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/pipeline/engine.py +0 -0
  113. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/pipeline/rules.py +0 -0
  114. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/providers/__init__.py +0 -0
  115. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/providers/base.py +0 -0
  116. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/providers/bde.py +0 -0
  117. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/providers/ecb.py +0 -0
  118. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/providers/fred.py +0 -0
  119. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/providers/imf_irfcl.py +0 -0
  120. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/py.typed +0 -0
  121. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/__init__.py +0 -0
  122. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/__init__.py +0 -0
  123. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/content.py +0 -0
  124. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/images.py +0 -0
  125. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/section.py +0 -0
  126. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/subtitle.py +0 -0
  127. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/table.py +0 -0
  128. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/text.py +0 -0
  129. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/content/title.py +0 -0
  130. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/document.py +0 -0
  131. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/render/report.py +0 -0
  132. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/scaffold.py +0 -0
  133. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/testing/__init__.py +0 -0
  134. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/testing/compare.py +0 -0
  135. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/__init__.py +0 -0
  136. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/config.py +0 -0
  137. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/format.py +0 -0
  138. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/globals.py +0 -0
  139. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/matplotlib.py +0 -0
  140. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/series.py +0 -0
  141. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/shortcuts.py +0 -0
  142. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/utils/template.py +0 -0
  143. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/__init__.py +0 -0
  144. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/app.py +0 -0
  145. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/brand.py +0 -0
  146. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/cli.py +0 -0
  147. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/components.py +0 -0
  148. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/formatting.py +0 -0
  149. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/launcher.py +0 -0
  150. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/palette.py +0 -0
  151. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/plotting.py +0 -0
  152. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/scaffold.py +0 -0
  153. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/Dockerfile.tmpl +0 -0
  154. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/README.md.tmpl +0 -0
  155. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/dot-dockerignore.tmpl +0 -0
  156. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/dot-gitignore.tmpl +0 -0
  157. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/dot-streamlit/config.toml.tmpl +0 -0
  158. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/pyproject.toml.tmpl +0 -0
  159. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/src/$package/__init__.py.tmpl +0 -0
  160. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/src/$package/cli.py.tmpl +0 -0
  161. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/src/$package/main.py.tmpl +0 -0
  162. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/src/$package/sections.py.tmpl +0 -0
  163. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/tests/__init__.py.tmpl +0 -0
  164. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/src/tesorotools/web/templates/tests/test_app.py.tmpl +0 -0
  165. {tesorotools_python-0.1.6 → tesorotools_python-0.1.8}/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.6
3
+ Version: 0.1.8
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -9,6 +9,7 @@ Requires-Dist: pandas>=2.2
9
9
  Requires-Dist: psycopg[binary]>=3.1
10
10
  Requires-Dist: pyarrow>=18.0
11
11
  Requires-Dist: python-docx>=1.1
12
+ Requires-Dist: python-pptx>=1.0
12
13
  Requires-Dist: pywin32>=311; sys_platform == 'win32'
13
14
  Requires-Dist: pyyaml>=6.0
14
15
  Requires-Dist: sqlalchemy>=2.0
@@ -19,6 +19,7 @@ dependencies = [
19
19
  # data visualization
20
20
  "matplotlib>=3.10",
21
21
  "python-docx>=1.1",
22
+ "python-pptx>=1.0",
22
23
 
23
24
  # os dependencies
24
25
  "pywin32>=311; sys_platform == 'win32'",
@@ -39,6 +40,7 @@ web = ["streamlit>=1.62", "plotly>=5.24"]
39
40
  tesorotools-web = "tesorotools.web.cli:main"
40
41
  tesorotools-latex = "tesorotools.latex.cli:main"
41
42
  tesorotools-nota = "tesorotools.note.cli:main"
43
+ tesorotools-presentacion = "tesorotools.presentation.cli:main"
42
44
 
43
45
  [dependency-groups]
44
46
  dev = [
@@ -47,9 +47,21 @@ from matplotlib.dates import (
47
47
  from matplotlib.figure import Figure
48
48
  from matplotlib.font_manager import FontProperties
49
49
  from matplotlib.legend import Legend as MplLegend
50
- from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator
50
+ from matplotlib.text import Text
51
+ from matplotlib.ticker import (
52
+ FixedFormatter,
53
+ FixedLocator,
54
+ FuncFormatter,
55
+ MaxNLocator,
56
+ NullFormatter,
57
+ )
51
58
  from yaml.nodes import MappingNode
52
59
 
60
+ from tesorotools.artists._dates import (
61
+ DateLayout,
62
+ date_layouts,
63
+ period_label,
64
+ )
53
65
  from tesorotools.utils.matplotlib import (
54
66
  PLOT_CONFIG,
55
67
  apply_house_style,
@@ -110,6 +122,53 @@ def date_maxticks(density: str, *, compact: bool) -> int:
110
122
  return int(table.get(density, fallback[density]))
111
123
 
112
124
 
125
+ # Most date labels an axis shows per date_density level, read from
126
+ # ``compact.date_max_labels`` / ``line.date_max_labels`` in ``plots.yaml``:
127
+ # the finest calendar step (every year, every month...) whose labels fit in
128
+ # the axis width AND stay within this count wins.
129
+ _DATE_MAX_LABELS_FALLBACK_COMPACT: dict[str, int] = {
130
+ "few": 4,
131
+ "default": 8,
132
+ "many": 14,
133
+ }
134
+ _DATE_MAX_LABELS_FALLBACK_LINE: dict[str, int] = {
135
+ "few": 6,
136
+ "default": 15,
137
+ "many": 30,
138
+ }
139
+
140
+
141
+ def date_max_labels(density: str, *, compact: bool) -> int:
142
+ """Most date labels a ``date_density`` level allows on the x-axis.
143
+
144
+ Reads ``compact.date_max_labels`` / ``line.date_max_labels`` from
145
+ ``plots.yaml``, falling back to the built-in table when the config
146
+ (or a level) is absent.
147
+ """
148
+ cfg = COMPACT_CONFIG if compact else LINE_CONFIG
149
+ fallback = (
150
+ _DATE_MAX_LABELS_FALLBACK_COMPACT
151
+ if compact
152
+ else _DATE_MAX_LABELS_FALLBACK_LINE
153
+ )
154
+ table: dict[str, Any] = cfg.get("date_max_labels", fallback)
155
+ return int(table.get(density, fallback[density]))
156
+
157
+
158
+ def _date_axis_config(*, compact: bool) -> tuple[int, float, float]:
159
+ """Calendar date-axis knobs of a mode, from ``plots.yaml``.
160
+
161
+ ``(date_monthly_max_months, date_centered_min_fraction,
162
+ date_label_gap_em)``.
163
+ """
164
+ cfg = COMPACT_CONFIG if compact else LINE_CONFIG
165
+ return (
166
+ int(cfg.get("date_monthly_max_months", 24)),
167
+ float(cfg.get("date_centered_min_fraction", 0.5)),
168
+ float(cfg.get("date_label_gap_em", 0.3)),
169
+ )
170
+
171
+
113
172
  def dynamic_dpi(fig: Figure) -> int:
114
173
  """Save DPI adapted to the figure's physical size.
115
174
 
@@ -804,6 +863,188 @@ def _date_label_ha(
804
863
  return "right" if rotation > 0 else "left"
805
864
 
806
865
 
866
+ def _style_date_labels(ax: Axes, rot: float, *, minor: bool) -> None:
867
+ """Tilt the date labels *rot* degrees with the matching anchor.
868
+
869
+ Rotation goes in the axis tick kwargs; ``ha`` and the anchor rotation
870
+ mode are per-label Text properties, so they are reapplied after every
871
+ draw or locator swap.
872
+ """
873
+ ha = _date_label_ha(None if rot == 0 else rot)
874
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
875
+ axis="x", which="minor" if minor else "major", rotation=rot
876
+ )
877
+ for label in ax.get_xticklabels(minor=minor):
878
+ label.set_horizontalalignment(ha)
879
+ if rot != 0:
880
+ label.set_rotation_mode("anchor")
881
+
882
+
883
+ def _layout_texts(layout: DateLayout, *, pin_start: bool) -> list[str]:
884
+ """The label texts of *layout*, as the axis will print them."""
885
+ if layout.centered:
886
+ # centred: each label names its period
887
+ return [
888
+ period_label(p, layout.step, first=i == 0)
889
+ for i, p in enumerate(layout.periods)
890
+ ]
891
+ values = [float(cast(Any, date2num(t))) for t in layout.ticks]
892
+ formatter = _tick_formatter(FixedLocator(values), values[0], pin_start)
893
+ return list(formatter.format_ticks(values))
894
+
895
+
896
+ def _tick_formatter(
897
+ locator: FixedLocator, start: float, pin_start: bool
898
+ ) -> ConciseDateFormatter:
899
+ return (
900
+ _StartLabeledConcise(locator, start)
901
+ if pin_start
902
+ else ConciseDateFormatter(locator)
903
+ )
904
+
905
+
906
+ def calendar_date_axis(
907
+ ax: Axes,
908
+ layouts: list[DateLayout],
909
+ *,
910
+ pin_start: bool,
911
+ rotation: float | None = None,
912
+ auto_rotate: bool = False,
913
+ gap_em: float = 0.3,
914
+ ) -> DateLayout:
915
+ """Label every year (or month) the axis can hold.
916
+
917
+ *layouts* are the candidate calendar steps, finest first (see
918
+ :func:`tesorotools.artists._dates.date_layouts`). Each one's labels
919
+ are measured at their real size and position, and the first whose
920
+ labels do not overlap wins: a decade shows every year, thirty years
921
+ fall back to one every two or five. When none fits, the coarsest is
922
+ used. Returns the layout drawn.
923
+
924
+ With the start pinned, the first calendar tick gives way to the
925
+ (wider) start label when they collide and it lies under half a step
926
+ from the start: a series from October 2015 shows ``oct. 2015``,
927
+ 2017, 2018... Further away, dropping it would read as a skipped
928
+ year, so that step counts as not fitting.
929
+
930
+ Centred layouts (``date_labels: centered``) put the tick marks on the
931
+ period boundaries and the labels, as minor ticks without a mark, in
932
+ the middle of each period.
933
+
934
+ ``rotation`` tilts the labels. ``auto_rotate`` (ignored when a
935
+ rotation is given) tries 0, 30, 45, 60 and 90 degrees and keeps the
936
+ flattest tilt that reaches the finest step any of them reaches.
937
+
938
+ Neighbouring labels must stay *gap_em* ems apart
939
+ (``date_label_gap_em`` in ``plots.yaml``).
940
+ """
941
+ fig = ax.get_figure()
942
+ assert fig is not None
943
+ # settle the layout so dates map to their final pixels
944
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
945
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
946
+ font = ax.xaxis.get_major_ticks()[0].label1.get_fontproperties()
947
+ # a fixed share of an em between neighbouring labels, whatever the dpi
948
+ pad_px = (
949
+ float(
950
+ renderer.points_to_pixels(font.get_size_in_points()) # type: ignore[reportUnknownMemberType]
951
+ )
952
+ * gap_em
953
+ )
954
+
955
+ def _boxes(layout: DateLayout, rot: float) -> list[Any]:
956
+ texts = _layout_texts(layout, pin_start=pin_start)
957
+ ha = _date_label_ha(None if rot == 0 else rot)
958
+ boxes: list[Any] = []
959
+ for when, text in zip(layout.labels, texts):
960
+ x_px = float(
961
+ ax.transData.transform((float(cast(Any, date2num(when))), 0))[0]
962
+ )
963
+ probe = Text(
964
+ x_px,
965
+ 0.0,
966
+ text,
967
+ fontproperties=font,
968
+ rotation=rot,
969
+ horizontalalignment=ha,
970
+ rotation_mode="anchor" if rot != 0 else "default",
971
+ )
972
+ probe.set_figure(fig)
973
+ boxes.append(probe.get_window_extent(renderer)) # type: ignore[reportUnknownArgumentType]
974
+ return boxes
975
+
976
+ def _fit(layout: DateLayout, rot: float) -> list[int] | None:
977
+ boxes = _boxes(layout, rot)
978
+ kept = list(range(len(boxes)))
979
+ centered = layout.centered
980
+ if pin_start and not centered and len(kept) > 2:
981
+ # the first calendar tick may give way to the (wider) start
982
+ # label only when it sits under half a step from the start:
983
+ # further away, dropping it would open a gap that reads as a
984
+ # skipped year, so the step does not fit instead
985
+ when = [float(cast(Any, date2num(t))) for t in layout.ticks]
986
+ near = when[1] - when[0] < (when[2] - when[1]) / 2
987
+ if near and boxes[1].x0 <= boxes[0].x1 + pad_px:
988
+ kept.pop(1)
989
+ clear = all(
990
+ boxes[b].x0 > boxes[a].x1 + pad_px for a, b in zip(kept, kept[1:])
991
+ )
992
+ return kept if clear else None
993
+
994
+ tilts = (
995
+ (0.0, 30.0, 45.0, 60.0, 90.0)
996
+ if auto_rotate and rotation is None
997
+ else (0.0 if rotation is None else float(rotation),)
998
+ )
999
+ best: tuple[int, float, list[int]] | None = None
1000
+ for rot in tilts:
1001
+ for i, layout in enumerate(layouts):
1002
+ if best is not None and i >= best[0]:
1003
+ break
1004
+ kept = _fit(layout, rot)
1005
+ if kept is not None:
1006
+ best = (i, rot, kept)
1007
+ break
1008
+ if best is not None and best[0] == 0:
1009
+ break
1010
+ if best is None:
1011
+ coarsest = layouts[-1]
1012
+ best = (len(layouts) - 1, tilts[0], list(range(len(coarsest.labels))))
1013
+ index, rot, kept = best
1014
+ layout = layouts[index]
1015
+ centered = layout.centered
1016
+
1017
+ if centered:
1018
+ major = FixedLocator(
1019
+ [float(cast(Any, date2num(t))) for t in layout.ticks]
1020
+ )
1021
+ ax.xaxis.set_major_locator(major)
1022
+ ax.xaxis.set_major_formatter(NullFormatter())
1023
+ texts = _layout_texts(layout, pin_start=pin_start)
1024
+ ax.xaxis.set_minor_locator(
1025
+ FixedLocator(
1026
+ [float(cast(Any, date2num(layout.labels[k]))) for k in kept]
1027
+ )
1028
+ )
1029
+ ax.xaxis.set_minor_formatter(FixedFormatter([texts[k] for k in kept]))
1030
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
1031
+ axis="x",
1032
+ which="minor",
1033
+ length=0,
1034
+ labelsize=ax.xaxis.get_major_ticks()[0].label1.get_fontsize(),
1035
+ )
1036
+ else:
1037
+ values = [float(cast(Any, date2num(layout.ticks[k]))) for k in kept]
1038
+ fixed = FixedLocator(values)
1039
+ ax.xaxis.set_major_locator(fixed)
1040
+ ax.xaxis.set_major_formatter(
1041
+ _tick_formatter(fixed, values[0], pin_start)
1042
+ )
1043
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
1044
+ _style_date_labels(ax, rot, minor=centered)
1045
+ return layout
1046
+
1047
+
807
1048
  def compact_date_axis(
808
1049
  ax: Axes,
809
1050
  data_min: Any,
@@ -813,8 +1054,22 @@ def compact_date_axis(
813
1054
  rotation: float | None = None,
814
1055
  pin_start: bool | None = None,
815
1056
  auto_rotate: bool = False,
1057
+ max_labels: int | None = None,
1058
+ step: str = "auto",
1059
+ labels: str = "tick",
816
1060
  ) -> None:
817
- """Compact date x-axis: few ticks, anchored to the data span.
1061
+ """Compact date x-axis: as many calendar dates as fit the narrow figure.
1062
+
1063
+ The axis labels every year (or, over two years at most, every month)
1064
+ that fits in the width without overlapping and within *max_labels*
1065
+ (``None`` reads ``compact.date_max_labels`` for the default density),
1066
+ stepping to one every 2, 5, 10... years only when they do not fit;
1067
+ ``step`` (``"1Y"``, ``"3M"``...) fixes the step instead, and
1068
+ ``labels="centered"`` centres each year over its stretch. See
1069
+ :func:`calendar_date_axis`.
1070
+
1071
+ Spans too short for a calendar grid (a few weeks of daily data) keep
1072
+ the historical behaviour described below, with *maxticks* ticks.
818
1073
 
819
1074
  Ticks are computed over the **data span** ``[data_min,
820
1075
  data_max]`` and pinned with a ``FixedLocator``, so none
@@ -853,6 +1108,31 @@ def compact_date_axis(
853
1108
  if pin_start is None
854
1109
  else pin_start
855
1110
  )
1111
+ monthly_max, min_fraction, gap_em = _date_axis_config(compact=True)
1112
+ layouts = date_layouts(
1113
+ data_min,
1114
+ data_max,
1115
+ step=step,
1116
+ max_labels=(
1117
+ date_max_labels("default", compact=True)
1118
+ if max_labels is None
1119
+ else max_labels
1120
+ ),
1121
+ monthly_max_months=monthly_max,
1122
+ pin_start=show_start,
1123
+ centered=labels == "centered",
1124
+ min_fraction=min_fraction,
1125
+ )
1126
+ if layouts:
1127
+ calendar_date_axis(
1128
+ ax,
1129
+ layouts,
1130
+ pin_start=show_start,
1131
+ rotation=rotation,
1132
+ auto_rotate=auto_rotate,
1133
+ gap_em=gap_em,
1134
+ )
1135
+ return
856
1136
  # same span-clipped ticks (and same pinned start) the non-compact axis
857
1137
  # uses, so both modes answer "when does this start?" identically
858
1138
  ticks: list[float] = date_span_ticks(
@@ -958,8 +1238,20 @@ def apply_date_axis(
958
1238
  data_min: Any = None,
959
1239
  data_max: Any = None,
960
1240
  pin_start: bool = False,
1241
+ max_labels: int | None = None,
1242
+ step: str = "auto",
1243
+ labels: str = "tick",
961
1244
  ) -> None:
962
- """Native matplotlib date x-axis with a soft tick count and optional slant.
1245
+ """Native matplotlib date x-axis with every year (or month) that fits.
1246
+
1247
+ Given the ``data_min`` / ``data_max`` span, the axis labels every year
1248
+ (or, over two years at most, every month) that fits in the width and
1249
+ within *max_labels* (``None`` reads ``line.date_max_labels`` for the
1250
+ default density), stepping to one every 2, 5, 10... years only when
1251
+ they do not; ``step`` fixes the step and ``labels="centered"`` centres
1252
+ each year over its stretch (see :func:`calendar_date_axis`). The rest
1253
+ of this docstring describes the fallback for spans too short for a
1254
+ calendar grid, or when no span is given.
963
1255
 
964
1256
  The non-compact counterpart to :func:`compact_date_axis`: it takes over the
965
1257
  x-axis (which must carry a **native** date axis, i.e. the series were
@@ -984,6 +1276,31 @@ def apply_date_axis(
984
1276
  ``rotation=None`` has to *actively* flatten them to keep the documented
985
1277
  "horizontal unless asked" behaviour.
986
1278
  """
1279
+ if data_min is not None and data_max is not None:
1280
+ monthly_max, min_fraction, gap_em = _date_axis_config(compact=False)
1281
+ layouts = date_layouts(
1282
+ data_min,
1283
+ data_max,
1284
+ step=step,
1285
+ max_labels=(
1286
+ date_max_labels("default", compact=False)
1287
+ if max_labels is None
1288
+ else max_labels
1289
+ ),
1290
+ monthly_max_months=monthly_max,
1291
+ pin_start=pin_start,
1292
+ centered=labels == "centered",
1293
+ min_fraction=min_fraction,
1294
+ )
1295
+ if layouts:
1296
+ calendar_date_axis(
1297
+ ax,
1298
+ layouts,
1299
+ pin_start=pin_start,
1300
+ rotation=rotation,
1301
+ gap_em=gap_em,
1302
+ )
1303
+ return
987
1304
  if pin_start and data_min is not None and data_max is not None:
988
1305
  ticks = date_span_ticks(
989
1306
  data_min, data_max, maxticks=maxticks, pin_start=True
@@ -1226,8 +1543,9 @@ def style_compact_axes(
1226
1543
  :func:`declutter_spines`.
1227
1544
  """
1228
1545
  if font_pt is not None:
1546
+ # which="both": centred date labels live on the minor ticks
1229
1547
  ax.tick_params( # type: ignore[reportUnknownMemberType]
1230
- axis="both", labelsize=font_pt
1548
+ axis="both", which="both", labelsize=font_pt
1231
1549
  )
1232
1550
  if side == "right":
1233
1551
  ax.yaxis.tick_right()
@@ -0,0 +1,238 @@
1
+ """Calendar grids for date x-axes: every year (or month) the axis can hold.
2
+
3
+ Pure date maths shared by the matplotlib date axes in ``_common`` and by
4
+ the Plotly backend: which calendar steps are worth trying over a span,
5
+ where their ticks fall and, for centred labels, where each period label
6
+ sits. Nothing here draws or measures text; picking the finest step whose
7
+ labels actually fit is the caller's job.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import pandas as pd
16
+
17
+ #: Calendar steps, finest first. ``M`` counts months, ``Y`` years; the
18
+ #: month steps divide a year, so January is always on the grid.
19
+ DATE_STEPS: tuple[str, ...] = (
20
+ "1M",
21
+ "2M",
22
+ "3M",
23
+ "6M",
24
+ "1Y",
25
+ "2Y",
26
+ "5Y",
27
+ "10Y",
28
+ "20Y",
29
+ "50Y",
30
+ )
31
+
32
+ #: How a date label relates to its tick: under it, or centred over the
33
+ #: period (year or month) that starts at the tick.
34
+ DATE_LABEL_MODES: tuple[str, ...] = ("tick", "centered")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DateLayout:
39
+ """Where one calendar step puts the tick marks and the labels.
40
+
41
+ ``ticks`` are the tick marks. ``labels`` are the label positions;
42
+ with ``tick`` labels they are the ticks themselves, with ``centered``
43
+ labels they sit mid-period. ``periods`` holds, per label, the date
44
+ that names it (the tick itself, or the start of its period).
45
+ """
46
+
47
+ step: str
48
+ ticks: list[pd.Timestamp]
49
+ labels: list[pd.Timestamp]
50
+ periods: list[pd.Timestamp]
51
+ centered: bool = False
52
+
53
+
54
+ def check_date_step(step: str) -> str:
55
+ """Return *step* when it is ``auto`` or one of :data:`DATE_STEPS`."""
56
+ if step != "auto" and step not in DATE_STEPS:
57
+ raise ValueError(
58
+ f"date_step must be 'auto' or one of {DATE_STEPS}: {step!r}"
59
+ )
60
+ return step
61
+
62
+
63
+ def check_date_labels(mode: str) -> str:
64
+ """Return *mode* when it is one of :data:`DATE_LABEL_MODES`."""
65
+ if mode not in DATE_LABEL_MODES:
66
+ raise ValueError(
67
+ f"date_labels must be one of {DATE_LABEL_MODES}: {mode!r}"
68
+ )
69
+ return mode
70
+
71
+
72
+ def _parse(step: str) -> tuple[str, int]:
73
+ return step[-1], int(step[:-1])
74
+
75
+
76
+ def _month_index(ts: pd.Timestamp) -> int:
77
+ return ts.year * 12 + ts.month - 1
78
+
79
+
80
+ def _month_start(index: int) -> pd.Timestamp:
81
+ return pd.Timestamp(year=index // 12, month=index % 12 + 1, day=1)
82
+
83
+
84
+ def _period_starts(
85
+ lo: pd.Timestamp, hi: pd.Timestamp, step: str, *, anchor_year: int
86
+ ) -> list[pd.Timestamp]:
87
+ """Calendar boundaries of *step* from the one at or before *lo* to *hi*.
88
+
89
+ Year steps run from ``anchor_year`` (so a two-year step over a series
90
+ starting in 2015 gives 2015, 2017, 2019 and not the calendar 2016,
91
+ 2018); month steps stay on the calendar grid (quarters start in
92
+ January, April, July and October), which keeps January, and so the
93
+ year, on the axis.
94
+ """
95
+ unit, n = _parse(step)
96
+ if unit == "Y":
97
+ return [
98
+ pd.Timestamp(year=y, month=1, day=1)
99
+ for y in range(anchor_year, hi.year + 1, n)
100
+ ]
101
+ first = _month_index(lo) - _month_index(lo) % n
102
+ return [_month_start(i) for i in range(first, _month_index(hi) + 1, n)]
103
+
104
+
105
+ def _tick_layout(
106
+ lo: pd.Timestamp, hi: pd.Timestamp, step: str, *, pin_start: bool
107
+ ) -> DateLayout:
108
+ # pinned, the start label stands for its own year, so the grid is
109
+ # anchored there; unpinned, at the first year boundary inside the span
110
+ on_new_year = lo == pd.Timestamp(year=lo.year, month=1, day=1)
111
+ anchor = lo.year if pin_start or on_new_year else lo.year + 1
112
+ grid = [
113
+ t
114
+ for t in _period_starts(lo, hi, step, anchor_year=anchor)
115
+ if lo < t <= hi or (not pin_start and t == lo)
116
+ ]
117
+ ticks = [lo, *grid] if pin_start else grid
118
+ return DateLayout(step, ticks, ticks, ticks)
119
+
120
+
121
+ def _centered_layout(
122
+ lo: pd.Timestamp, hi: pd.Timestamp, step: str, *, min_fraction: float
123
+ ) -> DateLayout:
124
+ unit, _n = _parse(step)
125
+ length = pd.DateOffset(years=1) if unit == "Y" else pd.DateOffset(months=1)
126
+ starts = _period_starts(lo, hi, step, anchor_year=lo.year)
127
+ labels: list[pd.Timestamp] = []
128
+ periods: list[pd.Timestamp] = []
129
+ for start in starts:
130
+ end = start + length
131
+ shown_lo = max(start, lo)
132
+ shown_hi = min(end, hi)
133
+ # a sliver of a year at either edge would push its label off the
134
+ # plot: label a period only when enough of it is drawn
135
+ if shown_hi - shown_lo >= (end - start) * min_fraction:
136
+ labels.append(shown_lo + (shown_hi - shown_lo) / 2)
137
+ periods.append(start)
138
+ ticks = [t for t in starts if lo <= t <= hi]
139
+ return DateLayout(step, ticks, labels, periods, centered=True)
140
+
141
+
142
+ def date_layout(
143
+ data_min: Any,
144
+ data_max: Any,
145
+ step: str,
146
+ *,
147
+ pin_start: bool,
148
+ centered: bool = False,
149
+ min_fraction: float = 0.5,
150
+ ) -> DateLayout:
151
+ """Ticks and labels that *step* puts over ``[data_min, data_max]``.
152
+
153
+ With ``tick`` labels (the default) every tick is labelled and, with
154
+ ``pin_start``, the first datum leads as its own tick. With
155
+ ``centered`` labels the ticks mark where each period starts and its
156
+ name sits in the middle of the part drawn; ``pin_start`` is ignored
157
+ there, since the first label already names the starting period.
158
+ """
159
+ lo = pd.Timestamp(data_min)
160
+ hi = pd.Timestamp(data_max)
161
+ if centered:
162
+ return _centered_layout(lo, hi, step, min_fraction=min_fraction)
163
+ return _tick_layout(lo, hi, step, pin_start=pin_start)
164
+
165
+
166
+ def date_layouts(
167
+ data_min: Any,
168
+ data_max: Any,
169
+ *,
170
+ step: str,
171
+ max_labels: int,
172
+ monthly_max_months: int,
173
+ pin_start: bool,
174
+ centered: bool = False,
175
+ min_fraction: float = 0.5,
176
+ ) -> list[DateLayout]:
177
+ """Candidate layouts for the span, finest first.
178
+
179
+ An explicit *step* is the only candidate. With ``auto``, month steps
180
+ are tried only over spans up to *monthly_max_months* (beyond that the
181
+ axis reads in years), and a step is kept when it gives between two
182
+ and *max_labels* labels. An empty list means no calendar step suits
183
+ the span (a few weeks of daily data, say) and the caller falls back to
184
+ matplotlib's own date locator.
185
+ """
186
+ lo = pd.Timestamp(data_min)
187
+ hi = pd.Timestamp(data_max)
188
+ if step != "auto":
189
+ pool: tuple[str, ...] = (step,)
190
+ limit = None
191
+ else:
192
+ months = _month_index(hi) - _month_index(lo)
193
+ pool = tuple(
194
+ s
195
+ for s in DATE_STEPS
196
+ if months <= monthly_max_months or s.endswith("Y")
197
+ )
198
+ limit = max_labels
199
+ layouts: list[DateLayout] = []
200
+ for candidate in pool:
201
+ layout = date_layout(
202
+ lo,
203
+ hi,
204
+ candidate,
205
+ pin_start=pin_start,
206
+ centered=centered,
207
+ min_fraction=min_fraction,
208
+ )
209
+ count = len(layout.labels)
210
+ if count >= 2 and (limit is None or count <= limit):
211
+ layouts.append(layout)
212
+ return layouts
213
+
214
+
215
+ def period_label(
216
+ period: pd.Timestamp, step: str, *, first: bool = False
217
+ ) -> str:
218
+ """Text of a centred label: the year, or the month (the year in January).
219
+
220
+ The *first* month label carries its year too (``may. 2024``) unless
221
+ it is January, so the reader knows which year the axis starts in.
222
+ """
223
+ unit, _n = _parse(step)
224
+ if unit == "Y" or period.month == 1:
225
+ return str(period.year)
226
+ if first:
227
+ # locale's %b may already end in a dot ("may."): avoid "may.. 2024"
228
+ month = period.strftime("%b").lower().rstrip(".")
229
+ return f"{month}. {period.year}"
230
+ return period.strftime("%b")
231
+
232
+
233
+ def plotly_dtick(data_min: Any, step: str) -> tuple[str, str]:
234
+ """Plotly ``(dtick, tick0)`` for *step*, anchored as the PNG axis is."""
235
+ unit, n = _parse(step)
236
+ if unit == "Y":
237
+ return f"M{12 * n}", f"{pd.Timestamp(data_min).year}-01-01"
238
+ return f"M{n}", "2000-01-01"