snakelit 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.
- snakelit/__init__.py +0 -0
- snakelit/edit.py +43 -0
- snakelit/editor/__init__.py +0 -0
- snakelit/editor/config.py +325 -0
- snakelit/editor/table.py +332 -0
- snakelit/editor/table_schema.py +157 -0
- snakelit/editor/text.py +25 -0
- snakelit/file_explorer.py +302 -0
- snakelit/filetypes.py +37 -0
- snakelit/merge.py +167 -0
- snakelit/preview.py +58 -0
- snakelit/types.py +106 -0
- snakelit/utils/__init__.py +0 -0
- snakelit/utils/format.py +15 -0
- snakelit/utils/json.py +10 -0
- snakelit/utils/schema.py +229 -0
- snakelit/utils/st_layouts.py +23 -0
- snakelit/utils/table.py +101 -0
- snakelit/utils/ui_components.py +118 -0
- snakelit/utils/yaml.py +56 -0
- snakelit/utils/zip.py +60 -0
- snakelit/version_picker.py +215 -0
- snakelit-0.1.0.dist-info/METADATA +121 -0
- snakelit-0.1.0.dist-info/RECORD +25 -0
- snakelit-0.1.0.dist-info/WHEEL +4 -0
snakelit/__init__.py
ADDED
|
File without changes
|
snakelit/edit.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Store-backed file editors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import streamlit as st
|
|
6
|
+
|
|
7
|
+
from .filetypes import BINARY_SUFFIXES, IMAGE_SUFFIXES, suffix
|
|
8
|
+
from .types import Editors, FileStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def text_edit(text: str, key: str):
|
|
12
|
+
"""Render an editable text area and return its current value.
|
|
13
|
+
|
|
14
|
+
The key comes from whoever calls this (the file manager passes the path),
|
|
15
|
+
so multiple editable views never collide.
|
|
16
|
+
"""
|
|
17
|
+
return st.text_area("Content", value=text, key=f"{key}-text", height=420)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def edit_for(path: str, store: FileStore, key: str, *, editors: Editors | None = None):
|
|
21
|
+
"""Render ``path``'s editor and write the result back to ``store``.
|
|
22
|
+
|
|
23
|
+
The editor is chosen by suffix: a host-provided one from ``editors`` when
|
|
24
|
+
available, a plain text area for other text, nothing for images and
|
|
25
|
+
binaries. A host editor returns the original bytes when the file did not
|
|
26
|
+
change, so nothing is written then. Missing files start empty, so the
|
|
27
|
+
same call creates them; a successful change writes and reruns.
|
|
28
|
+
"""
|
|
29
|
+
content = store.read(path) or b""
|
|
30
|
+
ext = suffix(path)
|
|
31
|
+
if editors and ext in editors:
|
|
32
|
+
edited = editors[ext](path, content, key)
|
|
33
|
+
elif ext in IMAGE_SUFFIXES or ext in BINARY_SUFFIXES:
|
|
34
|
+
return
|
|
35
|
+
else:
|
|
36
|
+
try:
|
|
37
|
+
text = content.decode("utf-8")
|
|
38
|
+
except UnicodeDecodeError:
|
|
39
|
+
return
|
|
40
|
+
edited = text_edit(text=text, key=key).encode("utf-8")
|
|
41
|
+
if edited != content:
|
|
42
|
+
store.write(path, edited)
|
|
43
|
+
st.rerun()
|
|
File without changes
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Config editors driven by a JSON schema.
|
|
2
|
+
|
|
3
|
+
``schema_config`` renders a config document as widgets and rebuilds it from the
|
|
4
|
+
widget values on every rerun, so it never mutates the caller's object. The
|
|
5
|
+
schema is optional and read-only: with one, widgets and validation follow it;
|
|
6
|
+
without one, the config is edited as raw YAML. Only mappings get the form;
|
|
7
|
+
other YAML documents (lists, scalars) fall back to the text editor. Hosts
|
|
8
|
+
discover or let the user choose the schema; nothing in here edits or writes it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Callable, Mapping
|
|
15
|
+
|
|
16
|
+
import streamlit as st
|
|
17
|
+
|
|
18
|
+
from ..utils.st_layouts import st_container_maxheight
|
|
19
|
+
from ..utils.schema import infer_type, is_schema, property_schema, validate
|
|
20
|
+
from ..utils.yaml import SCIENTIFIC_FLOAT, dump as dump_yaml, load
|
|
21
|
+
from .text import schema_text_editor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dump(data, format: str):
|
|
25
|
+
if format == "yaml":
|
|
26
|
+
return dump_yaml(data)
|
|
27
|
+
from ..utils.json import dump as dump_json
|
|
28
|
+
|
|
29
|
+
return dump_json(data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _looks_like_path(value: str):
|
|
33
|
+
if not value or "://" in value or any(char.isspace() for char in value):
|
|
34
|
+
return False
|
|
35
|
+
return "/" in value or bool(re.search(r"\.[A-Za-z][A-Za-z0-9]*$", value))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def schema_config(
|
|
39
|
+
data: bytes,
|
|
40
|
+
errors: list[str],
|
|
41
|
+
schema: Mapping[str, Any] | None = None,
|
|
42
|
+
header="Configuration Editor",
|
|
43
|
+
*,
|
|
44
|
+
key: str = "config",
|
|
45
|
+
edit_file: Callable[[str], None] | None = None,
|
|
46
|
+
format: str = "yaml",
|
|
47
|
+
):
|
|
48
|
+
"""Render an editor for a config file and return the new bytes.
|
|
49
|
+
|
|
50
|
+
Widgets follow ``schema`` where it describes a key and are inferred from
|
|
51
|
+
the value elsewhere; a raw YAML editor is always offered as the
|
|
52
|
+
alternative. ``errors`` is filled in place. Returns ``None`` when the
|
|
53
|
+
text did not change or is not valid, so the caller can skip writing it.
|
|
54
|
+
|
|
55
|
+
With ``edit_file``, a string value that looks like a path gets a button
|
|
56
|
+
that calls it with the path string; the host decides whether to create,
|
|
57
|
+
open or ignore the file. ``format`` is ``"yaml"`` or ``"json"`` and
|
|
58
|
+
selects the serializer (parsing uses the YAML loader for both).
|
|
59
|
+
"""
|
|
60
|
+
text = data.decode("utf-8")
|
|
61
|
+
height = 420
|
|
62
|
+
probe: list[str] = []
|
|
63
|
+
config = load(text, probe)
|
|
64
|
+
if config is None and not probe:
|
|
65
|
+
config = {}
|
|
66
|
+
|
|
67
|
+
if isinstance(config, dict):
|
|
68
|
+
mode = st.radio(
|
|
69
|
+
header,
|
|
70
|
+
["Form", "Text Editor"],
|
|
71
|
+
help="Configuration editor mode",
|
|
72
|
+
horizontal=True,
|
|
73
|
+
key=f"{key}-editor-mode",
|
|
74
|
+
)
|
|
75
|
+
if mode == "Form":
|
|
76
|
+
# Compare canonical YAML, not the source text: comments and
|
|
77
|
+
# formatting must survive an untouched form.
|
|
78
|
+
original = _dump(config, format)
|
|
79
|
+
with st_container_maxheight(f"{key}-form-container", height=height):
|
|
80
|
+
create_form(config, schema, "", key, edit_file)
|
|
81
|
+
before = len(errors)
|
|
82
|
+
errors.extend(validate(config, schema))
|
|
83
|
+
if len(errors) > before:
|
|
84
|
+
return None
|
|
85
|
+
updated = _dump(config, format)
|
|
86
|
+
return None if updated == original else updated.encode("utf-8")
|
|
87
|
+
|
|
88
|
+
# Unparseable input is still editable: the text area is the source of
|
|
89
|
+
# truth and reports its own parse/validation errors.
|
|
90
|
+
updated = schema_text_editor(text, schema, key, height=height)
|
|
91
|
+
probe.clear()
|
|
92
|
+
parsed = load(updated, probe)
|
|
93
|
+
if not probe:
|
|
94
|
+
probe.extend(validate(parsed, schema) if parsed else ())
|
|
95
|
+
if not probe:
|
|
96
|
+
return None if updated == text else updated.encode("utf-8")
|
|
97
|
+
errors.extend(probe)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_form(
|
|
101
|
+
config: dict,
|
|
102
|
+
schema: Mapping[str, Any] | None,
|
|
103
|
+
path: str = "",
|
|
104
|
+
key: str = "config",
|
|
105
|
+
edit_file: Callable[[str], None] | None = None,
|
|
106
|
+
):
|
|
107
|
+
"""Render widgets for ``config`` in place, in configuration order.
|
|
108
|
+
|
|
109
|
+
Nested mappings recurse into a bordered container at their own position,
|
|
110
|
+
so the form follows the tree instead of collecting them into tabs. A
|
|
111
|
+
schema entry is used when it exists; otherwise the widget is inferred from
|
|
112
|
+
the value. No schema is constructed or modified.
|
|
113
|
+
"""
|
|
114
|
+
required = set((schema or {}).get("required") or ())
|
|
115
|
+
|
|
116
|
+
for name, value in config.items():
|
|
117
|
+
subschema = property_schema(schema, name)
|
|
118
|
+
if isinstance(value, dict):
|
|
119
|
+
with st.container(border=True):
|
|
120
|
+
st.markdown(f"**{name}**")
|
|
121
|
+
if subschema is None or is_schema(subschema):
|
|
122
|
+
create_form(value, subschema, _path(path, name), key, edit_file)
|
|
123
|
+
else:
|
|
124
|
+
config[name] = _raw(value, name, f"{key}-{_path(path, name)}", None)
|
|
125
|
+
continue
|
|
126
|
+
config[name] = _value(
|
|
127
|
+
value,
|
|
128
|
+
subschema or infer_type(value),
|
|
129
|
+
_path(path, name),
|
|
130
|
+
key,
|
|
131
|
+
label=name,
|
|
132
|
+
required=name in required,
|
|
133
|
+
edit_file=edit_file,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _value(
|
|
138
|
+
value,
|
|
139
|
+
schema: Mapping[str, Any],
|
|
140
|
+
path: str,
|
|
141
|
+
key: str,
|
|
142
|
+
*,
|
|
143
|
+
label: str,
|
|
144
|
+
required: bool,
|
|
145
|
+
edit_file: Callable[[str], None] | None = None,
|
|
146
|
+
):
|
|
147
|
+
widget_key = f"{key}-{path}"
|
|
148
|
+
help_text = schema.get("description")
|
|
149
|
+
caption = f"{label} *" if required else label
|
|
150
|
+
types = _types(schema)
|
|
151
|
+
|
|
152
|
+
if isinstance(schema.get("enum"), list):
|
|
153
|
+
options = list(schema["enum"])
|
|
154
|
+
if value not in options:
|
|
155
|
+
options = [value, *options]
|
|
156
|
+
return st.selectbox(
|
|
157
|
+
caption,
|
|
158
|
+
options,
|
|
159
|
+
index=options.index(value),
|
|
160
|
+
key=widget_key,
|
|
161
|
+
help=help_text,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if isinstance(value, list):
|
|
165
|
+
return _array(value, schema, caption, widget_key, help_text)
|
|
166
|
+
|
|
167
|
+
if isinstance(value, Mapping):
|
|
168
|
+
return _raw(value, caption, widget_key, help_text)
|
|
169
|
+
|
|
170
|
+
if "boolean" in types or (not types and isinstance(value, bool)):
|
|
171
|
+
return st.checkbox(caption, value=bool(value), key=widget_key, help=help_text)
|
|
172
|
+
|
|
173
|
+
if "integer" in types or "number" in types:
|
|
174
|
+
return _number(value, types, caption, widget_key, help_text)
|
|
175
|
+
|
|
176
|
+
if not types and isinstance(value, (int, float)):
|
|
177
|
+
return _number(value, ["number"], caption, widget_key, help_text)
|
|
178
|
+
|
|
179
|
+
if "string" in types or not types or value is None:
|
|
180
|
+
return _text(
|
|
181
|
+
value,
|
|
182
|
+
caption,
|
|
183
|
+
widget_key,
|
|
184
|
+
help_text,
|
|
185
|
+
nullable="null" in types,
|
|
186
|
+
edit_file=edit_file,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return _raw(value, caption, widget_key, help_text)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _text(
|
|
193
|
+
value,
|
|
194
|
+
caption: str,
|
|
195
|
+
key: str,
|
|
196
|
+
help_text: str | None,
|
|
197
|
+
*,
|
|
198
|
+
nullable: bool,
|
|
199
|
+
edit_file: Callable[[str], None] | None = None,
|
|
200
|
+
):
|
|
201
|
+
text = "" if value is None else str(value)
|
|
202
|
+
if edit_file is None or not _looks_like_path(text):
|
|
203
|
+
edited = st.text_input(caption, value=text, key=key, help=help_text)
|
|
204
|
+
else:
|
|
205
|
+
field, action = st.columns([5, 1], vertical_alignment="bottom")
|
|
206
|
+
edited = field.text_input(caption, value=text, key=key, help=help_text)
|
|
207
|
+
action.button(
|
|
208
|
+
"Edit",
|
|
209
|
+
key=f"{key}-file",
|
|
210
|
+
help=f"Treat `{text}` as a file, and open to edit it",
|
|
211
|
+
on_click=edit_file,
|
|
212
|
+
args=(edited or text,),
|
|
213
|
+
)
|
|
214
|
+
if nullable and not edited.strip():
|
|
215
|
+
return None
|
|
216
|
+
return edited
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _number(value, types: list[str], caption: str, key: str, help_text: str | None):
|
|
220
|
+
# Scientific-notation floats are strings by convention and must stay so.
|
|
221
|
+
if isinstance(value, str) or value is None or not types:
|
|
222
|
+
text = "" if value is None else str(value)
|
|
223
|
+
edited = st.text_input(caption, value=text, key=key, help=help_text)
|
|
224
|
+
return _parse_number(edited)
|
|
225
|
+
|
|
226
|
+
integer = "integer" in types
|
|
227
|
+
number = int(value) if integer else float(value)
|
|
228
|
+
edited = st.number_input(
|
|
229
|
+
caption,
|
|
230
|
+
value=number,
|
|
231
|
+
step=1 if integer else 0.1,
|
|
232
|
+
key=key,
|
|
233
|
+
help=help_text,
|
|
234
|
+
format="%d" if integer else None,
|
|
235
|
+
)
|
|
236
|
+
if edited == value:
|
|
237
|
+
# preserve the original object: an int must not become 2.0 just because
|
|
238
|
+
# the schema allows any number
|
|
239
|
+
return value
|
|
240
|
+
if not integer and isinstance(value, int) and float(edited).is_integer():
|
|
241
|
+
return int(edited)
|
|
242
|
+
return edited
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_number(text: str):
|
|
246
|
+
stripped = text.strip()
|
|
247
|
+
if not stripped:
|
|
248
|
+
return None
|
|
249
|
+
if SCIENTIFIC_FLOAT.match(stripped):
|
|
250
|
+
return stripped
|
|
251
|
+
try:
|
|
252
|
+
return int(stripped)
|
|
253
|
+
except ValueError:
|
|
254
|
+
pass
|
|
255
|
+
try:
|
|
256
|
+
return float(stripped)
|
|
257
|
+
except ValueError:
|
|
258
|
+
return stripped
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _array(
|
|
262
|
+
value: list,
|
|
263
|
+
schema: Mapping[str, Any],
|
|
264
|
+
caption: str,
|
|
265
|
+
key: str,
|
|
266
|
+
help_text: str | None,
|
|
267
|
+
):
|
|
268
|
+
items = schema.get("items") or {}
|
|
269
|
+
if not isinstance(value, list):
|
|
270
|
+
value = [] if value is None else [value]
|
|
271
|
+
|
|
272
|
+
if (
|
|
273
|
+
is_schema(items)
|
|
274
|
+
or "array" in _types(items)
|
|
275
|
+
or any(isinstance(item, (Mapping, list)) for item in value)
|
|
276
|
+
):
|
|
277
|
+
parsed = _raw(value, caption, key, help_text)
|
|
278
|
+
return parsed if isinstance(parsed, list) else value
|
|
279
|
+
|
|
280
|
+
text = "\n".join("" if item is None else str(item) for item in value)
|
|
281
|
+
edited = st.text_area(
|
|
282
|
+
f"{caption} (one value per line)",
|
|
283
|
+
value=text,
|
|
284
|
+
key=key,
|
|
285
|
+
help=help_text,
|
|
286
|
+
height=min(200, 40 + 22 * max(1, len(value))),
|
|
287
|
+
)
|
|
288
|
+
parsed = []
|
|
289
|
+
for line in edited.splitlines():
|
|
290
|
+
if not line.strip():
|
|
291
|
+
continue
|
|
292
|
+
try:
|
|
293
|
+
item = load(line)
|
|
294
|
+
except Exception: # noqa: BLE001 - shown to the user
|
|
295
|
+
st.error(f"Invalid value in {caption}: {line!r}")
|
|
296
|
+
return value
|
|
297
|
+
parsed.append(line.strip() if isinstance(item, (dict, list)) else item)
|
|
298
|
+
return parsed
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _raw(value, caption: str, key: str, help_text: str | None):
|
|
302
|
+
edited = st.text_area(
|
|
303
|
+
f"{caption} (YAML)",
|
|
304
|
+
value=dump_yaml(value),
|
|
305
|
+
key=key,
|
|
306
|
+
help=help_text,
|
|
307
|
+
height=160,
|
|
308
|
+
)
|
|
309
|
+
errors = []
|
|
310
|
+
parsed = load(edited, errors)
|
|
311
|
+
if errors:
|
|
312
|
+
st.error(errors[0])
|
|
313
|
+
return value
|
|
314
|
+
return parsed
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _types(schema: Mapping[str, Any]) -> list[str]:
|
|
318
|
+
declared = schema.get("type")
|
|
319
|
+
if isinstance(declared, str):
|
|
320
|
+
return [declared]
|
|
321
|
+
return list(declared or ())
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _path(parent: str, name: str):
|
|
325
|
+
return f"{parent}.{name}" if parent else name
|
snakelit/editor/table.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Editable Polars-backed table widgets.
|
|
2
|
+
|
|
3
|
+
``form_table`` seeds ``st.data_editor`` from the frame it is given; the
|
|
4
|
+
editor's widget state carries the edits, and the caller decides where the
|
|
5
|
+
returned frame goes. ``column_controls`` renders icon add/rename/remove/move
|
|
6
|
+
popovers that replace the frame before it is edited. ``schema_table`` adds
|
|
7
|
+
optional row-schema coercion and validation on top of a bytes value.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, Mapping
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import polars as pl
|
|
16
|
+
import streamlit as st
|
|
17
|
+
|
|
18
|
+
from .table_schema import coerce_frame, table_errors
|
|
19
|
+
from .text import schema_text_editor
|
|
20
|
+
from ..utils.table import dump, load
|
|
21
|
+
from ..utils.st_layouts import st_container_maxheight
|
|
22
|
+
|
|
23
|
+
Control = Callable[[str, pl.DataFrame], tuple[pl.DataFrame, Any] | None]
|
|
24
|
+
"""A control returns ``(frame, payload)`` when it fired, else ``None``."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def schema_table(
|
|
28
|
+
data: bytes,
|
|
29
|
+
errors: list[str],
|
|
30
|
+
schema: Mapping[str, Any] | None = None,
|
|
31
|
+
header: str = "Table Editor",
|
|
32
|
+
*,
|
|
33
|
+
key: str = "table",
|
|
34
|
+
format: str = "csv",
|
|
35
|
+
):
|
|
36
|
+
"""Render an editor for a table and return the new bytes.
|
|
37
|
+
|
|
38
|
+
``format`` is the table's file suffix (``csv``, ``tsv``, ``parquet`` or
|
|
39
|
+
``xlsx``). Text formats offer a Form/Text switch, binary formats only the
|
|
40
|
+
form. ``errors`` is filled in place. Returns ``None`` when the data did
|
|
41
|
+
not change or is invalid, so the caller can skip writing it.
|
|
42
|
+
|
|
43
|
+
With ``schema``, the form coerces columns to its declared dtypes and
|
|
44
|
+
validates every row; the schema is never edited.
|
|
45
|
+
"""
|
|
46
|
+
height = 420
|
|
47
|
+
# A file that cannot be read is not an error yet: the text editor takes
|
|
48
|
+
# over below, so this probe keeps its failures to itself.
|
|
49
|
+
table = load(data, suffix=format, errors=[])
|
|
50
|
+
textable = format in ("csv", "tsv")
|
|
51
|
+
if table is not None:
|
|
52
|
+
if (
|
|
53
|
+
not textable
|
|
54
|
+
or st.radio(
|
|
55
|
+
header,
|
|
56
|
+
["Form", "Text Editor"],
|
|
57
|
+
help="Table editor mode",
|
|
58
|
+
horizontal=True,
|
|
59
|
+
key=f"{key}-editor-mode",
|
|
60
|
+
)
|
|
61
|
+
== "Form"
|
|
62
|
+
):
|
|
63
|
+
before = len(errors)
|
|
64
|
+
with st_container_maxheight(f"{key}-form-container", height=height):
|
|
65
|
+
edited = form_table(table, errors, schema, key=key)
|
|
66
|
+
if len(errors) > before:
|
|
67
|
+
return None
|
|
68
|
+
# Compare canonical bytes: coercion changes dtypes without changing
|
|
69
|
+
# the values, and an untouched form must not rewrite the table.
|
|
70
|
+
updated = dump(edited, format)
|
|
71
|
+
return None if updated == dump(table, format) else updated
|
|
72
|
+
elif not textable:
|
|
73
|
+
st.error(f"Cannot edit broken {format.upper()} table as text.")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
text = data.decode("utf-8")
|
|
78
|
+
except UnicodeDecodeError:
|
|
79
|
+
st.error(f"Cannot edit {format.upper()} data as text.")
|
|
80
|
+
return None
|
|
81
|
+
updated = schema_text_editor(text, schema, key, height=height)
|
|
82
|
+
before = len(errors)
|
|
83
|
+
parsed = load(updated.encode("utf-8"), suffix=format, errors=errors)
|
|
84
|
+
if parsed is None:
|
|
85
|
+
return None
|
|
86
|
+
if schema:
|
|
87
|
+
parsed, _ = coerce_frame(parsed, schema, errors)
|
|
88
|
+
errors.extend(table_errors(parsed, schema))
|
|
89
|
+
if len(errors) > before:
|
|
90
|
+
return None
|
|
91
|
+
return None if updated == text else updated.encode("utf-8")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def form_table(
|
|
95
|
+
frame: pl.DataFrame,
|
|
96
|
+
errors: list[str],
|
|
97
|
+
schema: Mapping | None = None,
|
|
98
|
+
*,
|
|
99
|
+
key: str = "table",
|
|
100
|
+
column_config: Mapping | None = None,
|
|
101
|
+
):
|
|
102
|
+
"""Edit ``frame`` and return the edited frame.
|
|
103
|
+
|
|
104
|
+
With ``schema``, cells carry its declared dtypes and every row is validated;
|
|
105
|
+
the schema itself is never edited.
|
|
106
|
+
"""
|
|
107
|
+
data_key = f"{key}-data"
|
|
108
|
+
if schema:
|
|
109
|
+
# input frame is loaded without schema (default strings for csv/tsv),
|
|
110
|
+
# so coerce it to the schema before comparing.
|
|
111
|
+
frame, _ = coerce_frame(frame, schema, errors)
|
|
112
|
+
|
|
113
|
+
current: pl.DataFrame | None = st.session_state.get(data_key)
|
|
114
|
+
reset = current is None or not current.equals(frame)
|
|
115
|
+
if reset:
|
|
116
|
+
# The host's frame is the truth: refresh the copy whenever it changed,
|
|
117
|
+
# so an unmounted editor or a document changed elsewhere cannot leave
|
|
118
|
+
# stale rows behind.
|
|
119
|
+
st.session_state[data_key] = frame
|
|
120
|
+
change = column_controls(key)
|
|
121
|
+
edited = editable_table(
|
|
122
|
+
key,
|
|
123
|
+
column_config=column_config,
|
|
124
|
+
reset=reset or bool(change),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if schema:
|
|
128
|
+
# strip and format the edited frame to save correctly.
|
|
129
|
+
edited, _ = coerce_frame(edited, schema, errors)
|
|
130
|
+
st.session_state[data_key] = edited
|
|
131
|
+
errors.extend(table_errors(edited, schema))
|
|
132
|
+
return edited
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def column_controls(
|
|
136
|
+
key: str,
|
|
137
|
+
*,
|
|
138
|
+
controls: Mapping[str, Control] | None = None,
|
|
139
|
+
extras: Mapping[str, Control] | None = None,
|
|
140
|
+
):
|
|
141
|
+
"""Render column popovers for ``st.session_state[f"{key}-data"]``.
|
|
142
|
+
|
|
143
|
+
Returns the changes applied, keyed by control name. ``controls`` replaces
|
|
144
|
+
the default add/rename/remove/move set; ``extras`` renders more next to it.
|
|
145
|
+
"""
|
|
146
|
+
changes: dict[str, Any] = {}
|
|
147
|
+
calls = dict(controls or _default_controls) | dict(extras or {})
|
|
148
|
+
holders = st.columns(len(calls))
|
|
149
|
+
for holder, call in zip(holders, calls):
|
|
150
|
+
with holder:
|
|
151
|
+
ret = calls[call](key, st.session_state[f"{key}-data"])
|
|
152
|
+
if ret is not None:
|
|
153
|
+
st.session_state[f"{key}-data"], changes[call] = ret
|
|
154
|
+
return changes
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def editable_table(
|
|
158
|
+
key: str,
|
|
159
|
+
*,
|
|
160
|
+
column_config: Mapping | None = None,
|
|
161
|
+
drop_empty: bool = True,
|
|
162
|
+
reset: bool = False,
|
|
163
|
+
) -> pl.DataFrame:
|
|
164
|
+
"""Render ``st.data_editor`` for ``st.session_state[f"{key}-data"]``.
|
|
165
|
+
|
|
166
|
+
The current data lives in ``{key}-data``; the edited result is written back
|
|
167
|
+
there and returned. Edits accumulate against a separate stable base
|
|
168
|
+
(``{key}-base``) rather than re-feeding ``{key}-data`` as the editor input,
|
|
169
|
+
which would make edits lag by one run. Pass ``reset=True`` when the table
|
|
170
|
+
is replaced programmatically (column change, upload, fill, clear) so the
|
|
171
|
+
base is re-seeded and the widget rebuilt.
|
|
172
|
+
"""
|
|
173
|
+
data_key = f"{key}-data"
|
|
174
|
+
base_key = f"{key}-base"
|
|
175
|
+
revision_key = f"{key}-revision"
|
|
176
|
+
if reset or base_key not in st.session_state:
|
|
177
|
+
st.session_state[base_key] = st.session_state[data_key]
|
|
178
|
+
st.session_state[revision_key] = st.session_state.get(revision_key, 0) + 1
|
|
179
|
+
edited = st.data_editor(
|
|
180
|
+
st.session_state[base_key].to_pandas(),
|
|
181
|
+
num_rows="dynamic",
|
|
182
|
+
width="stretch",
|
|
183
|
+
hide_index=True,
|
|
184
|
+
key=f"{key}-{st.session_state[revision_key]}",
|
|
185
|
+
column_config=column_config,
|
|
186
|
+
)
|
|
187
|
+
result = _from_pandas(edited)
|
|
188
|
+
if drop_empty:
|
|
189
|
+
result = _drop_empty_rows(result)
|
|
190
|
+
st.session_state[data_key] = result
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _from_pandas(frame):
|
|
195
|
+
"""Rebuild a Polars frame from the data editor's pandas frame.
|
|
196
|
+
|
|
197
|
+
Polars' own pandas bridge creates string columns that later panic with
|
|
198
|
+
``capacity overflow`` when converted back to pandas under Pyodide
|
|
199
|
+
(polars 1.33). Going through plain Python values avoids that path.
|
|
200
|
+
"""
|
|
201
|
+
if sys.platform != "emscripten":
|
|
202
|
+
return pl.from_pandas(frame)
|
|
203
|
+
|
|
204
|
+
import pandas as pd
|
|
205
|
+
|
|
206
|
+
def cell(value):
|
|
207
|
+
if value is None or value is pd.NA or value is pd.NaT:
|
|
208
|
+
return None
|
|
209
|
+
if isinstance(value, float) and value != value:
|
|
210
|
+
return None
|
|
211
|
+
return value
|
|
212
|
+
|
|
213
|
+
data = {
|
|
214
|
+
name: [cell(value) for value in values]
|
|
215
|
+
for name, values in frame.to_dict(orient="list").items()
|
|
216
|
+
}
|
|
217
|
+
overrides: dict[str, type[pl.DataType]] = {}
|
|
218
|
+
for name, dtype in frame.dtypes.items():
|
|
219
|
+
if pd.api.types.is_bool_dtype(dtype):
|
|
220
|
+
overrides[name] = pl.Boolean
|
|
221
|
+
elif pd.api.types.is_unsigned_integer_dtype(dtype):
|
|
222
|
+
overrides[name] = pl.UInt64
|
|
223
|
+
elif pd.api.types.is_integer_dtype(dtype):
|
|
224
|
+
overrides[name] = pl.Int64
|
|
225
|
+
elif pd.api.types.is_float_dtype(dtype):
|
|
226
|
+
overrides[name] = pl.Float64
|
|
227
|
+
elif not pd.api.types.is_object_dtype(dtype) and pd.api.types.is_string_dtype(
|
|
228
|
+
dtype
|
|
229
|
+
):
|
|
230
|
+
overrides[name] = pl.Utf8
|
|
231
|
+
elif pd.api.types.is_object_dtype(dtype) and not any(
|
|
232
|
+
value is not None for value in data[name]
|
|
233
|
+
):
|
|
234
|
+
overrides[name] = pl.Utf8
|
|
235
|
+
return pl.DataFrame(data, schema_overrides=overrides)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _drop_empty_rows(frame: pl.DataFrame):
|
|
239
|
+
"""Drop rows where every cell is null or blank.
|
|
240
|
+
|
|
241
|
+
The mask is applied while rebuilding the frame from Python values instead
|
|
242
|
+
of ``frame.filter``: Polars 1.33 under Pyodide corrupts string columns when
|
|
243
|
+
a filtered frame is converted back to pandas (``capacity overflow``).
|
|
244
|
+
"""
|
|
245
|
+
if frame.height == 0 or not frame.columns:
|
|
246
|
+
return frame
|
|
247
|
+
keep = [
|
|
248
|
+
any(
|
|
249
|
+
value is not None and (not isinstance(value, str) or value.strip() != "")
|
|
250
|
+
for value in row
|
|
251
|
+
)
|
|
252
|
+
for row in frame.iter_rows()
|
|
253
|
+
]
|
|
254
|
+
if all(keep):
|
|
255
|
+
return frame
|
|
256
|
+
data = {
|
|
257
|
+
name: [value for value, wanted in zip(frame[name].to_list(), keep) if wanted]
|
|
258
|
+
for name in frame.columns
|
|
259
|
+
}
|
|
260
|
+
return pl.DataFrame(data, schema=frame.schema)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _add_column(key: str, frame: pl.DataFrame):
|
|
264
|
+
options = list(frame.columns)
|
|
265
|
+
with st.popover("", icon=":material/add:", help="Add a column"):
|
|
266
|
+
name = st.text_input("Add column", key=f"{key}-new-column")
|
|
267
|
+
clicked = st.button("Add", key=f"{key}-add-column")
|
|
268
|
+
name = (name or "").strip()
|
|
269
|
+
if clicked and name and name not in options:
|
|
270
|
+
return frame.with_columns(pl.Series(name, [""] * frame.height)), name
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _rename_column(key: str, frame: pl.DataFrame):
|
|
274
|
+
options = list(frame.columns)
|
|
275
|
+
with st.popover("", icon=":material/edit:", help="Rename a column"):
|
|
276
|
+
if options:
|
|
277
|
+
selected = st.selectbox(
|
|
278
|
+
"Select column to rename", options, key=f"{key}-rename-column"
|
|
279
|
+
)
|
|
280
|
+
renamed = st.text_input("Rename to", key=f"{key}-rename-column-text")
|
|
281
|
+
clicked = st.button("Rename", key=f"{key}-rename-column-button")
|
|
282
|
+
else:
|
|
283
|
+
st.caption("No columns to rename.")
|
|
284
|
+
selected, renamed, clicked = None, "", False
|
|
285
|
+
new_name = (renamed or "").strip()
|
|
286
|
+
if clicked and new_name and new_name not in options and selected in frame.columns:
|
|
287
|
+
return frame.rename({selected: new_name}), (selected, new_name)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _delete_column(key: str, frame: pl.DataFrame):
|
|
291
|
+
options = list(frame.columns)
|
|
292
|
+
with st.popover("", icon=":material/remove:", help="Remove a column"):
|
|
293
|
+
if options:
|
|
294
|
+
selected = st.selectbox(
|
|
295
|
+
"Select column to remove", options, key=f"{key}-remove-column"
|
|
296
|
+
)
|
|
297
|
+
clicked = st.button("Remove", key=f"{key}-remove-column-button")
|
|
298
|
+
else:
|
|
299
|
+
st.caption("No columns to remove.")
|
|
300
|
+
selected, clicked = None, False
|
|
301
|
+
if clicked and selected in frame.columns:
|
|
302
|
+
return frame.drop(selected), selected
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _move_column(key: str, frame: pl.DataFrame):
|
|
306
|
+
options = list(frame.columns)
|
|
307
|
+
with st.popover("", icon=":material/swap_horiz:", help="Reorder columns"):
|
|
308
|
+
if len(options) > 1:
|
|
309
|
+
selected = st.selectbox("Move column", options, key=f"{key}-move-column")
|
|
310
|
+
target = st.selectbox(
|
|
311
|
+
"Move before", ["(end)", *options], key=f"{key}-move-target"
|
|
312
|
+
)
|
|
313
|
+
clicked = st.button("Move", key=f"{key}-move-button")
|
|
314
|
+
else:
|
|
315
|
+
st.caption("Need at least two columns to reorder.")
|
|
316
|
+
return None
|
|
317
|
+
if not clicked or selected not in options:
|
|
318
|
+
return None
|
|
319
|
+
order = [column for column in options if column != selected]
|
|
320
|
+
index = len(order) if target == "(end)" else order.index(target)
|
|
321
|
+
order.insert(index, selected)
|
|
322
|
+
if order == options:
|
|
323
|
+
return None
|
|
324
|
+
return frame.select(order), order
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_default_controls = {
|
|
328
|
+
"add": _add_column,
|
|
329
|
+
"rename": _rename_column,
|
|
330
|
+
"remove": _delete_column,
|
|
331
|
+
"move": _move_column,
|
|
332
|
+
}
|