Skip to content

Commit 28bb151

Browse files
stonebigclaude
andcommitted
json import/export (gui + batch) and recent-databases menu
The file extension decides the format everywhere: - import button and .import accept .json/.jsonl: records land in a 1-column raw table and a ready-made shredding query tab opens (json_extract per key of the first record; DuckDB engine uses native read_json_auto instead) - export_writer emits json when the target ends in .json (array) or .jsonl (one object per line), so the export dialog and .once/.output batch commands gain json for free - Database > Open Recent lists the ten most recently opened databases - new code stays python-3.4 compatible (no f-strings; the two of the dpi commit converted back) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8f8cba5 commit 28bb151

3 files changed

Lines changed: 258 additions & 6 deletions

File tree

HISTORY.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ Changelog
44
(unreleased) : v1.1.1
55
---------------------
66

7+
* json : the import button also accepts .json / .jsonl files : records land in a 1-column raw table, and a ready-made 'shredding' query tab opens (DuckDB engine : native read_json_auto instead)
8+
9+
* json : exports write json when the file name ends in .json (array) or .jsonl (one object per line) : same buttons, same .once / .output batch commands, the file extension decides the format
10+
11+
* json : .import file.json [table] works in scripts too (batch mode with -sc)
12+
13+
* menu : Database > Open Recent : the ten most recently opened databases
14+
715
* high-dpi : the app declares dpi-awareness on Windows : no more blurry rendering on scaled screens (125%/150%/200%)
816

917
* high-dpi : icons, Treeview row heights, pane widths and the minimal window size follow the screen scale ; other OSes unchanged (scale never goes below 1)

sqlite_bro/sqlite_bro.py

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import csv
1919
import datetime
2020
import io
21+
import json
2122
import re
2223
import shlex # Simple lexical analysis
2324
from os.path import expanduser
@@ -145,8 +146,11 @@ def __init__(self, use_gui=True):
145146
# bitmap-enlarged by Windows, and the natural (content) size
146147
# would not fit the screen anyway
147148
self.tk_win.geometry(
148-
f"{self.tk_win.winfo_screenwidth() * 4 // 5}"
149-
f"x{self.tk_win.winfo_screenheight() * 4 // 5}"
149+
"%sx%s"
150+
% (
151+
self.tk_win.winfo_screenwidth() * 4 // 5,
152+
self.tk_win.winfo_screenheight() * 4 // 5,
153+
)
150154
)
151155

152156
# start from the real default font size (9 on Windows), so the
@@ -251,6 +255,8 @@ def create_menu(self):
251255
command=lambda: self.new_db(":memory:", engine="duckdb"),
252256
)
253257
self.menu.add_command(label="Open Database ...", command=self.open_db)
258+
self.menu_recent = Menu(self.menu, postcommand=self.refresh_recent_menu)
259+
self.menu.add_cascade(menu=self.menu_recent, label="Open Recent")
254260
self.menu.add_command(
255261
label="Open Database ...(legacy auto-commit)",
256262
command=lambda: self.open_db(""),
@@ -359,6 +365,7 @@ def new_db(self, filename="", engine=None):
359365
):
360366
os.remove(filename)
361367
self.conn = Baresql(self.database_file, engine=engine)
368+
self.add_recent_db(filename)
362369
self.show_duckdb_demo()
363370
self.actualize_db()
364371

@@ -379,9 +386,43 @@ def open_db(self, filename="", isolation_level=None, engine=None):
379386
self.set_initialdir(filename)
380387
self.database_file = filename
381388
self.conn = Baresql(self.database_file, engine=engine)
389+
self.add_recent_db(filename)
382390
self.show_duckdb_demo()
383391
self.actualize_db()
384392

393+
def recent_dbs(self):
394+
"""return the list of recently opened databases"""
395+
try:
396+
with open(
397+
os.path.join(self.home, ".sqlite_bro_recent"), encoding="utf-8"
398+
) as f:
399+
return [line.strip() for line in f if line.strip()]
400+
except (OSError, IOError):
401+
return []
402+
403+
def add_recent_db(self, filename):
404+
"""remember the ten most recently opened databases"""
405+
if filename in ("", ":memory:"):
406+
return
407+
filename = os.path.abspath(filename)
408+
recents = [filename] + [r for r in self.recent_dbs() if r != filename]
409+
try:
410+
with open(
411+
os.path.join(self.home, ".sqlite_bro_recent"), "w", encoding="utf-8"
412+
) as f:
413+
f.write("\n".join(recents[:10]))
414+
except (OSError, IOError):
415+
pass
416+
417+
def refresh_recent_menu(self):
418+
"""(re)feed the 'Open Recent' menu, at click time"""
419+
self.menu_recent.delete(0, "end")
420+
for recent in self.recent_dbs():
421+
if os.path.isfile(recent):
422+
self.menu_recent.add_command(
423+
label=recent, command=lambda r=recent: self.open_db(r)
424+
)
425+
385426
def show_duckdb_demo(self):
386427
"""open the DuckDB welcome demo tab, once, when DuckDB is first used"""
387428
if (
@@ -1360,6 +1401,32 @@ def bip(c):
13601401
csv_file = csv_file.strip('"')
13611402
if (csv_file + "z")[0] == "~":
13621403
csv_file = os.path.join(self.home, csv_file[1:])
1404+
if (
1405+
shell_list[0] == ".import"
1406+
and len(shell_list) >= 2
1407+
and csv_file.lower().endswith((".json", ".jsonl", ".ndjson"))
1408+
): # the file extension decides the format
1409+
table_name = json_table_name(csv_file)
1410+
if len(shell_list) >= 3:
1411+
table_name = shell_list[2]
1412+
records = read_this_json(csv_file)
1413+
self.conn.insert_reader(
1414+
((json.dumps(r, ensure_ascii=False),) for r in records),
1415+
table_name,
1416+
'CREATE TABLE "%s" (json TEXT)' % table_name,
1417+
create_table=False,
1418+
replace=False,
1419+
)
1420+
dot_result = 'file %s imported in "%s"' % (
1421+
csv_file,
1422+
table_name,
1423+
)
1424+
if log is not None: # write to logFile
1425+
log.write(
1426+
'-- File %s imported in "%s"\n'
1427+
% (csv_file, table_name)
1428+
)
1429+
elif shell_list[0] == ".import" and len(shell_list) >= 2:
13631430
guess = guess_csv(csv_file)
13641431
if len(shell_list) >= 3:
13651432
guess.table_name = shell_list[2]
@@ -1613,10 +1680,19 @@ def import_csvtb(self, csv_file=None):
16131680
csv_file = filedialog.askopenfilename(
16141681
initialdir=self.initialdir,
16151682
defaultextension=".db",
1616-
title="Choose a csv fileto import ",
1617-
filetypes=[("default", "*.csv"), ("other", "*.txt"), ("all", "*.*")],
1683+
title="Choose a csv or json file to import ",
1684+
filetypes=[
1685+
("default", "*.csv"),
1686+
("json", "*.json *.jsonl *.ndjson"),
1687+
("other", "*.txt"),
1688+
("all", "*.*"),
1689+
],
16181690
)
1619-
if csv_file != "":
1691+
if csv_file != "" and csv_file.lower().endswith(
1692+
(".json", ".jsonl", ".ndjson")
1693+
): # the file extension decides the format
1694+
self.import_jsontb(csv_file)
1695+
elif csv_file != "":
16201696
self.set_initialdir(csv_file)
16211697
# guess all via an object
16221698
guess = guess_csv(csv_file)
@@ -1658,6 +1734,35 @@ def import_csvtb(self, csv_file=None):
16581734
actions,
16591735
)
16601736

1737+
def import_jsontb(self, json_file):
1738+
"""import a .json (array) or .jsonl file into a 1-column raw table,
1739+
and propose a first 'shredding' query in a new tab"""
1740+
self.set_initialdir(json_file)
1741+
table_name = json_table_name(json_file)
1742+
if self.conn.engine == "duckdb":
1743+
# DuckDB reads (and types) json natively : do it in visible sql
1744+
base = table_name[: -len("_raw")]
1745+
query = (
1746+
'CREATE OR REPLACE TABLE "%s" AS\n'
1747+
"SELECT * FROM read_json_auto('%s');\n"
1748+
'SELECT * FROM "%s" LIMIT 100;'
1749+
) % (base, json_file.replace("'", "''"), base)
1750+
self.n.new_query_tab("import %s" % base, query)
1751+
self.run_tab()
1752+
else:
1753+
records = read_this_json(json_file)
1754+
self.conn.insert_reader(
1755+
((json.dumps(r, ensure_ascii=False),) for r in records),
1756+
table_name,
1757+
'CREATE TABLE "%s" (json TEXT)' % table_name,
1758+
create_table=True,
1759+
replace=True,
1760+
)
1761+
self.n.new_query_tab(
1762+
"shred %s" % table_name, shred_query(table_name, records)
1763+
)
1764+
self.actualize_db()
1765+
16611766
def paste_csvtb(self):
16621767
"""paste clipboard into a table, via the csv import dialog"""
16631768
try:
@@ -2279,6 +2384,38 @@ def read_this_csv(csv_file, encoding, delimiter, quotechar, header, decim):
22792384
yield (row)
22802385

22812386

2387+
def read_this_json(json_file):
2388+
"""return the records of a .json (array or object) or .jsonl file"""
2389+
with open(json_file, encoding="utf-8-sig") as f:
2390+
if json_file.lower().endswith(".json"):
2391+
data = json.load(f)
2392+
return data if isinstance(data, list) else [data]
2393+
return [json.loads(line) for line in f if line.strip()]
2394+
2395+
2396+
def json_table_name(json_file):
2397+
"""a table name from a file name : 'd:/some stuff.json' -> some_stuff_raw"""
2398+
base = os.path.splitext(os.path.basename(json_file))[0]
2399+
return re.sub(r"\W", "_", base) + "_raw"
2400+
2401+
2402+
def shred_query(table_name, records):
2403+
"""sql proposal shredding a raw json table, from the first record keys"""
2404+
first = records[0] if records else None
2405+
if not isinstance(first, dict) or not first:
2406+
return 'SELECT json FROM "%s";' % table_name
2407+
cols = ",\n".join(
2408+
" json_extract(json, '$.%s') AS \"%s\""
2409+
% (k if re.match(r"^\w+$", k) else '"' + k + '"', k.replace('"', '""'))
2410+
for k in first
2411+
)
2412+
return (
2413+
'-- shredding "%s" : keys seen in the first record, edit at will\n'
2414+
'SELECT\n%s\nFROM "%s";\n'
2415+
"-- nested data ? see json_each(json, '$.path') and json_tree\n"
2416+
) % (table_name, cols, table_name)
2417+
2418+
22822419
def copy_csv_close_ok(thetop, entries, actions):
22832420
"copy a query result to the clipboard, then close the dialog (action)"
22842421
copy_csv_ok(thetop, entries, actions)
@@ -2792,12 +2929,41 @@ def write_rows(fout):
27922929
) # PyPy as a strange list of list
27932930
writer.writerows(cursor.fetchall())
27942931

2932+
def write_json_rows(fout, lines_mode):
2933+
"""one object per row ; a .json array, or .jsonl lines"""
2934+
cols = [i if isinstance(i, str) else i[0] for i in cursor.description]
2935+
2936+
def clean(v):
2937+
if isinstance(v, (bytes, bytearray)):
2938+
return v.decode("utf-8", "replace")
2939+
if v is None or isinstance(v, (int, float, str)):
2940+
return v
2941+
return str(v) # datetime, Decimal, uuid, ...
2942+
2943+
first = True
2944+
for row in cursor:
2945+
record = json.dumps(
2946+
dict(zip(cols, [clean(v) for v in row])), ensure_ascii=False
2947+
)
2948+
if lines_mode:
2949+
fout.write(record + "\n")
2950+
else:
2951+
fout.write(("[\n" if first else ",\n") + record)
2952+
first = False
2953+
if not lines_mode:
2954+
fout.write("[]" if first else "\n]\n")
2955+
27952956
if hasattr(csv_file, "write"): # file-like target (e.g. clipboard buffer)
27962957
write_rows(csv_file)
27972958
else:
27982959
write_mode = "w" if initialize else "a" # Write or Append
2960+
target = csv_file.lower()
27992961
with open(csv_file, write_mode, newline="", encoding=encoding) as fout:
2800-
write_rows(fout)
2962+
if target.endswith((".json", ".jsonl", ".ndjson")):
2963+
# the file extension decides the format : json, not csv
2964+
write_json_rows(fout, not target.endswith(".json"))
2965+
else:
2966+
write_rows(fout)
28012967
return nb_columns
28022968

28032969

sqlite_bro/tests/test_json.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""json import / export : gui button routing, .import / .once batch, helpers"""
2+
import json
3+
import pathlib
4+
import tempfile
5+
6+
import pytest
7+
8+
from sqlite_bro import sqlite_bro
9+
from sqlite_bro.sqlite_bro import read_this_json, json_table_name, shred_query
10+
from sqlite_bro.tests import test_general # owner of the single gui App
11+
12+
app = sqlite_bro.App(use_gui=False)
13+
needs_gui = pytest.mark.skipif(not test_general.app.use_gui, reason="no display")
14+
15+
16+
def test_helpers():
17+
"file name to table name, and shred query proposal"
18+
assert json_table_name("d:/some stuff.json") == "some_stuff_raw"
19+
records = [{"id": 1, "the name": "bolt"}]
20+
query = shred_query("items_raw", records)
21+
assert "json_extract(json, '$.id')" in query
22+
assert 'json_extract(json, \'$."the name"\')' in query
23+
assert shred_query("t", []) == 'SELECT json FROM "t";'
24+
25+
26+
def test_read_this_json():
27+
".json array, single object, and .jsonl lines"
28+
with tempfile.TemporaryDirectory(prefix=".tmp") as tmp_dir:
29+
fj = str(pathlib.PurePath(tmp_dir, "t.json"))
30+
with open(fj, "w", encoding="utf-8") as f:
31+
f.write('{"solo": true}')
32+
assert read_this_json(fj) == [{"solo": True}]
33+
fl = str(pathlib.PurePath(tmp_dir, "t.jsonl"))
34+
with open(fl, "w", encoding="utf-8") as f:
35+
f.write('{"a": 1}\n\n{"a": 2}\n')
36+
assert read_this_json(fl) == [{"a": 1}, {"a": 2}]
37+
38+
39+
def test_batch_json_import_export():
40+
".import file.json + .once out.json / out.jsonl, no gui needed"
41+
app.new_db(":memory:")
42+
with tempfile.TemporaryDirectory(prefix=".tmp") as tmp_dir:
43+
src = str(pathlib.PurePath(tmp_dir, "items.json")).replace("\\", "/")
44+
with open(src, "w", encoding="utf-8") as f:
45+
json.dump([{"id": 1, "name": "bolt"}, {"id": 2, "name": "nut"}], f)
46+
out_j = str(pathlib.PurePath(tmp_dir, "out.json")).replace("\\", "/")
47+
out_l = str(pathlib.PurePath(tmp_dir, "out.jsonl")).replace("\\", "/")
48+
script = (
49+
'.import "%s"\n'
50+
".once %s\n"
51+
"SELECT json_extract(json, '$.id') AS id FROM items_raw;\n"
52+
".once %s\n"
53+
"SELECT json_extract(json, '$.name') AS name FROM items_raw;\n"
54+
) % (src, out_j, out_l)
55+
app.n.new_query_tab("json batch", script)
56+
app.run_tab()
57+
with open(out_j, encoding="utf-8") as f:
58+
assert json.load(f) == [{"id": 1}, {"id": 2}]
59+
with open(out_l, encoding="utf-8") as f:
60+
assert [json.loads(l) for l in f] == [{"name": "bolt"}, {"name": "nut"}]
61+
62+
63+
@needs_gui
64+
def test_gui_json_import_opens_shred_tab():
65+
"the csv import button routes .json files and proposes a shred query"
66+
gui_app = test_general.app
67+
gui_app.new_db(":memory:")
68+
with tempfile.TemporaryDirectory(prefix=".tmp") as tmp_dir:
69+
src = str(pathlib.PurePath(tmp_dir, "goods.json"))
70+
with open(src, "w", encoding="utf-8") as f:
71+
json.dump([{"id": 7, "name": "screw"}], f)
72+
gui_app.import_csvtb(src)
73+
assert gui_app.conn.conn.execute(
74+
'SELECT count(*) FROM "goods_raw"'
75+
).fetchall() == [(1,)]
76+
tab_id = gui_app.n.notebook.select()
77+
shred = gui_app.n.fw_labels[tab_id].get("1.0", "end")
78+
assert "json_extract" in shred and "goods_raw" in shred

0 commit comments

Comments
 (0)