Skip to content

Commit c26b5e5

Browse files
Merge pull request #378 from maxfordham/366-edit-grid-file
Added crud operations to edit_tsv
2 parents 72c1ba7 + b9e7b8c commit c26b5e5

15 files changed

Lines changed: 1141 additions & 542 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ target/
7676
/src/ipyautoui/.ipynb_checkpoints
7777
/src/ipyautoui/_version.py
7878
/src/ipyautoui/demo_schemas/.ipynb_checkpoints/
79+
/src/ipyautoui/data/.ipynb_checkpoints
7980
/src/ipyautoui/demo_schemas/.__pycache__/
8081
/tests/.ipynb_checkpoints
8182
/tests/__pycache__

docs/EditableGrid.csv

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
string,integer,floater,something_else
2+
how long,1,3.14,324
3+
how long,2,3.4,123

docs/EditableGridTransposed.csv

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
string,not long,veryyyyy looooooooooooooooongggg
2+
integer,1,2
3+
floater,3,4
4+
something_else,5,6

docs/demo.ipynb

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,17 @@
3333
"metadata": {},
3434
"outputs": [
3535
{
36-
"data": {
37-
"application/vnd.jupyter.widget-view+json": {
38-
"model_id": "ae97585320b443818051764b74cea37c",
39-
"version_major": 2,
40-
"version_minor": 0
41-
},
42-
"text/plain": [
43-
"DemoReel(children=(VBox(children=(HTML(value='⬇️ -- <b>Select demo pydantic model / generated AutoUi</b> -- ⬇️…"
44-
]
45-
},
46-
"metadata": {},
47-
"output_type": "display_data"
36+
"ename": "SyntaxError",
37+
"evalue": "expected ':' (editgrid.py, line 267)",
38+
"output_type": "error",
39+
"traceback": [
40+
"Traceback \u001b[36m(most recent call last)\u001b[39m:\n",
41+
" File \u001b[92m~/ipyautoui/.pixi/envs/dev/lib/python3.12/site-packages/IPython/core/interactiveshell.py:3699\u001b[39m in \u001b[95mrun_code\u001b[39m\n exec(code_obj, self.user_global_ns, self.user_ns)\n",
42+
" Cell \u001b[92mIn[1]\u001b[39m\u001b[92m, line 1\u001b[39m\n from ipyautoui import demo\n",
43+
" File \u001b[92m~/ipyautoui/src/ipyautoui/__init__.py:22\u001b[39m\n from ipyautoui.autoui import AutoUi\n",
44+
"\u001b[36m \u001b[39m\u001b[36mFile \u001b[39m\u001b[32m~/ipyautoui/src/ipyautoui/autoui.py:33\u001b[39m\n\u001b[31m \u001b[39m\u001b[31mfrom ipyautoui.custom.editgrid import EditGrid\u001b[39m\n",
45+
" \u001b[36mFile \u001b[39m\u001b[32m~/ipyautoui/src/ipyautoui/custom/editgrid.py:267\u001b[39m\n\u001b[31m \u001b[39m\u001b[31mif self.ui_\u001b[39m\n ^\n\u001b[31mSyntaxError\u001b[39m\u001b[31m:\u001b[39m expected ':'\n"
46+
]
4847
}
4948
],
5049
"source": [

pixi.lock

Lines changed: 422 additions & 412 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ipyautoui/custom/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from ipyautoui.custom.fileupload import FileUploadToDir, FilesUploadToDir
77
from ipyautoui.custom.executetasks import ExecuteTasks, SelectAndExecute
88
from ipyautoui.custom.svgspinner import SvgSpinner
9-
from ipyautoui.custom.filedownload import FileDownload, FilesDownload, SelectAndDownload
9+
from ipyautoui.custom.filedownload import FileDownload, FilesDownload, SelectAndDownload, MakeFileAndDownload
1010
from ipyautoui.custom.maplist import MapList
1111
from ipyautoui.custom.jsonable_dict import JsonableDict
1212

@@ -23,6 +23,7 @@
2323
"SelectAndExecute",
2424
"SvgSpinner",
2525
"FileDownload",
26+
"MakeFileAndDownload",
2627
"FilesDownload",
2728
"SelectAndDownload",
2829
"MapList",

src/ipyautoui/custom/editgrid.py

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ipyautoui.constants import BUTTON_WIDTH_MIN
2121
from ipyautoui.custom.autogrid import AutoGrid
2222
from ipyautoui.custom.title_description import TitleDescription
23+
from ipyautoui.custom.edittsv import Changes
2324

2425
MAP_TRANSPOSED_SELECTION_MODE = frozenmap({True: "column", False: "row"})
2526
logger = logging.getLogger(__name__)
@@ -43,11 +44,11 @@ class DataHandler(BaseModel):
4344

4445
# REVIEW... MAYBE SHOULD USE *ARGS AND **KWARGS
4546
fn_get_all_data: ty.Callable # TODO: rename to fn_get
46-
fn_post: ty.Callable[[dict], None]
47+
fn_post: ty.Callable[[dict], None] # should return int
4748
fn_patch: ty.Callable[[ty.Any, dict], None] # TODO: need to add index
4849
fn_delete: ty.Callable[[list[int]], None]
4950
fn_copy: ty.Callable[[list[int]], None]
50-
fn_io: ty.Callable
51+
fn_io: ty.Callable # TOOD: rename -> fn_dump
5152

5253

5354
if __name__ == "__main__":
@@ -65,15 +66,6 @@ class DataHandler(BaseModel):
6566
ui.value = {"string": "adfs", "integer": 2, "floater": 1.22}
6667

6768

68-
# +
69-
# class RowEditor:
70-
# fn_add: ty.List[ty.Callable[[ty.Any, dict], None]] # post
71-
# fn_edit: ty.List[ty.Callable[[ty.Any, dict], None]] # patch
72-
# fn_move: ty.Callable
73-
# fn_copy: ty.Callable
74-
# fn_delete: ty.Callable
75-
76-
# +
7769
class UiDelete(w.VBox):
7870
value = tr.Dict(default_value={})
7971
columns = tr.List(default_value=[])
@@ -238,6 +230,7 @@ class EditGrid(w.VBox, TitleDescription):
238230
warn_on_delete = tr.Bool()
239231
show_copy_dialogue = tr.Bool()
240232
close_crud_dialogue_on_action = tr.Bool()
233+
allow_download = tr.Bool(default_value=True)
241234

242235
@tr.observe("warn_on_delete")
243236
def observe_warn_on_delete(self, on_change):
@@ -253,6 +246,13 @@ def observe_show_copy_dialogue(self, on_change):
253246
else:
254247
self.ui_copy.layout.display = "None"
255248

249+
@tr.observe("allow_download")
250+
def show_hide_download_bn_edittsv(self, on_change):
251+
if "allow_download" in self.ui_io.traits():
252+
self.ui_io.allow_download = self.allow_download
253+
else:
254+
logger.warning("allow_download not found in ui_io")
255+
256256
@property
257257
def json(self): # HOTFIX: not required if WatchValidate is used
258258
return json.dumps(self.value, indent=4)
@@ -264,6 +264,10 @@ def transposed(self):
264264
@transposed.setter
265265
def transposed(self, value: bool):
266266
self.grid.transposed = value
267+
if "transposed" in self.ui_io.traits():
268+
self.ui_io.transposed = value
269+
else:
270+
logger.warning("transposed not found in ui_io")
267271

268272
@property
269273
def value(self):
@@ -413,7 +417,7 @@ def _init_ui_callables(
413417
self.ui_copy = ui_copy()
414418
if ui_io is None:
415419
if self.model is not None: # is BaseModel
416-
self.ui_io = EditTsvWithDiff(model=self.model, fn_upload=self.set_value_from_tsv)
420+
self.ui_io = EditTsvWithDiff(model=self.model, fn_upload=self.set_value_from_tsv, transposed=self.transposed, allow_download=self.allow_download)
417421
else:
418422
self.ui_io = w.HTML("must instantiate with pydantic model for this feature")
419423
else:
@@ -746,14 +750,6 @@ class TestDataFrame(RootModel):
746750
editgrid.observe(lambda c: print("_value changed"), "_value")
747751
display(editgrid)
748752

749-
# +
750-
#
751-
# [x.__name__ for x in editgrid.stk_crud.children]
752-
753-
# +
754-
# editgrid.stk_crud.children[0].type
755-
# -
756-
757753
if __name__ == "__main__":
758754

759755
class DataFrameCols(BaseModel):
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
from ipyautoui.custom.editgrid import (
2+
EditGrid,
3+
DataHandler,
4+
)
5+
from ipyautoui.custom.edittsv import Changes
6+
import pathlib
7+
import traitlets as tr
8+
import json
9+
from pydantic import BaseModel, RootModel, Field
10+
import typing as ty
11+
import ipywidgets as w
12+
import random
13+
import functools
14+
15+
16+
# +
17+
# --- LOAD & SAVE HELPERS ---
18+
def load_json(fpth: pathlib.Path = pathlib.Path("text.json")) -> list[dict]:
19+
"""Load JSON list data safely."""
20+
with fpth.open("r", encoding="utf-8") as f:
21+
data = json.load(f)
22+
if not isinstance(data, list):
23+
raise ValueError("Expected list of dicts in JSON file.")
24+
return data
25+
26+
27+
def save_json(data: list[dict], fpth: pathlib.Path) -> pathlib.Path:
28+
"""Save JSON list data safely."""
29+
with fpth.open("w", encoding="utf-8") as f:
30+
json.dump(data, f, indent=4)
31+
return fpth
32+
33+
34+
# --- DELETE ---
35+
def delete_rows(primary_keys_list: list[str | int], primary_key_name, fpth: pathlib.Path) -> list[dict]:
36+
"""Delete rows from JSON based on keys in 'primary_keys' (list of strings or ints)."""
37+
data = load_json(fpth)
38+
39+
# Normalize all keys to strings for consistent comparison
40+
delete_keys = [str(k) for k in primary_keys_list]
41+
42+
# Keep only rows whose 'primary_key_name' is NOT in delete_ids
43+
updated_data = [row for row in data if str(row.get(primary_key_name)) not in delete_keys]
44+
45+
save_json(updated_data, fpth)
46+
print(f"🗑️ Deleted rows with IDs: {delete_keys}")
47+
return updated_data
48+
49+
50+
# --- ADD ---
51+
def add_rows(additions: list[dict], primary_key_name = "id", fpth: pathlib.Path = pathlib.Path("text.json")) -> list[dict]:
52+
"""Add new rows from 'insert' (dict-of-dicts form)."""
53+
data = load_json(fpth)
54+
maxId = max(data, key=lambda x:x[primary_key_name])[primary_key_name]
55+
56+
for addition in additions:
57+
new_addition = {}
58+
new_addition[primary_key_name] = maxId + 1
59+
addition.pop(primary_key_name, None)
60+
new_addition.update(addition)
61+
data.append(new_addition)
62+
63+
save_json(data, fpth)
64+
print(f"➕ Added {len(additions)} new row(s)")
65+
return data
66+
67+
68+
# --- EDIT ---
69+
def edit_rows(edits: dict[str | int, dict], primary_key_name, fpth: pathlib.Path) -> list[dict]:
70+
"""Update existing rows based on 'update' section."""
71+
data = load_json(fpth)
72+
73+
for key, update_fields_dict in edits.items():
74+
for record in data:
75+
if str(record.get(primary_key_name)) == str(key):
76+
record.update(update_fields_dict)
77+
78+
save_json(data, fpth)
79+
print(f"✏️ Updated {len(edits)} row(s)")
80+
return data
81+
82+
83+
# --- COMBINED HANDLER ---
84+
def handle_crud(primary_key_name, changes: Changes, fpth: pathlib.Path):
85+
"""Apply delete → insert → update in order."""
86+
if changes.deletions:
87+
delete_rows(changes.deletions, primary_key_name, fpth)
88+
if changes.additions:
89+
add_rows(changes.additions, primary_key_name, fpth)
90+
if changes.edits:
91+
edit_rows(changes.edits, primary_key_name, fpth)
92+
# --- COMBINED HANDLER ---
93+
94+
def delete_row(value: dict, primary_key_name = "id", fpth: pathlib.Path = pathlib.Path("text.json")) -> list[dict]:
95+
data = load_json(fpth)
96+
deleted_id = value[primary_key_name]
97+
# Keep only rows whose 'primary_key_name' is NOT delete_id
98+
updated_data = [row for row in data if str(row.get(primary_key_name)) != str(deleted_id)]
99+
save_json(updated_data, fpth)
100+
return updated_data
101+
102+
def edit_row(value: dict, primary_key_name = "id", fpth: pathlib.Path = pathlib.Path("text.json")) -> list[dict]:
103+
data = load_json(fpth)
104+
row_id = value[primary_key_name]
105+
106+
# Update matching record
107+
for record in data:
108+
if str(record.get(primary_key_name)) == str(row_id):
109+
# update all keys except 'primary_key_name'
110+
record.update({k: v for k, v in value.items() if k != primary_key_name})
111+
break
112+
113+
save_json(data, fpth)
114+
return data
115+
116+
def add_row(addition: dict, primary_key_name = "id", fpth: pathlib.Path = pathlib.Path("text.json")) -> list[dict]:
117+
data = load_json(fpth)
118+
maxId = max(data, key=lambda x:x[primary_key_name])[primary_key_name]
119+
new_addition = {}
120+
new_addition[primary_key_name] = maxId + 1
121+
addition.pop(primary_key_name, None)
122+
new_addition.update(addition)
123+
data.append(new_addition)
124+
save_json(data, fpth)
125+
print(f"➕ Added new row")
126+
return data
127+
128+
class EditGridFile(EditGrid):
129+
primary_key_name = tr.Unicode(default_value="id")
130+
fpth = tr.Instance(klass=pathlib.Path, allow_none=False)
131+
132+
@tr.observe("fpth")
133+
def update_handler(self, change):
134+
self.datahandler.fn_get_all_data = functools.partial(load_json, fpth=self.fpth)
135+
self.datahandler.fn_post = functools.partial(add_row, primary_key_name=self.primary_key_name, fpth=self.fpth)
136+
self.datahandler.fn_patch = functools.partial(edit_row, primary_key_name=self.primary_key_name, fpth=self.fpth)
137+
self.datahandler.fn_delete = functools.partial(delete_row, primary_key_name=self.primary_key_name, fpth=self.fpth)
138+
self.datahandler.fn_copy = functools.partial(add_row, primary_key_name=self.primary_key_name, fpth=self.fpth)
139+
self.datahandler.fn_io = functools.partial(self.handle_crud)
140+
141+
self._set_datahandler(self.datahandler)
142+
143+
def __init__(
144+
self,
145+
**kwargs,
146+
):
147+
148+
datahandler = DataHandler(
149+
fn_get_all_data=lambda v: print(v),
150+
fn_post=lambda v: print(v),
151+
fn_patch=lambda v: v,
152+
fn_delete=lambda v: print(v),
153+
fn_copy=lambda v: print(v),
154+
fn_io = lambda v: print("io")
155+
)
156+
super().__init__(
157+
datahandler=datahandler,
158+
warn_on_delete=True,
159+
layout=w.Layout(height="800px"),
160+
**kwargs,
161+
)
162+
163+
if hasattr(self, "ui_io") and hasattr(self.ui_io, "primary_key_name"):
164+
self.ui_io.primary_key_name = self.primary_key_name
165+
166+
self.update_handler("")
167+
168+
def set_value_from_tsv(self, value):
169+
self.value=value
170+
if self.ui_io is not None:
171+
self.datahandler.fn_io(self.ui_io.changes)
172+
173+
# --- HANDLERS ---
174+
def handle_crud(self, changes: Changes):
175+
handle_crud(self.ui_io.primary_key_name, changes, self.fpth)
176+
177+
178+
if __name__ == "__main__":
179+
json_path = pathlib.Path("../../..") / "tests" / "test_data" / "edit-grid-file-data.json"
180+
with json_path.open('r', encoding='utf-8') as file:
181+
data = json.load(file)
182+
183+
# Test: EditGrid instance with multi-indexing.
184+
AUTO_GRID_DEFAULT_VALUE = data
185+
186+
class DataFrameCols(BaseModel):
187+
id: int = Field(1, json_schema_extra=dict(column_width=80, section="a"))
188+
string: str = Field(
189+
"string", json_schema_extra=dict(column_width=400, section="a")
190+
)
191+
integer: int = Field(1, json_schema_extra=dict(column_width=80, section="a"))
192+
floater: float = Field(
193+
None, json_schema_extra=dict(column_width=70, section="b")
194+
)
195+
196+
class TestDataFrame(RootModel):
197+
"""a description of TestDataFrame"""
198+
199+
root: ty.List[DataFrameCols] = Field(
200+
default=AUTO_GRID_DEFAULT_VALUE,
201+
json_schema_extra=dict(
202+
format="dataframe", datagrid_index_name=("section", "title")
203+
),
204+
)
205+
206+
title = "Testing Crud Info Grid"
207+
description = "Useful for all editing purposes whatever they may be 👍"
208+
edit_grid_file = EditGridFile(
209+
schema=TestDataFrame,
210+
title=title,
211+
description=description,
212+
ui_add=None,
213+
ui_edit=None,
214+
show_copy_dialogue=False,
215+
close_crud_dialogue_on_action=False,
216+
global_decimal_places=1,
217+
column_width={"String": 400},
218+
fpth=json_path
219+
)
220+
display(edit_grid_file)
221+
222+
223+
224+

0 commit comments

Comments
 (0)