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.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. 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