composeui 0.1.0__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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- composeui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""A chart as a report's figure, drawn by matplotlib alike on every backend, or with none.
|
|
2
|
+
|
|
3
|
+
A screen chart follows its window and theme; a report's figure has its page's size and
|
|
4
|
+
fonts, and a light background. So the figure is drawn from the `ChartView`'s data, e.g.
|
|
5
|
+
by a batch run writing a report's figures:
|
|
6
|
+
|
|
7
|
+
save_chart_image(chart, Path("pressure.pdf"), ImageOptions(width_mm=80))
|
|
8
|
+
|
|
9
|
+
It needs the `report` extra (matplotlib), imported only by these functions; without it
|
|
10
|
+
they raise an `ImportError` naming the extra, and `is_available` is False.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import io
|
|
18
|
+
import math
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
import attrs
|
|
22
|
+
|
|
23
|
+
from composeui.core.view import ChartKind, ChartView, LineStyle
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Iterator
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from matplotlib.axes import Axes
|
|
30
|
+
from matplotlib.figure import Figure
|
|
31
|
+
|
|
32
|
+
__all__ = ["ImageOptions", "chart_figure", "save_chart_image"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#: the formats of an image, by its file's suffix: vector ones, then PNG, a raster
|
|
36
|
+
FORMATS = ("pdf", "svg", "eps", "png")
|
|
37
|
+
MEDIA_TYPES = {
|
|
38
|
+
"pdf": "application/pdf",
|
|
39
|
+
"svg": "image/svg+xml",
|
|
40
|
+
"eps": "application/postscript",
|
|
41
|
+
"png": "image/png",
|
|
42
|
+
}
|
|
43
|
+
#: Okabe and Ito's colors, told apart with color blindness too, the yellow last: it is
|
|
44
|
+
#: faint on white
|
|
45
|
+
SERIES_COLORS = (
|
|
46
|
+
"#0072B2",
|
|
47
|
+
"#D55E00",
|
|
48
|
+
"#009E73",
|
|
49
|
+
"#CC79A7",
|
|
50
|
+
"#E69F00",
|
|
51
|
+
"#56B4E9",
|
|
52
|
+
"#000000",
|
|
53
|
+
"#F0E442",
|
|
54
|
+
)
|
|
55
|
+
#: a dash pattern, a marker and a hatch per series: printed in black and white, a figure
|
|
56
|
+
#: still tells its series apart
|
|
57
|
+
LINE_STYLES: tuple[Any, ...] = (
|
|
58
|
+
"-",
|
|
59
|
+
(0, (5, 2)),
|
|
60
|
+
(0, (5, 2, 1, 2)),
|
|
61
|
+
(0, (1, 1.5)),
|
|
62
|
+
(0, (8, 2, 2, 2)),
|
|
63
|
+
(0, (3, 1, 1, 1, 1, 1)),
|
|
64
|
+
)
|
|
65
|
+
MARKERS = ("o", "s", "^", "D", "v", "P", "X", "*")
|
|
66
|
+
HATCHES = ("", "//", "..", "xx", "\\\\", "oo", "--", "++")
|
|
67
|
+
#: a reference line's color, unless it has its own: the axes'
|
|
68
|
+
REFERENCE_LINE_COLOR = "#333333"
|
|
69
|
+
#: a reference line's dash pattern, by its style
|
|
70
|
+
REFERENCE_LINE_STYLES: dict[LineStyle, Any] = {
|
|
71
|
+
LineStyle.SOLID: "-",
|
|
72
|
+
LineStyle.DASHED: (0, (5, 3)),
|
|
73
|
+
LineStyle.DOTTED: (0, (1, 2)),
|
|
74
|
+
LineStyle.DASH_DOT: (0, (5, 2, 1, 2)),
|
|
75
|
+
}
|
|
76
|
+
#: the series a legend lists inside the axes; more, and it goes beside them
|
|
77
|
+
_INSIDE_LEGEND_MAX = 3
|
|
78
|
+
_MM_PER_INCH = 25.4
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@attrs.frozen
|
|
82
|
+
class ImageOptions:
|
|
83
|
+
"""How a chart's image looks: its size, fonts and range."""
|
|
84
|
+
|
|
85
|
+
#: an A4 page's text width
|
|
86
|
+
width_mm: float = 160
|
|
87
|
+
height_mm: float = 100
|
|
88
|
+
#: the resolution of a PNG; a vector image has none
|
|
89
|
+
dpi: int = 300
|
|
90
|
+
#: points, as the report's body text
|
|
91
|
+
font_size: float = 9
|
|
92
|
+
#: a report usually captions its figures itself
|
|
93
|
+
show_title: bool = False
|
|
94
|
+
#: the region shown, e.g. the one the user zoomed into; None: all the data
|
|
95
|
+
x_range: tuple[float, float] | None = None
|
|
96
|
+
y_range: tuple[float, float] | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
#: an image for a report's page, in its body text's font size
|
|
100
|
+
DEFAULT_OPTIONS = ImageOptions()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_available() -> bool:
|
|
104
|
+
"""Check whether matplotlib, the `report` extra, is installed."""
|
|
105
|
+
return importlib.util.find_spec("matplotlib") is not None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require() -> None:
|
|
109
|
+
if not is_available():
|
|
110
|
+
msg = (
|
|
111
|
+
"A chart's image is drawn by matplotlib: install composeui's `report` extra"
|
|
112
|
+
" (pip install 'composeui[report]')."
|
|
113
|
+
)
|
|
114
|
+
raise ImportError(msg)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@contextlib.contextmanager
|
|
118
|
+
def image_style(options: ImageOptions = DEFAULT_OPTIONS) -> Iterator[None]:
|
|
119
|
+
"""Draw and save figures with the options' fonts while the block runs.
|
|
120
|
+
|
|
121
|
+
Text stays text in a vector image: TrueType fonts in a PDF or EPS, ``<text>`` in an
|
|
122
|
+
SVG. So its labels can still be edited or searched. Matplotlib's global settings are
|
|
123
|
+
left as they were: wrap a `chart_figure` saved with `Figure.savefig` in it too.
|
|
124
|
+
"""
|
|
125
|
+
_require()
|
|
126
|
+
import matplotlib as mpl # noqa: PLC0415
|
|
127
|
+
|
|
128
|
+
size = options.font_size
|
|
129
|
+
with mpl.rc_context(
|
|
130
|
+
{
|
|
131
|
+
"pdf.fonttype": 42,
|
|
132
|
+
"ps.fonttype": 42,
|
|
133
|
+
"svg.fonttype": "none",
|
|
134
|
+
"font.size": size,
|
|
135
|
+
"axes.titlesize": size * 1.1,
|
|
136
|
+
"axes.labelsize": size,
|
|
137
|
+
"xtick.labelsize": size * 0.9,
|
|
138
|
+
"ytick.labelsize": size * 0.9,
|
|
139
|
+
"legend.fontsize": size * 0.9,
|
|
140
|
+
"figure.facecolor": "white",
|
|
141
|
+
"axes.facecolor": "white",
|
|
142
|
+
"savefig.facecolor": "white",
|
|
143
|
+
"axes.edgecolor": "#333333",
|
|
144
|
+
"axes.grid": True,
|
|
145
|
+
"grid.color": "#e5e5e5",
|
|
146
|
+
"grid.linewidth": 0.5,
|
|
147
|
+
"axes.axisbelow": True,
|
|
148
|
+
}
|
|
149
|
+
):
|
|
150
|
+
yield
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def chart_figure(chart: ChartView, options: ImageOptions = DEFAULT_OPTIONS) -> Figure:
|
|
154
|
+
"""Draw `chart` as a report's figure, which app code can add to before saving it.
|
|
155
|
+
|
|
156
|
+
Save it in `image_style` for its text to stay text, or with `save_chart_image`.
|
|
157
|
+
"""
|
|
158
|
+
_require()
|
|
159
|
+
from matplotlib.figure import Figure # noqa: PLC0415
|
|
160
|
+
|
|
161
|
+
with image_style(options):
|
|
162
|
+
figure = Figure(
|
|
163
|
+
figsize=(options.width_mm / _MM_PER_INCH, options.height_mm / _MM_PER_INCH),
|
|
164
|
+
dpi=options.dpi,
|
|
165
|
+
layout="constrained",
|
|
166
|
+
)
|
|
167
|
+
axes = figure.add_subplot()
|
|
168
|
+
if chart.kind is ChartKind.BAR:
|
|
169
|
+
_draw_bars(axes, chart)
|
|
170
|
+
else:
|
|
171
|
+
_draw_curves(axes, chart)
|
|
172
|
+
_draw_reference_lines(axes, chart)
|
|
173
|
+
if options.show_title and chart.title:
|
|
174
|
+
axes.set_title(chart.title)
|
|
175
|
+
axes.set_xlabel(ChartView.axis_label(chart.x_title, chart.x_unit))
|
|
176
|
+
axes.set_ylabel(ChartView.axis_label(chart.y_title, chart.y_unit))
|
|
177
|
+
if options.x_range is not None:
|
|
178
|
+
axes.set_xlim(*options.x_range)
|
|
179
|
+
if options.y_range is not None:
|
|
180
|
+
axes.set_ylim(*options.y_range)
|
|
181
|
+
_add_legend(figure, axes, chart)
|
|
182
|
+
return figure
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _draw_curves(axes: Axes, chart: ChartView) -> None:
|
|
186
|
+
if chart.x_log:
|
|
187
|
+
axes.set_xscale("log")
|
|
188
|
+
if chart.y_log:
|
|
189
|
+
axes.set_yscale("log")
|
|
190
|
+
for index, series in enumerate(chart.series):
|
|
191
|
+
x, y = ChartView.drawn_points(series, x_log=chart.x_log, y_log=chart.y_log)
|
|
192
|
+
color = series.color or SERIES_COLORS[index % len(SERIES_COLORS)]
|
|
193
|
+
if ChartView.kind_of(series, chart.kind) is ChartKind.SCATTER:
|
|
194
|
+
axes.plot(
|
|
195
|
+
x,
|
|
196
|
+
y,
|
|
197
|
+
linestyle="none",
|
|
198
|
+
marker=MARKERS[index % len(MARKERS)],
|
|
199
|
+
markersize=4,
|
|
200
|
+
markeredgewidth=0.5,
|
|
201
|
+
markeredgecolor="white",
|
|
202
|
+
color=color,
|
|
203
|
+
label=series.name,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
# a NaN is a gap in a matplotlib line too; its path simplification keeps what
|
|
207
|
+
# shows at the figure's resolution, so a long series needs no thinning
|
|
208
|
+
axes.plot(
|
|
209
|
+
x,
|
|
210
|
+
y,
|
|
211
|
+
linestyle=LINE_STYLES[index % len(LINE_STYLES)],
|
|
212
|
+
linewidth=1.2,
|
|
213
|
+
color=color,
|
|
214
|
+
label=series.name,
|
|
215
|
+
# each value held until the next point
|
|
216
|
+
drawstyle="steps-post" if series.step else "default",
|
|
217
|
+
)
|
|
218
|
+
if series.fill: # down to 0, e.g. a shape
|
|
219
|
+
axes.fill_between(
|
|
220
|
+
x, y, 0, color=color, linewidth=0, step="post" if series.step else None
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _draw_bars(axes: Axes, chart: ChartView) -> None:
|
|
225
|
+
count = len(chart.categories)
|
|
226
|
+
if chart.y_log:
|
|
227
|
+
axes.set_yscale("log")
|
|
228
|
+
for index, series in enumerate(chart.series):
|
|
229
|
+
heights = ChartView.bar_heights(series, count, y_log=chart.y_log)
|
|
230
|
+
offset, width = ChartView.bar_slot(index, len(chart.series))
|
|
231
|
+
drawn = [category for category, height in enumerate(heights) if math.isfinite(height)]
|
|
232
|
+
axes.bar(
|
|
233
|
+
[category + offset for category in drawn],
|
|
234
|
+
[heights[category] for category in drawn],
|
|
235
|
+
width=width,
|
|
236
|
+
color=series.color or SERIES_COLORS[index % len(SERIES_COLORS)],
|
|
237
|
+
hatch=HATCHES[index % len(HATCHES)],
|
|
238
|
+
edgecolor="white",
|
|
239
|
+
linewidth=0.5,
|
|
240
|
+
label=series.name,
|
|
241
|
+
)
|
|
242
|
+
axes.set_xticks(range(count), chart.categories)
|
|
243
|
+
axes.grid(visible=False, axis="x")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _draw_reference_lines(axes: Axes, chart: ChartView) -> None:
|
|
247
|
+
"""Draw the chart's reference lines, labeled inside the axes, at their ends."""
|
|
248
|
+
x_log = chart.x_log and chart.kind is not ChartKind.BAR
|
|
249
|
+
for line in ChartView.drawn_lines(chart.reference_lines, x_log=x_log, y_log=chart.y_log):
|
|
250
|
+
color = line.color or REFERENCE_LINE_COLOR
|
|
251
|
+
style = REFERENCE_LINE_STYLES[line.style]
|
|
252
|
+
# not in the legend, which names the series: its label is beside it
|
|
253
|
+
if line.vertical:
|
|
254
|
+
axes.axvline(line.value, color=color, linestyle=style, linewidth=1)
|
|
255
|
+
# at its top, left of it (now, after the data): the x in data, the y in the
|
|
256
|
+
# axes' share
|
|
257
|
+
axes.annotate(
|
|
258
|
+
line.label,
|
|
259
|
+
(line.value, 1),
|
|
260
|
+
xycoords=axes.get_xaxis_transform(),
|
|
261
|
+
xytext=(-3, -3),
|
|
262
|
+
textcoords="offset points",
|
|
263
|
+
ha="right",
|
|
264
|
+
va="top",
|
|
265
|
+
color=color,
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
axes.axhline(line.value, color=color, linestyle=style, linewidth=1)
|
|
269
|
+
# at its right end, over it
|
|
270
|
+
axes.annotate(
|
|
271
|
+
line.label,
|
|
272
|
+
(1, line.value),
|
|
273
|
+
xycoords=axes.get_yaxis_transform(),
|
|
274
|
+
xytext=(-3, 2),
|
|
275
|
+
textcoords="offset points",
|
|
276
|
+
ha="right",
|
|
277
|
+
va="bottom",
|
|
278
|
+
color=color,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _add_legend(figure: Figure, axes: Axes, chart: ChartView) -> None:
|
|
283
|
+
"""Name the series, beside the axes when there are more than a few of them."""
|
|
284
|
+
if len(chart.series) == 0:
|
|
285
|
+
return
|
|
286
|
+
if len(chart.series) <= _INSIDE_LEGEND_MAX:
|
|
287
|
+
axes.legend(frameon=False)
|
|
288
|
+
else:
|
|
289
|
+
figure.legend(loc="outside right upper", frameon=False)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def save_figure(figure: Figure, path: Path, options: ImageOptions = DEFAULT_OPTIONS) -> None:
|
|
293
|
+
"""Save `figure` to `path`, in the format its suffix names (see `FORMATS`)."""
|
|
294
|
+
image_format = _format_of(path.suffix.lstrip("."))
|
|
295
|
+
with image_style(options):
|
|
296
|
+
figure.savefig(path, format=image_format, dpi=options.dpi)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def save_chart_image(
|
|
300
|
+
chart: ChartView,
|
|
301
|
+
path: Path,
|
|
302
|
+
options: ImageOptions = DEFAULT_OPTIONS,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Save `chart`'s figure to `path`, in the format its suffix names (see `FORMATS`)."""
|
|
305
|
+
_format_of(path.suffix.lstrip(".")) # before drawing a figure it can't save
|
|
306
|
+
save_figure(chart_figure(chart, options), path, options)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def chart_image_bytes(
|
|
310
|
+
chart: ChartView,
|
|
311
|
+
image_format: str,
|
|
312
|
+
options: ImageOptions = DEFAULT_OPTIONS,
|
|
313
|
+
) -> bytes:
|
|
314
|
+
"""Get `chart`'s figure as a file of `image_format`'s (see `FORMATS`), e.g. to send."""
|
|
315
|
+
image_format = _format_of(image_format)
|
|
316
|
+
figure = chart_figure(chart, options)
|
|
317
|
+
buffer = io.BytesIO()
|
|
318
|
+
with image_style(options):
|
|
319
|
+
figure.savefig(buffer, format=image_format, dpi=options.dpi)
|
|
320
|
+
return buffer.getvalue()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def image_name(title: str, image_format: str) -> str:
|
|
324
|
+
"""Get a file name for the image of a chart of `title`, as a file can be named."""
|
|
325
|
+
stem = "".join(
|
|
326
|
+
character if character.isalnum() or character in " -_" else "_" for character in title
|
|
327
|
+
).strip()
|
|
328
|
+
return f"{stem or 'chart'}.{image_format}"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _format_of(image_format: str) -> str:
|
|
332
|
+
image_format = image_format.lower()
|
|
333
|
+
if image_format not in FORMATS:
|
|
334
|
+
msg = f"Cannot save a chart's image as {image_format!r}: choose one of {FORMATS}."
|
|
335
|
+
raise ValueError(msg)
|
|
336
|
+
return image_format
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""A dataframe's rows in a large table, a chart or a map: pandas, Polars, PyArrow, lazy...
|
|
2
|
+
|
|
3
|
+
`narwhals` (the ``dataframe`` extra) reads each library's frame through one API, so the
|
|
4
|
+
library sorts and filters the rows with its own expressions, never row by row in Python.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Optional, Union
|
|
11
|
+
|
|
12
|
+
import narwhals as nw
|
|
13
|
+
|
|
14
|
+
from composeui.core.signal import SignalNoArgs
|
|
15
|
+
from composeui.core.view import (
|
|
16
|
+
MAP_LATITUDE_LIMIT,
|
|
17
|
+
ColumnKind,
|
|
18
|
+
FilterOperator,
|
|
19
|
+
MapPoint,
|
|
20
|
+
Series,
|
|
21
|
+
SourceColumn,
|
|
22
|
+
column_kind,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
27
|
+
|
|
28
|
+
from narwhals.typing import IntoFrame
|
|
29
|
+
|
|
30
|
+
from composeui.core.view import Filter, Sort
|
|
31
|
+
|
|
32
|
+
__all__ = ["DataFrameSource", "points_of", "series_of"]
|
|
33
|
+
|
|
34
|
+
#: the column of the rows' ids added to a frame without an `id_column`
|
|
35
|
+
ROW_ID = "__composeui_id"
|
|
36
|
+
_EMPTY_KEY = "__composeui_empty"
|
|
37
|
+
_SORT_KEY = "__composeui_key"
|
|
38
|
+
|
|
39
|
+
_TEXT_OPERATORS = (
|
|
40
|
+
FilterOperator.CONTAINS,
|
|
41
|
+
FilterOperator.NOT_CONTAINS,
|
|
42
|
+
FilterOperator.TEXT_EQUAL,
|
|
43
|
+
FilterOperator.TEXT_NOT_EQUAL,
|
|
44
|
+
FilterOperator.STARTS_WITH,
|
|
45
|
+
FilterOperator.ENDS_WITH,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _python_type(dtype: Any) -> Any:
|
|
50
|
+
"""Get the Python type of a column's values, which sets how it sorts and filters."""
|
|
51
|
+
if dtype == nw.Boolean:
|
|
52
|
+
return bool
|
|
53
|
+
if dtype.is_integer():
|
|
54
|
+
return int
|
|
55
|
+
if dtype.is_numeric(): # a float or a decimal
|
|
56
|
+
return float
|
|
57
|
+
if dtype == nw.Datetime:
|
|
58
|
+
return datetime.datetime
|
|
59
|
+
if dtype == nw.Date:
|
|
60
|
+
return datetime.date
|
|
61
|
+
return str # a text, a category, or a type shown as its text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DataFrameSource:
|
|
65
|
+
"""A `TableSource` of a dataframe's rows, which a large table reads a window at a time.
|
|
66
|
+
|
|
67
|
+
Build it with any frame `narwhals` reads (pandas, Polars, PyArrow, a Polars
|
|
68
|
+
``LazyFrame``...) and the headers by column: ``DataFrameSource(frame, {"lat":
|
|
69
|
+
T("Latitude")})``, every column under its name without them. A row's id is its
|
|
70
|
+
position in the frame, or its `id_column` when it has one: a lazy frame needs one, its
|
|
71
|
+
rows having no position of their own. Assign a new `frame` to change the rows, which
|
|
72
|
+
emits `changed`.
|
|
73
|
+
|
|
74
|
+
The frame's library sorts and filters: a text compares in lower case, which isn't
|
|
75
|
+
Python's ``casefold`` ("ß" doesn't find "SS"), and a ``/regex/`` is in the library's
|
|
76
|
+
syntax (Rust's ``regex`` for Polars, ``re`` for pandas). Equal cells, and the rows
|
|
77
|
+
unsorted, keep the frame's order.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
frame: IntoFrame,
|
|
83
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
84
|
+
*,
|
|
85
|
+
id_column: Optional[str] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._id_column = id_column
|
|
88
|
+
self._changed = SignalNoArgs()
|
|
89
|
+
self._native = frame
|
|
90
|
+
self._frame = self._wrapped(frame)
|
|
91
|
+
schema = self._frame.collect_schema()
|
|
92
|
+
names = list(schema) if headers is None else list(headers)
|
|
93
|
+
for name in [*names, *([] if id_column is None else [id_column])]:
|
|
94
|
+
if name not in schema:
|
|
95
|
+
msg = f"Cannot build a DataFrameSource: the frame has no column {name!r}."
|
|
96
|
+
raise ValueError(msg)
|
|
97
|
+
self._columns = {
|
|
98
|
+
name: SourceColumn(
|
|
99
|
+
name, name if headers is None else headers[name], _python_type(schema[name])
|
|
100
|
+
)
|
|
101
|
+
for name in names
|
|
102
|
+
if name != ROW_ID
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def columns(self) -> Sequence[SourceColumn]:
|
|
107
|
+
return tuple(self._columns.values())
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def changed(self) -> SignalNoArgs:
|
|
111
|
+
return self._changed
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def frame(self) -> IntoFrame:
|
|
115
|
+
"""Get the frame given, in its own library."""
|
|
116
|
+
return self._native
|
|
117
|
+
|
|
118
|
+
@frame.setter
|
|
119
|
+
def frame(self, frame: IntoFrame) -> None:
|
|
120
|
+
wrapped = self._wrapped(frame)
|
|
121
|
+
self._native, self._frame = frame, wrapped
|
|
122
|
+
self._changed.emit()
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def _id(self) -> str:
|
|
126
|
+
return ROW_ID if self._id_column is None else self._id_column
|
|
127
|
+
|
|
128
|
+
def _wrapped(self, frame: IntoFrame) -> Union[nw.DataFrame[Any], nw.LazyFrame[Any]]:
|
|
129
|
+
wrapped = nw.from_native(frame)
|
|
130
|
+
if self._id_column is not None:
|
|
131
|
+
return wrapped
|
|
132
|
+
if isinstance(wrapped, nw.LazyFrame):
|
|
133
|
+
msg = "Cannot build a DataFrameSource of a lazy frame without an id_column."
|
|
134
|
+
raise TypeError(msg)
|
|
135
|
+
return wrapped.with_row_index(ROW_ID)
|
|
136
|
+
|
|
137
|
+
def count(self) -> int:
|
|
138
|
+
return int(_collected(self._frame.select(nw.len())).item())
|
|
139
|
+
|
|
140
|
+
def ordered_ids(
|
|
141
|
+
self, sort: Optional[Sort], search: str, filters: Mapping[str, Filter]
|
|
142
|
+
) -> list[Hashable]:
|
|
143
|
+
for name in [*filters, *([] if sort is None else [sort.column])]:
|
|
144
|
+
self._column(name)
|
|
145
|
+
# the frame once: in a job, the main thread may assign a new one meanwhile
|
|
146
|
+
frame = self._frame
|
|
147
|
+
conditions = [
|
|
148
|
+
self._condition(self._column(name), row_filter) for name, row_filter in filters.items()
|
|
149
|
+
]
|
|
150
|
+
needle = search.strip().casefold()
|
|
151
|
+
if needle != "":
|
|
152
|
+
texts = [
|
|
153
|
+
_text(name)
|
|
154
|
+
for name, column in self._columns.items()
|
|
155
|
+
if column_kind(column.type) is ColumnKind.TEXT
|
|
156
|
+
]
|
|
157
|
+
if len(texts) == 0:
|
|
158
|
+
return [] # no text column holds it, as a `ListSource` finds
|
|
159
|
+
conditions.append(
|
|
160
|
+
nw.any_horizontal(
|
|
161
|
+
*(text.str.contains(needle, literal=True) for text in texts),
|
|
162
|
+
ignore_nulls=True,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
if len(conditions) > 0:
|
|
166
|
+
frame = frame.filter(*conditions)
|
|
167
|
+
if sort is None:
|
|
168
|
+
ordered = frame.select(self._id)
|
|
169
|
+
else:
|
|
170
|
+
column = self._column(sort.column)
|
|
171
|
+
is_text = column_kind(column.type) is ColumnKind.TEXT
|
|
172
|
+
cell = nw.col(column.name)
|
|
173
|
+
empty = cell.is_null() | (cell.cast(nw.String) == "") if is_text else cell.is_null()
|
|
174
|
+
key = cell.cast(nw.String).str.to_lowercase() if is_text else cell
|
|
175
|
+
ordered = (
|
|
176
|
+
frame.select(self._id, empty.alias(_EMPTY_KEY), key.alias(_SORT_KEY))
|
|
177
|
+
# the empty cells last in both orders, equal cells in the frame's order
|
|
178
|
+
.sort(
|
|
179
|
+
_EMPTY_KEY,
|
|
180
|
+
_SORT_KEY,
|
|
181
|
+
self._id,
|
|
182
|
+
descending=[False, sort.descending, False],
|
|
183
|
+
nulls_last=True,
|
|
184
|
+
)
|
|
185
|
+
.select(self._id)
|
|
186
|
+
)
|
|
187
|
+
return list(_collected(ordered).get_column(self._id).to_list())
|
|
188
|
+
|
|
189
|
+
def cells(self, ids: Sequence[Hashable], columns: Sequence[str]) -> dict[Hashable, list[Any]]:
|
|
190
|
+
names = [self._column(name).name for name in columns]
|
|
191
|
+
rows = _collected(
|
|
192
|
+
self._frame.filter(nw.col(self._id).is_in(list(ids))).select(self._id, *names)
|
|
193
|
+
)
|
|
194
|
+
return {
|
|
195
|
+
_python_value(row[0]): [_python_value(value) for value in row[1:]]
|
|
196
|
+
for row in rows.iter_rows()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
def _column(self, name: str) -> SourceColumn:
|
|
200
|
+
column = self._columns.get(name)
|
|
201
|
+
if column is None:
|
|
202
|
+
msg = f"Cannot read the column {name!r} of a DataFrameSource: it has none so."
|
|
203
|
+
raise ValueError(msg)
|
|
204
|
+
return column
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _condition(column: SourceColumn, row_filter: Filter) -> nw.Expr:
|
|
208
|
+
"""Get the expression of `row_filter` on `column`, as `Filter.matches` reads a cell.
|
|
209
|
+
|
|
210
|
+
An empty cell's text is "", and it fails a comparison.
|
|
211
|
+
"""
|
|
212
|
+
cell = nw.col(column.name)
|
|
213
|
+
operator, value = row_filter.operator, row_filter.value
|
|
214
|
+
if operator is FilterOperator.EMPTY:
|
|
215
|
+
if column_kind(column.type) is ColumnKind.TEXT:
|
|
216
|
+
return cell.is_null() | (cell.cast(nw.String) == "")
|
|
217
|
+
return cell.is_null()
|
|
218
|
+
if operator in _TEXT_OPERATORS:
|
|
219
|
+
return _text_condition(_text(column.name), operator, value)
|
|
220
|
+
if operator is FilterOperator.MATCHES:
|
|
221
|
+
text = cell.cast(nw.String).fill_null("")
|
|
222
|
+
return text.str.contains(f"(?i){value.pattern}")
|
|
223
|
+
if operator is FilterOperator.RANGE:
|
|
224
|
+
compared = cell.is_between(value, row_filter.upper)
|
|
225
|
+
else:
|
|
226
|
+
compared = _comparison(cell, operator, value)
|
|
227
|
+
# an empty cell fails, though pandas' NaN differs from any value
|
|
228
|
+
return ~cell.is_null() & compared
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def series_of(frame: IntoFrame, x: str, y: str, *, name: Optional[str] = None) -> Series:
|
|
232
|
+
"""Make a chart's series of a frame's columns `x` and `y`, under `name`, else `y`.
|
|
233
|
+
|
|
234
|
+
The rows missing either value are left out, in the frame's order otherwise.
|
|
235
|
+
"""
|
|
236
|
+
rows = _collected(nw.from_native(frame).select(x, y).drop_nulls())
|
|
237
|
+
return Series(
|
|
238
|
+
y if name is None else name, rows.get_column(x).to_list(), rows.get_column(y).to_list()
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def points_of(frame: IntoFrame, latitude: str, longitude: str) -> list[MapPoint]:
|
|
243
|
+
"""Get a map's points of a frame's columns `latitude` and `longitude`, in degrees.
|
|
244
|
+
|
|
245
|
+
The rows missing either value, or out of a Web Mercator map (see `MapPoint`), are left out.
|
|
246
|
+
"""
|
|
247
|
+
rows = _collected(
|
|
248
|
+
nw.from_native(frame)
|
|
249
|
+
.select(latitude, longitude)
|
|
250
|
+
.drop_nulls()
|
|
251
|
+
.filter(
|
|
252
|
+
nw.col(latitude).is_between(-MAP_LATITUDE_LIMIT, MAP_LATITUDE_LIMIT),
|
|
253
|
+
nw.col(longitude).is_between(-180, 180),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return [MapPoint(latitude=lat, longitude=lon) for lat, lon in rows.iter_rows()]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _text(name: str) -> nw.Expr:
|
|
260
|
+
"""Get a cell's text in lower case, an empty cell's being ""."""
|
|
261
|
+
return nw.col(name).cast(nw.String).fill_null("").str.to_lowercase()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _text_condition(text: nw.Expr, operator: FilterOperator, value: str) -> nw.Expr:
|
|
265
|
+
if operator is FilterOperator.CONTAINS:
|
|
266
|
+
return text.str.contains(value, literal=True)
|
|
267
|
+
if operator is FilterOperator.NOT_CONTAINS:
|
|
268
|
+
return ~text.str.contains(value, literal=True)
|
|
269
|
+
if operator is FilterOperator.TEXT_EQUAL:
|
|
270
|
+
return text == value
|
|
271
|
+
if operator is FilterOperator.TEXT_NOT_EQUAL:
|
|
272
|
+
return text != value
|
|
273
|
+
if operator is FilterOperator.STARTS_WITH:
|
|
274
|
+
return text.str.starts_with(value)
|
|
275
|
+
return text.str.ends_with(value)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _comparison(cell: nw.Expr, operator: FilterOperator, value: Any) -> nw.Expr:
|
|
279
|
+
if operator is FilterOperator.EQUAL:
|
|
280
|
+
return cell == value
|
|
281
|
+
if operator is FilterOperator.NOT_EQUAL:
|
|
282
|
+
return cell != value
|
|
283
|
+
if operator is FilterOperator.LESS:
|
|
284
|
+
return cell < value
|
|
285
|
+
if operator is FilterOperator.LESS_OR_EQUAL:
|
|
286
|
+
return cell <= value
|
|
287
|
+
if operator is FilterOperator.GREATER:
|
|
288
|
+
return cell > value
|
|
289
|
+
return cell >= value
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _python_value(value: Any) -> Any:
|
|
293
|
+
"""Get a cell as Python's value: pandas gives numpy's scalars, and NaN or NaT for none."""
|
|
294
|
+
try:
|
|
295
|
+
if value is None or bool(value != value): # noqa: PLR0124 (NaN and NaT differ from themselves)
|
|
296
|
+
return None
|
|
297
|
+
except TypeError: # pandas' NA, which is neither true nor false
|
|
298
|
+
return None
|
|
299
|
+
if hasattr(value, "to_pydatetime"): # a pandas Timestamp
|
|
300
|
+
return value.to_pydatetime()
|
|
301
|
+
if hasattr(value, "item"): # a numpy scalar
|
|
302
|
+
return value.item()
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _collected(frame: Union[nw.DataFrame[Any], nw.LazyFrame[Any]]) -> nw.DataFrame[Any]:
|
|
307
|
+
return frame.collect() if isinstance(frame, nw.LazyFrame) else frame
|