tesorotools-python 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tesorotools/artists/_common.py +322 -4
- tesorotools/artists/_dates.py +238 -0
- tesorotools/artists/line_plot.py +54 -16
- tesorotools/artists/plotly_backend.py +17 -0
- tesorotools/artists/stacked.py +22 -7
- tesorotools/assets/plots.yaml +27 -0
- tesorotools/note/lint.py +5 -1
- tesorotools/providers/lseg.py +42 -14
- {tesorotools_python-0.1.7.dist-info → tesorotools_python-0.1.8.dist-info}/METADATA +1 -1
- {tesorotools_python-0.1.7.dist-info → tesorotools_python-0.1.8.dist-info}/RECORD +12 -11
- {tesorotools_python-0.1.7.dist-info → tesorotools_python-0.1.8.dist-info}/WHEEL +0 -0
- {tesorotools_python-0.1.7.dist-info → tesorotools_python-0.1.8.dist-info}/entry_points.txt +0 -0
tesorotools/artists/_common.py
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
|
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"
|
tesorotools/artists/line_plot.py
CHANGED
|
@@ -39,6 +39,7 @@ from tesorotools.artists._common import (
|
|
|
39
39
|
compact_font_pt,
|
|
40
40
|
compact_headroom_pad_pt,
|
|
41
41
|
compact_y_axis,
|
|
42
|
+
date_max_labels,
|
|
42
43
|
date_maxticks,
|
|
43
44
|
date_show_start_default,
|
|
44
45
|
draw_vlines,
|
|
@@ -50,6 +51,7 @@ from tesorotools.artists._common import (
|
|
|
50
51
|
style_compact_axes,
|
|
51
52
|
style_spines,
|
|
52
53
|
)
|
|
54
|
+
from tesorotools.artists._dates import check_date_labels, check_date_step
|
|
53
55
|
from tesorotools.utils.matplotlib import format_annotation
|
|
54
56
|
from tesorotools.utils.template import TemplateLoader
|
|
55
57
|
|
|
@@ -128,12 +130,19 @@ class LinePlot(FigureLifecycle):
|
|
|
128
130
|
historical behaviour). Set ``axis_units: false`` to keep the
|
|
129
131
|
unit only on the value labels (a common house look: ``mM€``
|
|
130
132
|
on the annotations, bare numbers on the axis), or the reverse.
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
``
|
|
136
|
-
|
|
133
|
+
* Date axis -- the x-axis labels **every year** that fits in
|
|
134
|
+
its width without overlapping (every month over spans of up to
|
|
135
|
+
``date_monthly_max_months``, 24 by default), and only steps to
|
|
136
|
+
one every 2, 5, 10... years when they do not fit or would pass
|
|
137
|
+
the ``date_max_labels`` cap of the density level (15 at full
|
|
138
|
+
size, 8 in compact by default; both in ``plots.yaml``).
|
|
139
|
+
Spans too short for a calendar grid (a few weeks of daily data)
|
|
140
|
+
keep matplotlib's own date ticks, ``date_maxticks`` of them.
|
|
141
|
+
* ``date_density`` -- how many date labels the x-axis may show:
|
|
142
|
+
``"few"`` / ``"default"`` / ``"many"``, mapped to the cap
|
|
143
|
+
``date_max_labels`` per mode in ``plots.yaml``
|
|
144
|
+
(``compact.date_max_labels`` / ``line.date_max_labels``). In
|
|
145
|
+
compact mode a
|
|
137
146
|
denser level with no explicit ``date_rotation`` **auto-tilts**
|
|
138
147
|
the labels only as far as needed to actually fit the extra
|
|
139
148
|
dates (a narrow chart cannot fit many horizontal 4-digit years),
|
|
@@ -160,6 +169,19 @@ class LinePlot(FigureLifecycle):
|
|
|
160
169
|
blank), so in non-compact mode this switches the x-axis to
|
|
161
170
|
the concise date labels -- a visible change of label *style*
|
|
162
171
|
against the historical pandas one.
|
|
172
|
+
* ``date_step`` -- fix the date step instead of fitting it:
|
|
173
|
+
``"1M"``, ``"2M"``, ``"3M"``, ``"6M"``, ``"1Y"``, ``"2Y"``,
|
|
174
|
+
``"5Y"``, ``"10Y"``, ``"20Y"`` or ``"50Y"``. ``"auto"`` (the
|
|
175
|
+
default) picks the finest step that fits. A fixed step is drawn
|
|
176
|
+
even if its labels overlap.
|
|
177
|
+
* ``date_labels`` -- ``"tick"`` (the default) puts each label
|
|
178
|
+
under its tick; ``"centered"`` marks the start of each year (or
|
|
179
|
+
month) with a tick and centres its label over the stretch it
|
|
180
|
+
covers, the usual look for long monthly series. A year at
|
|
181
|
+
either edge is labelled only when at least
|
|
182
|
+
``date_centered_min_fraction`` of it is drawn (half by default,
|
|
183
|
+
in ``plots.yaml``). The first label already names the starting
|
|
184
|
+
year, so ``date_show_start`` does not apply.
|
|
163
185
|
* ``adapt_height`` -- when true (the default), grow the figure
|
|
164
186
|
**height** (keeping the width) so the plot area is not
|
|
165
187
|
squashed by a large legend or rotated date labels. Skipped
|
|
@@ -232,6 +254,8 @@ class LinePlot(FigureLifecycle):
|
|
|
232
254
|
date_density: str = "default",
|
|
233
255
|
date_rotation: float | None = None,
|
|
234
256
|
date_show_start: bool | None = None,
|
|
257
|
+
date_step: str = "auto",
|
|
258
|
+
date_labels: str = "tick",
|
|
235
259
|
adapt_height: bool = True,
|
|
236
260
|
ref_lines: list[dict[str, Any]] | None = None,
|
|
237
261
|
show_means: bool = False,
|
|
@@ -285,6 +309,8 @@ class LinePlot(FigureLifecycle):
|
|
|
285
309
|
self.date_density = date_density
|
|
286
310
|
self.date_rotation = date_rotation
|
|
287
311
|
self.date_show_start = date_show_start
|
|
312
|
+
self.date_step = check_date_step(date_step)
|
|
313
|
+
self.date_labels = check_date_labels(date_labels)
|
|
288
314
|
self.adapt_height = adapt_height
|
|
289
315
|
self.ref_lines = ref_lines or []
|
|
290
316
|
self.show_means = show_means
|
|
@@ -307,8 +333,9 @@ class LinePlot(FigureLifecycle):
|
|
|
307
333
|
def _custom_date_axis(self) -> bool:
|
|
308
334
|
"""Whether the non-compact chart takes over the x-axis for dates.
|
|
309
335
|
|
|
310
|
-
When the user asks for a non-default density
|
|
311
|
-
the start date is pinned: all
|
|
336
|
+
When the user asks for a non-default density, step, label mode or
|
|
337
|
+
a rotation, or when the start date is pinned: all of them need a
|
|
338
|
+
**native** date axis,
|
|
312
339
|
because pandas' own date plotting owns its locator and formatter (and
|
|
313
340
|
labels a pinned tick blank). With none of them the historical pandas
|
|
314
341
|
date plotting is kept untouched. Compact mode has its own date axis
|
|
@@ -317,6 +344,8 @@ class LinePlot(FigureLifecycle):
|
|
|
317
344
|
return not self.compact and (
|
|
318
345
|
self.date_density != "default"
|
|
319
346
|
or self.date_rotation is not None
|
|
347
|
+
or self.date_step != "auto"
|
|
348
|
+
or self.date_labels != "tick"
|
|
320
349
|
or self._pin_start_date
|
|
321
350
|
)
|
|
322
351
|
|
|
@@ -345,6 +374,7 @@ class LinePlot(FigureLifecycle):
|
|
|
345
374
|
``plot_size``, ``vlines``, ``compact``, ``font_pt``,
|
|
346
375
|
``axis_decimals``, ``axis_units``, ``annotate_units``,
|
|
347
376
|
``date_density``, ``date_rotation``, ``date_show_start``,
|
|
377
|
+
``date_step``, ``date_labels``,
|
|
348
378
|
``adapt_height``,
|
|
349
379
|
``ref_lines``, ``show_means``, ``means_window``,
|
|
350
380
|
``y_limits``, ``y_group``.
|
|
@@ -611,10 +641,19 @@ class LinePlot(FigureLifecycle):
|
|
|
611
641
|
)
|
|
612
642
|
|
|
613
643
|
if self.compact:
|
|
644
|
+
# y-axis side: keep the house RIGHT side (matching every other
|
|
645
|
+
# chart in a two-per-row row) by default, and move to the LEFT
|
|
646
|
+
# only when annotate is on -- there the right edge is needed for
|
|
647
|
+
# the end-of-series value labels. style_compact_axes also pins
|
|
648
|
+
# the tick label size -- BEFORE the date axis, which measures the
|
|
649
|
+
# labels at that size to fit as many years as it can -- and
|
|
650
|
+
# drops the now-tickless spines.
|
|
651
|
+
side = "left" if self.annotate else "right"
|
|
652
|
+
style_compact_axes(ax, font_pt, side=side)
|
|
614
653
|
# pin the date ticks to the data span (after annotate widened the
|
|
615
654
|
# x-limit) so the last tick sits next to the final data point and
|
|
616
655
|
# none float in the value-label margin; match Word's body font.
|
|
617
|
-
# date_density ->
|
|
656
|
+
# date_density -> label cap; date_rotation tilts the labels.
|
|
618
657
|
# The start tick is pinned whatever the density or rotation: an
|
|
619
658
|
# even calendar cadence used to win here, but then asking for
|
|
620
659
|
# more (or slanted) dates silently cost you the one date the
|
|
@@ -632,14 +671,10 @@ class LinePlot(FigureLifecycle):
|
|
|
632
671
|
self.date_density != "default"
|
|
633
672
|
and self.date_rotation is None
|
|
634
673
|
),
|
|
674
|
+
max_labels=date_max_labels(self.date_density, compact=True),
|
|
675
|
+
step=self.date_step,
|
|
676
|
+
labels=self.date_labels,
|
|
635
677
|
)
|
|
636
|
-
# y-axis side: keep the house RIGHT side (matching every other
|
|
637
|
-
# chart in a two-per-row row) by default, and move to the LEFT
|
|
638
|
-
# only when annotate is on -- there the right edge is needed for
|
|
639
|
-
# the end-of-series value labels. style_compact_axes also pins
|
|
640
|
-
# the tick label size and drops the now-tickless spines.
|
|
641
|
-
side = "left" if self.annotate else "right"
|
|
642
|
-
style_compact_axes(ax, font_pt, side=side)
|
|
643
678
|
# denser y-numbers (e.g. every 20 instead of every 50) so the
|
|
644
679
|
# small chart still has enough reference gridlines.
|
|
645
680
|
compact_y_axis(ax)
|
|
@@ -655,6 +690,9 @@ class LinePlot(FigureLifecycle):
|
|
|
655
690
|
data_min=plot_data.index.min(),
|
|
656
691
|
data_max=plot_data.index.max(),
|
|
657
692
|
pin_start=self._pin_start_date,
|
|
693
|
+
max_labels=date_max_labels(self.date_density, compact=False),
|
|
694
|
+
step=self.date_step,
|
|
695
|
+
labels=self.date_labels,
|
|
658
696
|
)
|
|
659
697
|
|
|
660
698
|
if self.legend is not None:
|
|
@@ -53,6 +53,7 @@ import numpy as np
|
|
|
53
53
|
import plotly.graph_objects as go
|
|
54
54
|
|
|
55
55
|
from tesorotools.artists._common import AX_CONFIG, Legend
|
|
56
|
+
from tesorotools.artists._dates import plotly_dtick
|
|
56
57
|
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
57
58
|
|
|
58
59
|
if TYPE_CHECKING:
|
|
@@ -239,6 +240,20 @@ def _fit_bar_range(fig: go.Figure, values: Any, pad_frac: float = 0.25) -> None:
|
|
|
239
240
|
)
|
|
240
241
|
|
|
241
242
|
|
|
243
|
+
def _date_axis(fig: go.Figure, start: Any, step: str, labels: str) -> None:
|
|
244
|
+
"""Fixed date step and centred labels, as on the PNG axis.
|
|
245
|
+
|
|
246
|
+
With ``date_step: auto`` the interactive axis keeps Plotly's own
|
|
247
|
+
ticks, which adapt to zoom; a fixed step becomes ``dtick`` (anchored
|
|
248
|
+
like the PNG grid) and centred labels ``ticklabelmode="period"``.
|
|
249
|
+
"""
|
|
250
|
+
if step != "auto":
|
|
251
|
+
dtick, tick0 = plotly_dtick(start, step)
|
|
252
|
+
fig.update_xaxes(dtick=dtick, tick0=tick0)
|
|
253
|
+
if labels == "centered":
|
|
254
|
+
fig.update_xaxes(ticklabelmode="period")
|
|
255
|
+
|
|
256
|
+
|
|
242
257
|
def _padded_range(values: list[float], margin: float) -> list[float]:
|
|
243
258
|
"""Span *values* with *margin* padding (mirrors vector's ``_set_limits``)."""
|
|
244
259
|
lo, hi = min(values), max(values)
|
|
@@ -310,6 +325,7 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
|
|
|
310
325
|
# fixed or group-shared y range (see share_y_axes)
|
|
311
326
|
if chart.y_limits is not None:
|
|
312
327
|
fig.update_yaxes(range=list(chart.y_limits))
|
|
328
|
+
_date_axis(fig, plot_data.index.min(), chart.date_step, chart.date_labels)
|
|
313
329
|
_legend_side(fig, chart.legend)
|
|
314
330
|
return fig
|
|
315
331
|
|
|
@@ -447,6 +463,7 @@ def stacked_area_to_figure(chart: StackedAreaPlot) -> go.Figure:
|
|
|
447
463
|
_baseline(fig, 0)
|
|
448
464
|
if chart.fmt.units:
|
|
449
465
|
fig.update_yaxes(ticksuffix=chart.fmt.units)
|
|
466
|
+
_date_axis(fig, plot_data.index.min(), chart.date_step, chart.date_labels)
|
|
450
467
|
_legend_side(fig, chart.legend)
|
|
451
468
|
return fig
|
|
452
469
|
|
tesorotools/artists/stacked.py
CHANGED
|
@@ -29,6 +29,7 @@ from tesorotools.artists._common import (
|
|
|
29
29
|
style_compact_axes,
|
|
30
30
|
style_spines,
|
|
31
31
|
)
|
|
32
|
+
from tesorotools.artists._dates import check_date_labels, check_date_step
|
|
32
33
|
from tesorotools.utils.template import TemplateLoader
|
|
33
34
|
|
|
34
35
|
|
|
@@ -45,6 +46,11 @@ class StackedAreaPlot(FigureLifecycle):
|
|
|
45
46
|
``false`` leaves the plain calendar grid. Pinning takes
|
|
46
47
|
over the x-axis, so at full size the date labels switch to
|
|
47
48
|
the concise style.
|
|
49
|
+
|
|
50
|
+
The date axis labels every year (or month) that fits, like
|
|
51
|
+
``LinePlot``'s; ``date_step`` fixes the step (``"1Y"``,
|
|
52
|
+
``"3M"``...) and ``date_labels: centered`` centres each label
|
|
53
|
+
over its year or month.
|
|
48
54
|
"""
|
|
49
55
|
|
|
50
56
|
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
@@ -80,6 +86,8 @@ class StackedAreaPlot(FigureLifecycle):
|
|
|
80
86
|
compact: bool = False,
|
|
81
87
|
font_pt: float | None = None,
|
|
82
88
|
date_show_start: bool | None = None,
|
|
89
|
+
date_step: str = "auto",
|
|
90
|
+
date_labels: str = "tick",
|
|
83
91
|
) -> None:
|
|
84
92
|
if out_path.suffix != ".png":
|
|
85
93
|
raise ValueError(f"out_path must be .png: {out_path}")
|
|
@@ -95,6 +103,8 @@ class StackedAreaPlot(FigureLifecycle):
|
|
|
95
103
|
self.figsize = figsize
|
|
96
104
|
self.plot_size = plot_size
|
|
97
105
|
self.date_show_start = date_show_start
|
|
106
|
+
self.date_step = check_date_step(date_step)
|
|
107
|
+
self.date_labels = check_date_labels(date_labels)
|
|
98
108
|
self.compact = compact
|
|
99
109
|
self.font_pt = font_pt
|
|
100
110
|
|
|
@@ -117,7 +127,7 @@ class StackedAreaPlot(FigureLifecycle):
|
|
|
117
127
|
-------------
|
|
118
128
|
``scale``, ``start_date``, ``end_date``, ``baseline``,
|
|
119
129
|
``fmt`` (``!format``), ``legend`` (``!legend``),
|
|
120
|
-
``date_show_start``,
|
|
130
|
+
``date_show_start``, ``date_step``, ``date_labels``,
|
|
121
131
|
``figsize``, ``plot_size``, ``compact`` (fixed
|
|
122
132
|
physical size, body-size text, legend in its own band
|
|
123
133
|
and a sparse concise date axis, for two charts per row),
|
|
@@ -215,24 +225,29 @@ class StackedAreaPlot(FigureLifecycle):
|
|
|
215
225
|
else self.date_show_start
|
|
216
226
|
)
|
|
217
227
|
if self.compact:
|
|
218
|
-
#
|
|
219
|
-
#
|
|
228
|
+
# body-size ticks and the house right-hand y-axis first, so the
|
|
229
|
+
# date axis measures its labels at their real size, then every
|
|
230
|
+
# year (or month) that fits across the narrow figure.
|
|
231
|
+
style_compact_axes(ax, font_pt, side="right")
|
|
220
232
|
compact_date_axis(
|
|
221
233
|
ax,
|
|
222
234
|
plot_data.index.min(),
|
|
223
235
|
plot_data.index.max(),
|
|
224
236
|
pin_start=pin_start,
|
|
237
|
+
step=self.date_step,
|
|
238
|
+
labels=self.date_labels,
|
|
225
239
|
)
|
|
226
|
-
|
|
227
|
-
elif pin_start:
|
|
240
|
+
else:
|
|
228
241
|
# stackplot already draws on a native date axis, so the ticks can
|
|
229
|
-
# be
|
|
242
|
+
# be laid on the calendar grid with the first date spelled out
|
|
230
243
|
apply_date_axis(
|
|
231
244
|
ax,
|
|
232
245
|
maxticks=date_maxticks("default", compact=False),
|
|
233
246
|
data_min=plot_data.index.min(),
|
|
234
247
|
data_max=plot_data.index.max(),
|
|
235
|
-
pin_start=
|
|
248
|
+
pin_start=pin_start,
|
|
249
|
+
step=self.date_step,
|
|
250
|
+
labels=self.date_labels,
|
|
236
251
|
)
|
|
237
252
|
|
|
238
253
|
place_legend(
|
tesorotools/assets/plots.yaml
CHANGED
|
@@ -139,6 +139,22 @@ line:
|
|
|
139
139
|
few: 3
|
|
140
140
|
default: 6
|
|
141
141
|
many: 14
|
|
142
|
+
# Eje de fechas por calendario: se etiqueta CADA año (o cada mes en
|
|
143
|
+
# periodos de hasta date_monthly_max_months meses) si las etiquetas caben
|
|
144
|
+
# sin solaparse y no pasan de date_max_labels; si no, se salta a uno cada
|
|
145
|
+
# 2, 5, 10... años. date_maxticks solo queda para periodos demasiado
|
|
146
|
+
# cortos para una rejilla de calendario (unas semanas de datos diarios).
|
|
147
|
+
date_max_labels:
|
|
148
|
+
few: 6
|
|
149
|
+
default: 15
|
|
150
|
+
many: 30
|
|
151
|
+
date_monthly_max_months: 24
|
|
152
|
+
# Con `date_labels: centered`, un año (o mes) de los extremos solo se
|
|
153
|
+
# etiqueta si se dibuja al menos esta fracción de él
|
|
154
|
+
date_centered_min_fraction: 0.5
|
|
155
|
+
# Hueco mínimo entre dos etiquetas de fecha vecinas, en ems (fracción del
|
|
156
|
+
# tamaño de letra): por debajo se considera que se pisan
|
|
157
|
+
date_label_gap_em: 0.3
|
|
142
158
|
# Fija la fecha del PRIMER dato como primer tick del eje X, para que siempre
|
|
143
159
|
# se vea desde cuando se dibuja la serie (el tick "bonito" de calendario cae
|
|
144
160
|
# normalmente antes del primer dato, y entonces una serie que empieza en
|
|
@@ -227,6 +243,17 @@ compact:
|
|
|
227
243
|
few: 3
|
|
228
244
|
default: 4
|
|
229
245
|
many: 12
|
|
246
|
+
# Eje de fechas por calendario (ver line.date_max_labels): todos los años
|
|
247
|
+
# que quepan en el ancho compact, con un máximo de etiquetas por nivel
|
|
248
|
+
date_max_labels:
|
|
249
|
+
few: 4
|
|
250
|
+
default: 8
|
|
251
|
+
many: 14
|
|
252
|
+
date_monthly_max_months: 24
|
|
253
|
+
date_centered_min_fraction: 0.5
|
|
254
|
+
# Hueco mínimo entre dos etiquetas de fecha vecinas, en ems (fracción del
|
|
255
|
+
# tamaño de letra): por debajo se considera que se pisan
|
|
256
|
+
date_label_gap_em: 0.3
|
|
230
257
|
# Gráficos de barras horizontales (barh / grouped_barh): el alto fijo de
|
|
231
258
|
# compact aplastaría las etiquetas de un gráfico con muchas categorías, así
|
|
232
259
|
# que en compact se mantiene el ANCHO fijo y el ALTO crece con el nº de
|
tesorotools/note/lint.py
CHANGED
|
@@ -598,6 +598,8 @@ _ABBREVIATION = re.compile(
|
|
|
598
598
|
re.I,
|
|
599
599
|
)
|
|
600
600
|
_BOLD_RULE = "negrita solo en la entradilla, no en cifras ni frases sueltas"
|
|
601
|
+
#: A footnote in the text, ``^[...]`` (one level of brackets inside).
|
|
602
|
+
_NOTE_MARKUP = re.compile(r"\^\[(?:[^\[\]]|\[[^\]]*\])*\]")
|
|
601
603
|
|
|
602
604
|
|
|
603
605
|
def _ends_sentence(text: str) -> bool:
|
|
@@ -652,7 +654,9 @@ def _bold_issues(line: int, text: str, *, lead: bool) -> Iterator[Issue]:
|
|
|
652
654
|
yield Issue(line, "negrita", _BOLD_RULE)
|
|
653
655
|
if not starts_bold:
|
|
654
656
|
return
|
|
655
|
-
|
|
657
|
+
# without its footnotes: the «**» of a note's own lead
|
|
658
|
+
# («**Lead^[**Término**: ...] sigue.**») is not the end of the lead
|
|
659
|
+
if re.match(r"^\s*\*\*[^*]+\*\*[^\s*:;,.)]", _NOTE_MARKUP.sub("", text)):
|
|
656
660
|
yield Issue(
|
|
657
661
|
line,
|
|
658
662
|
"entradilla",
|
tesorotools/providers/lseg.py
CHANGED
|
@@ -650,6 +650,23 @@ class LSEGProvider(DataProvider):
|
|
|
650
650
|
RIC.
|
|
651
651
|
- **Flat columns labelled by RIC** — single-field call against
|
|
652
652
|
multiple RICs (degenerate). Returned as-is.
|
|
653
|
+
|
|
654
|
+
Rows are only sorted by date, with a stable sort so that a date
|
|
655
|
+
returned twice keeps the response's order and the by-index
|
|
656
|
+
de-dup of :meth:`_download_batch` keeps the first copy the
|
|
657
|
+
source sent. They are **never** de-duplicated here, and
|
|
658
|
+
in particular never by value. Up to 0.1.7 every branch ended in
|
|
659
|
+
``drop_duplicates()``, which compares the *values* of a row, not
|
|
660
|
+
its date: every session whose close had already appeared earlier
|
|
661
|
+
in the response was deleted, and :meth:`_download_batch`
|
|
662
|
+
reindexed the hole to NaN, so a caller that forward-fills (the
|
|
663
|
+
usual convention of a daily store) kept the previous session's
|
|
664
|
+
close in its place. A three-decimal yield revisits the same
|
|
665
|
+
figure hundreds of times in a 2 500-day window, so seeded
|
|
666
|
+
histories came out with up to half their sessions frozen. The
|
|
667
|
+
same close on two sessions is two observations; a date returned
|
|
668
|
+
twice is a different thing, and :meth:`_download_batch` drops it
|
|
669
|
+
by index.
|
|
653
670
|
"""
|
|
654
671
|
if df.empty:
|
|
655
672
|
return pd.DataFrame(columns=pd.Index(rics))
|
|
@@ -662,7 +679,7 @@ class LSEGProvider(DataProvider):
|
|
|
662
679
|
if key in df.columns:
|
|
663
680
|
picked[ric] = df[key]
|
|
664
681
|
return (
|
|
665
|
-
pd.DataFrame(picked).
|
|
682
|
+
pd.DataFrame(picked).sort_index(kind="stable")
|
|
666
683
|
if picked
|
|
667
684
|
else pd.DataFrame(columns=pd.Index(rics))
|
|
668
685
|
)
|
|
@@ -671,14 +688,10 @@ class LSEGProvider(DataProvider):
|
|
|
671
688
|
ric = rics[0]
|
|
672
689
|
field = field_per_ric[ric]
|
|
673
690
|
if field in df.columns:
|
|
674
|
-
return (
|
|
675
|
-
pd.DataFrame({ric: df[field]})
|
|
676
|
-
.drop_duplicates()
|
|
677
|
-
.sort_index()
|
|
678
|
-
)
|
|
691
|
+
return pd.DataFrame({ric: df[field]}).sort_index(kind="stable")
|
|
679
692
|
return pd.DataFrame(columns=pd.Index([ric]))
|
|
680
693
|
|
|
681
|
-
return df.
|
|
694
|
+
return df.sort_index(kind="stable")
|
|
682
695
|
|
|
683
696
|
# ------------------------------------------------------------------
|
|
684
697
|
# Internal: per-RIC batched range download (close path)
|
|
@@ -748,6 +761,10 @@ class LSEGProvider(DataProvider):
|
|
|
748
761
|
df.index = dates
|
|
749
762
|
bdays_mask = ~dates.weekday.isin([5, 6])
|
|
750
763
|
df = df[bdays_mask]
|
|
764
|
+
# By date, never by value: the same close on two
|
|
765
|
+
# sessions is two observations. A business day the
|
|
766
|
+
# source does not print stays NaN after the reindex;
|
|
767
|
+
# filling it is the caller's decision.
|
|
751
768
|
df = df[~df.index.duplicated(keep="first")]
|
|
752
769
|
full_range = pd.date_range(
|
|
753
770
|
start=start_date, end=end_date, freq="B"
|
|
@@ -773,10 +790,23 @@ class LSEGProvider(DataProvider):
|
|
|
773
790
|
|
|
774
791
|
@staticmethod
|
|
775
792
|
def _unify_batches(csv_path: Path) -> pd.DataFrame:
|
|
793
|
+
"""Merge every cached CSV partial into one column per RIC.
|
|
794
|
+
|
|
795
|
+
``csv_path`` may hold partials of any range for the same RIC
|
|
796
|
+
(earlier runs, other ``start``/``end``), so the same date can
|
|
797
|
+
appear in several files. For each date the **newest file with a
|
|
798
|
+
figure** wins: files are read newest first (modification time,
|
|
799
|
+
then name for a stable tie-break) and the first non-NaN value
|
|
800
|
+
is kept. A NaN never hides a figure from another file, which
|
|
801
|
+
matters for partials written before 0.1.8, whose value-based
|
|
802
|
+
``drop_duplicates()`` left holes; a date no file prints stays
|
|
803
|
+
NaN.
|
|
804
|
+
"""
|
|
805
|
+
csv_files = [f for f in csv_path.iterdir() if f.suffix == ".csv"]
|
|
806
|
+
csv_files.sort(key=lambda f: (-f.stat().st_mtime_ns, f.name))
|
|
807
|
+
|
|
776
808
|
same_col_groups: dict[str, list[pd.DataFrame]] = {}
|
|
777
|
-
for csv_file in
|
|
778
|
-
if csv_file.suffix != ".csv":
|
|
779
|
-
continue
|
|
809
|
+
for csv_file in csv_files:
|
|
780
810
|
df = pd.read_csv(csv_file, index_col=_CSV_DATE_TITLE)
|
|
781
811
|
col_name = df.columns[0]
|
|
782
812
|
same_col_groups.setdefault(col_name, []).append(df)
|
|
@@ -784,9 +814,7 @@ class LSEGProvider(DataProvider):
|
|
|
784
814
|
vertical_dfs: list[pd.DataFrame] = []
|
|
785
815
|
for dfs in same_col_groups.values():
|
|
786
816
|
concatenated = pd.concat(dfs, axis=0)
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
]
|
|
790
|
-
vertical_dfs.append(concatenated)
|
|
817
|
+
# ``first`` skips NaN: the newest file that prints the date.
|
|
818
|
+
vertical_dfs.append(concatenated.groupby(level=0).first())
|
|
791
819
|
|
|
792
820
|
return pd.concat(vertical_dfs, axis=1).sort_index()
|
|
@@ -8,27 +8,28 @@ tesorotools/palette.py,sha256=RHt_nSQXaIuGQzVVcgOaYzcvtQSEbK52ox1cOVcosyo,2122
|
|
|
8
8
|
tesorotools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
tesorotools/scaffold.py,sha256=6mYNxLm_1CbEMjcYZ77m_2H1eTotbpatFJqhgyote0s,4578
|
|
10
10
|
tesorotools/artists/__init__.py,sha256=wZTHZPB4Af6FVheWnTeuOmuzmCIG9mvziP3gHZW2nl0,2631
|
|
11
|
-
tesorotools/artists/_common.py,sha256=
|
|
11
|
+
tesorotools/artists/_common.py,sha256=jeciBAyc9414OLHOyy_Mhx_1g_sE6RvvmsK3kaeloJQ,74361
|
|
12
|
+
tesorotools/artists/_dates.py,sha256=bEEWJvlXHf9R3T0qN3Qpu8bue_o4DZP9WHZXyrrIeM0,7939
|
|
12
13
|
tesorotools/artists/bar_line.py,sha256=8C-PuHQ8drXjTZeNsQ56R8UdQy2zvG6V270qrgFqetE,20319
|
|
13
14
|
tesorotools/artists/barh_plot.py,sha256=n8DjIX1APCVS7HW4zzfh6ehggdgBqb6sezQHov1k7us,37520
|
|
14
15
|
tesorotools/artists/box_plot.py,sha256=HfJ9gdAXE6wcMHG3sKMG0M2CcigkyPe2Wly7bBxq4nI,20413
|
|
15
16
|
tesorotools/artists/compact.py,sha256=e_0tqkRSIgyiyMSwirJzj2TYSKojMxUHzug5tE_7toc,3003
|
|
16
17
|
tesorotools/artists/intraday_plot.py,sha256=ucr5Riai0lfEgINOFm6qCuvZrGSQ4jfUmSSTxuSvIjM,13297
|
|
17
|
-
tesorotools/artists/line_plot.py,sha256=
|
|
18
|
+
tesorotools/artists/line_plot.py,sha256=MBQ8S4P6xnGzs0nr2auGm_R4UMhLS-AbhJMel5-w70o,33378
|
|
18
19
|
tesorotools/artists/matrix.py,sha256=cOpj9ZO5RR0-RlIP55UmD70i-738ck8wZIqRiDO22Hw,11785
|
|
19
|
-
tesorotools/artists/plotly_backend.py,sha256=
|
|
20
|
+
tesorotools/artists/plotly_backend.py,sha256=YhkXJpuT5HRg8A7TvxqpKG-VSKW0H4OShd7QLB51GL8,43752
|
|
20
21
|
tesorotools/artists/sankey.py,sha256=5rdWeih3AgIeqaQyUAhQMY8ZGAeVoEV4jHsi1HO3NTc,16269
|
|
21
22
|
tesorotools/artists/scatter.py,sha256=CEkn_XDlX-u67O-sQxj5iPGIFewTC9J44gXTi1tYFx4,18117
|
|
22
23
|
tesorotools/artists/sharing.py,sha256=Tg4AYtv0ileKQmzBSgmT1GzxA97tdU8ji_arE0oGEZ4,2236
|
|
23
24
|
tesorotools/artists/shock_plot.py,sha256=fS8fi2OaIbWmG_I4FzaCF4U6i9a6tzKZSQH48l4ZobY,13473
|
|
24
|
-
tesorotools/artists/stacked.py,sha256=
|
|
25
|
+
tesorotools/artists/stacked.py,sha256=B3NF1NzfHhK-YFjTgwoTiCEI2teX6mVtMyd3cbnnfDQ,18170
|
|
25
26
|
tesorotools/artists/tikz_backend.py,sha256=zqCFa9A_amwBvGjrFULzKf8comL2-1aQx7_yEDFxXFo,5832
|
|
26
27
|
tesorotools/artists/type_curve.py,sha256=0j1LjfS4Zdz4_W7qp-nY8VP0H-fENV5adUg4syAOvig,12570
|
|
27
28
|
tesorotools/artists/vector_plot.py,sha256=P7BtLCXRTWs3eepRFYvXzbJ9OkvB-Ya3kmXB6yJSkR0,12211
|
|
28
29
|
tesorotools/artists/waterfall.py,sha256=U40HB3gVj3lyuM4rwfnpuxpq0XE7zPRGspgKAoq9T-g,12386
|
|
29
30
|
tesorotools/assets/README.md,sha256=EjDuA4Xf-Omn0Khp8erNXdW-iyWBzSM2FQFexdrS6Eg,285
|
|
30
31
|
tesorotools/assets/nota_template.dotx,sha256=LC3qcMz4F3SGDkJ12VNNvinlP3f5rJH5qGU5ZtJgJuc,146199
|
|
31
|
-
tesorotools/assets/plots.yaml,sha256=
|
|
32
|
+
tesorotools/assets/plots.yaml,sha256=DU2O4eF2QORiLMF-uojYLOzXMan3azZ1ioJvqr7BeyM,14439
|
|
32
33
|
tesorotools/assets/template.docx,sha256=QYilJmJ-ltGPH_GH22j6Kis7wmNaAgFQXr68d1KUSHY,94394
|
|
33
34
|
tesorotools/assets/tesoro.mplstyle,sha256=9J3quXzu4Mhq0auHhU4RrKcHEnTCxTGgTP1GRGs8D-g,456
|
|
34
35
|
tesorotools/assets/fonts/CabinetGrotesk-Black.otf,sha256=b7M_yFxz5IcGtmWWPMzLVkfx4RLhVALQDFeoq4T1YD0,37728
|
|
@@ -82,7 +83,7 @@ tesorotools/note/charts.py,sha256=7dUsCyURC39qOzLMU09mIm8Jh7B80ShBdRhpBNn6cUE,44
|
|
|
82
83
|
tesorotools/note/cli.py,sha256=ZEzC4k7X6Go2LhVXqGAuBmqKrBdhxH4B7gWsUPabsJY,10685
|
|
83
84
|
tesorotools/note/inline.py,sha256=FqpNKst1uLVvUtVHF9Q6ddhfFBKqeaOw1fXMLUvpHrw,12378
|
|
84
85
|
tesorotools/note/issues.py,sha256=DZY6FblaInDyl0keen40nHt27w4mzpXz9VBMUjNdbu0,1456
|
|
85
|
-
tesorotools/note/lint.py,sha256=
|
|
86
|
+
tesorotools/note/lint.py,sha256=jUgdAt1Qheh0PZo39qap2V5aRFRi_m6IG_ZzKz96Kbc,40678
|
|
86
87
|
tesorotools/note/markdown.py,sha256=fk8lnBtbcVj6SaOU__ZqKRnDR3xQ46_xiRa0HMjUm3E,52823
|
|
87
88
|
tesorotools/note/pdf.py,sha256=wQWrfSsCYGYxatANAn8FXXfZbMQ-4tYOp3y8lYVs1B4,4142
|
|
88
89
|
tesorotools/note/skeleton.md,sha256=lWvoiHdTqt6fEc5WLu87HRkR7I9vOULM4yE5UTtkff4,2924
|
|
@@ -114,7 +115,7 @@ tesorotools/providers/bde.py,sha256=xbTwxzb013VyjHkXN88UvS_IQxklPv8j5rslzL_RQv4,
|
|
|
114
115
|
tesorotools/providers/ecb.py,sha256=8UIW-NFLlGVIM7E2Sd_9UEWryt5wwilY3Hv13dnjY68,6422
|
|
115
116
|
tesorotools/providers/fred.py,sha256=5Jk1kCOSeH86wGqPOexKSVgcVJABUUea0dQ7k4eWYlc,13132
|
|
116
117
|
tesorotools/providers/imf_irfcl.py,sha256=ywix759wki5AfSTZGRnolf4LGYKqxj-23Xm8bmi3iT4,8106
|
|
117
|
-
tesorotools/providers/lseg.py,sha256=
|
|
118
|
+
tesorotools/providers/lseg.py,sha256=F8ePZfCEvykVXmP31FF_o931Up8EV1o_GokPXEy3WdI,32109
|
|
118
119
|
tesorotools/render/__init__.py,sha256=wPbM3OMNx1R46K9KaIURxf7LNdcXIFj3eO0P7zkEqXo,662
|
|
119
120
|
tesorotools/render/document.py,sha256=AiR909votacAWfZmeUekulRfZ-c5-bI6t5SnabibAd0,1136
|
|
120
121
|
tesorotools/render/report.py,sha256=Gp7vAB-sfnnFRQyZXmlthlYMbtOxwQg_qh3ZWDweeNo,1385
|
|
@@ -159,7 +160,7 @@ tesorotools/web/templates/src/$package/main.py.tmpl,sha256=GXG3AA87WOjk5nTKA5Ywm
|
|
|
159
160
|
tesorotools/web/templates/src/$package/sections.py.tmpl,sha256=4MNZ85AW5ERG1SrkVAXIwdmAMeQEBARCYdAMqUr37mg,2084
|
|
160
161
|
tesorotools/web/templates/tests/__init__.py.tmpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
161
162
|
tesorotools/web/templates/tests/test_app.py.tmpl,sha256=ZGfV5GPN6WsS3a2gCNLPWejkHTX2H_N9XpurIADuUxI,615
|
|
162
|
-
tesorotools_python-0.1.
|
|
163
|
-
tesorotools_python-0.1.
|
|
164
|
-
tesorotools_python-0.1.
|
|
165
|
-
tesorotools_python-0.1.
|
|
163
|
+
tesorotools_python-0.1.8.dist-info/METADATA,sha256=8Qjg10_TLhhQ9eadDIx9mrbpYsSpW2uIJHYp43dkB10,1025
|
|
164
|
+
tesorotools_python-0.1.8.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
165
|
+
tesorotools_python-0.1.8.dist-info/entry_points.txt,sha256=X0LciYC_rEvc6X1KGTKzQ6GvdDmSxdUy20_vg1Mcl6g,214
|
|
166
|
+
tesorotools_python-0.1.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|