sqlite-bro 0.13.1__tar.gz → 0.14.0__tar.gz

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.
@@ -1,8 +1,8 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: sqlite_bro
3
- Version: 0.13.1
4
- Summary: a graphic SQLite Client in 1 Python file
5
- Keywords: sqlite,gui,ttk,sql
3
+ Version: 0.14.0
4
+ Summary: a graphic SQLite and DuckDB Client in 1 Python file
5
+ Keywords: sqlite,duckdb,gui,ttk,sql
6
6
  Author: stonebig
7
7
  Requires-Python: >=3.3
8
8
  Description-Content-Type: text/x-rst
@@ -17,20 +17,23 @@ Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Development Status :: 5 - Production/Stable
18
18
  Classifier: Topic :: Scientific/Engineering
19
19
  Classifier: Topic :: Software Development :: Widget Sets
20
+ License-File: LICENSE
20
21
  Project-URL: Documentation, https://github.com/stonebig/sqlite_bro/README.rst
21
22
  Project-URL: Source, https://github.com/stonebig/sqlite_bro
22
23
 
23
- sqlite_bro : a graphic SQLite browser in 1 Python file
24
- ======================================================
24
+ sqlite_bro : a graphic SQLite and DuckDB browser in 1 Python file
25
+ =================================================================
25
26
 
26
- sqlite_bro is a tool to browse SQLite databases with
27
- any basic python installation.
27
+ sqlite_bro is a tool to browse SQLite (and optionally DuckDB)
28
+ databases with any basic python installation.
28
29
 
29
30
 
30
31
  Features
31
32
  --------
32
33
 
33
- * Tabular browsing of a SQLite database
34
+ * Tabular browsing of a SQLite or DuckDB database
35
+
36
+ * Optional DuckDB engine : open or create a '.duckdb' file (needs 'pip install duckdb')
34
37
 
35
38
  * Import/Export of .csv files with auto-detection
36
39
 
@@ -1,14 +1,16 @@
1
- sqlite_bro : a graphic SQLite browser in 1 Python file
2
- ======================================================
1
+ sqlite_bro : a graphic SQLite and DuckDB browser in 1 Python file
2
+ =================================================================
3
3
 
4
- sqlite_bro is a tool to browse SQLite databases with
5
- any basic python installation.
4
+ sqlite_bro is a tool to browse SQLite (and optionally DuckDB)
5
+ databases with any basic python installation.
6
6
 
7
7
 
8
8
  Features
9
9
  --------
10
10
 
11
- * Tabular browsing of a SQLite database
11
+ * Tabular browsing of a SQLite or DuckDB database
12
+
13
+ * Optional DuckDB engine : open or create a '.duckdb' file (needs 'pip install duckdb')
12
14
 
13
15
  * Import/Export of .csv files with auto-detection
14
16
 
@@ -25,8 +25,8 @@ classifiers=[
25
25
  'Topic :: Software Development :: Widget Sets',
26
26
  ]
27
27
  dynamic = ["version",]
28
- description="a graphic SQLite Client in 1 Python file"
29
- keywords = ["sqlite", "gui", "ttk", "sql"]
28
+ description="a graphic SQLite and DuckDB Client in 1 Python file"
29
+ keywords = ["sqlite", "duckdb", "gui", "ttk", "sql"]
30
30
 
31
31
  [project.urls]
32
32
  Documentation = "https://github.com/stonebig/sqlite_bro/README.rst"
@@ -0,0 +1 @@
1
+ __version__ = '0.14.0'
@@ -8,6 +8,15 @@ except ImportError:
8
8
  pass # Python<3.2
9
9
 
10
10
  import sqlite3 as sqlite
11
+
12
+ try: # optional second engine : DuckDB (pip install duckdb)
13
+ import duckdb
14
+
15
+ db_errors = (sqlite.Error, duckdb.Error)
16
+ except ImportError:
17
+ duckdb = None
18
+ db_errors = (sqlite.Error,)
19
+
11
20
  import sys
12
21
  import os
13
22
  import locale
@@ -35,15 +44,74 @@ except ImportError: # or we are still Python2.7
35
44
  tipwindow = None
36
45
 
37
46
  # starting Python-3.13, PEP 667 forces us to us a specific dictionnary pydef_locals instead of locals()
38
- pydef_locals ={}
47
+ pydef_locals ={}
48
+
49
+ WELCOME_DUCKDB = """-- DuckDB Memo (Demo = click on green "->" icon)
50
+ -- (this database uses the DuckDB engine : SQL is stricter than SQLite)
51
+ \n-- to CREATE tables : column types are mandatory
52
+ DROP TABLE IF EXISTS item; DROP TABLE IF EXISTS part;
53
+ CREATE TABLE item (ItemNo TEXT, Description TEXT, Kg REAL, PRIMARY KEY (ItemNo));
54
+ CREATE TABLE part(ParentNo TEXT, ChildNo TEXT, Description TEXT, Qty_per REAL);
55
+ \n-- to CREATE an index :
56
+ DROP INDEX IF EXISTS parts_id1;
57
+ CREATE INDEX parts_id1 ON part(ParentNo, ChildNo);
58
+ \n-- to CREATE a view 'v1':
59
+ DROP VIEW IF EXISTS v1;
60
+ CREATE VIEW v1 as select * from item inner join part as p ON ItemNo=p.ParentNo;
61
+ \n-- to INSERT datas : string literals use single quotes
62
+ INSERT INTO item values('T','Ford',1000);
63
+ INSERT INTO item select 'A','Merced',1250 union all select 'W','Wheel',9 ;
64
+ INSERT INTO part select ItemNo,'W','needed',Kg/250 from item where Kg>250;
65
+ \n-- to CREATE a Python embedded function (needs numpy installed),
66
+ -- enclose it by "pydef" and ";" :
67
+ pydef py_hello():
68
+ "hello world"
69
+ return ("Hello, World !");
70
+ pydef py_fib(n):
71
+ "fibonacci : example with function call (may only be internal) "
72
+ fib = lambda n: n if n < 2 else fib(n-1) + fib(n-2)
73
+ return("%s" % fib(n*1));
74
+ pydef py_fib_typed(n: int) -> int:
75
+ "type-annotated pydef : registered natively (faster, typed) "
76
+ fib = lambda n: n if n < 2 else fib(n-1) + fib(n-2)
77
+ return fib(n);
78
+ \n-- to USE a python embedded function :
79
+ select py_hello(), py_fib(6) as fibonacci, py_fib_typed(7) as fib_typed,
80
+ version() as duckdb_version;
81
+ \n-- some DuckDB goodies :
82
+ DESCRIBE item;
83
+ SUMMARIZE item;
84
+ select * from duckdb_tables();
85
+ \n-- to use COMMIT and ROLLBACK (DuckDB does not support SAVEPOINT) :
86
+ BEGIN TRANSACTION;
87
+ UPDATE item SET Kg = Kg + 1;
88
+ COMMIT;
89
+ BEGIN TRANSACTION;
90
+ UPDATE item SET Kg = 0;
91
+ select Kg, Description from Item;
92
+ ROLLBACK;
93
+ select Kg, Description from Item;
94
+ \n-- '.' commands understood (same as in SQLite mode, see sqlite demo) :
95
+ .headers on
96
+ .separator ;
97
+ .once --bom '~this_file_of result.txt'
98
+ select ItemNo, Description from item order by ItemNo desc;
99
+ .import '~this_file_of result.txt' in_this_table
100
+ .cd ~
101
+ ATTACH 'test.duckdb' as toto;
102
+ DROP TABLE IF EXISTS toto.new_item;
103
+ CREATE TABLE toto.new_item as select * from "main"."item";
104
+ .dump
105
+ """
106
+
39
107
 
40
108
  class App:
41
109
  """the GUI graphic application"""
42
110
 
43
111
  def __init__(self, use_gui=True):
44
112
  """create a tkk graphic interface with a main window tk_win"""
45
- self.__version__ = "0.13.1"
46
- self._title = "of 2024-05-11b : 'Setup me down !'"
113
+ self.__version__ = "0.14.0"
114
+ self._title = "of 2026-07-12 : 'Quack me up !'"
47
115
  self.conn = None # Baresql database object
48
116
  self.database_file = ""
49
117
  self.initialdir = "."
@@ -57,7 +125,9 @@ class App:
57
125
 
58
126
  if self.use_gui:
59
127
  self.tk_win.title(
60
- "A graphic SQLite Client in 1 Python file (" + self.__version__ + ")"
128
+ "A graphic SQLite and DuckDB Client in 1 Python file ("
129
+ + self.__version__
130
+ + ")"
61
131
  )
62
132
  self.tk_win.option_add("*tearOff", FALSE) # hint of tk documentation
63
133
  self.tk_win.minsize(600, 200) # minimal size
@@ -107,6 +177,9 @@ class App:
107
177
  self.default_separator = ","
108
178
  self.current_directory = os.getcwd()
109
179
 
180
+ # show the DuckDB welcome demo only once per session
181
+ self.duckdb_demo_shown = False
182
+
110
183
  # Initiate Output State
111
184
  self.once_mode = False
112
185
  self.encode_in = "utf-8"
@@ -131,6 +204,11 @@ class App:
131
204
  self.menu.add_command(
132
205
  label="New In-Memory Database", command=lambda: self.new_db(":memory:")
133
206
  )
207
+ if duckdb is not None:
208
+ self.menu.add_command(
209
+ label="New In-Memory DuckDB Database",
210
+ command=lambda: self.new_db(":memory:", engine="duckdb"),
211
+ )
134
212
  self.menu.add_command(label="Open Database ...", command=self.open_db)
135
213
  self.menu.add_command(
136
214
  label="Open Database ...(legacy auto-commit)",
@@ -152,7 +230,7 @@ class App:
152
230
  label="about",
153
231
  command=lambda: messagebox.showinfo(
154
232
  message="""
155
- \nSQLite_bro : a graphic SQLite Client in 1 Python file
233
+ \nSQLite_bro : a graphic SQLite and DuckDB Client in 1 Python file
156
234
  \nVersion """
157
235
  + self.__version__
158
236
  + " "
@@ -212,14 +290,19 @@ class App:
212
290
  if os.path.isfile(proposal):
213
291
  self.initialdir = os.path.dirname(proposal)
214
292
 
215
- def new_db(self, filename=""):
216
- """create a new database"""
293
+ def new_db(self, filename="", engine=None):
294
+ """create a new database (engine guessed from extension if not given)"""
217
295
  if filename == "":
218
296
  filename = filedialog.asksaveasfilename(
219
297
  initialdir=self.initialdir,
220
298
  defaultextension=".db",
221
299
  title="Define a new database name and location",
222
- filetypes=[("default", "*.db"), ("other", "*.db*"), ("all", "*.*")],
300
+ filetypes=[
301
+ ("default", "*.db"),
302
+ ("duckdb", "*.duckdb"),
303
+ ("other", "*.db*"),
304
+ ("all", "*.*"),
305
+ ],
223
306
  )
224
307
  if filename != "":
225
308
  self.database_file = filename
@@ -231,23 +314,40 @@ class App:
231
314
  title="Destroying",
232
315
  ):
233
316
  os.remove(filename)
234
- self.conn = Baresql(self.database_file)
317
+ self.conn = Baresql(self.database_file, engine=engine)
318
+ self.show_duckdb_demo()
235
319
  self.actualize_db()
236
320
 
237
- def open_db(self, filename="", isolation_level=None):
238
- """open an existing database"""
321
+ def open_db(self, filename="", isolation_level=None, engine=None):
322
+ """open an existing database (engine guessed from extension if not given)"""
239
323
  if filename == "":
240
324
  filename = filedialog.askopenfilename(
241
325
  initialdir=self.initialdir,
242
326
  defaultextension=".db",
243
- filetypes=[("default", "*.db"), ("other", "*.db*"), ("all", "*.*")],
327
+ filetypes=[
328
+ ("default", "*.db"),
329
+ ("duckdb", "*.duckdb"),
330
+ ("other", "*.db*"),
331
+ ("all", "*.*"),
332
+ ],
244
333
  )
245
334
  if filename != "":
246
335
  self.set_initialdir(filename)
247
336
  self.database_file = filename
248
- self.conn = Baresql(self.database_file)
337
+ self.conn = Baresql(self.database_file, engine=engine)
338
+ self.show_duckdb_demo()
249
339
  self.actualize_db()
250
340
 
341
+ def show_duckdb_demo(self):
342
+ """open the DuckDB welcome demo tab, once, when DuckDB is first used"""
343
+ if (
344
+ self.use_gui
345
+ and self.conn.engine == "duckdb"
346
+ and not self.duckdb_demo_shown
347
+ ):
348
+ self.duckdb_demo_shown = True
349
+ self.n.new_query_tab("DuckDB Memo", WELCOME_DUCKDB)
350
+
251
351
  def backup_db(self, filename="", isolation_level=None):
252
352
  """Backup the current database"""
253
353
  if filename == "":
@@ -266,9 +366,7 @@ class App:
266
366
  title="Destroying",
267
367
  ):
268
368
  os.remove(filename)
269
- db_to = sqlite.connect(filename)
270
- self.conn.conn.backup(db_to)
271
- db_to.close()
369
+ self.backup_main_to(filename)
272
370
  self.actualize_db()
273
371
 
274
372
  def restore_db(self, filename="", isolation_level=None):
@@ -280,10 +378,40 @@ class App:
280
378
  filetypes=[("default", "*.db"), ("other", "*.db*"), ("all", "*.*")],
281
379
  )
282
380
  if filename != "":
381
+ self.restore_main_from(filename)
382
+ self.actualize_db()
383
+
384
+ def backup_main_to(self, filename):
385
+ """backup the current 'main' database to filename, per engine"""
386
+ if self.conn.engine == "duckdb":
387
+ current = self.conn.execute("select current_database()").fetchall()[0][0]
388
+ self.conn.execute(
389
+ "ATTACH '%s' AS bro_backup" % filename.replace("'", "''")
390
+ )
391
+ self.conn.execute(
392
+ 'COPY FROM DATABASE "%s" TO bro_backup' % current.replace('"', '""')
393
+ )
394
+ self.conn.execute("DETACH bro_backup")
395
+ else:
396
+ db_to = sqlite.connect(filename)
397
+ self.conn.conn.backup(db_to)
398
+ db_to.close()
399
+
400
+ def restore_main_from(self, filename):
401
+ """restore the database in filename into current 'main', per engine"""
402
+ if self.conn.engine == "duckdb":
403
+ current = self.conn.execute("select current_database()").fetchall()[0][0]
404
+ self.conn.execute(
405
+ "ATTACH '%s' AS bro_restore (READ_ONLY)" % filename.replace("'", "''")
406
+ )
407
+ self.conn.execute(
408
+ 'COPY FROM DATABASE bro_restore TO "%s"' % current.replace('"', '""')
409
+ )
410
+ self.conn.execute("DETACH bro_restore")
411
+ else:
283
412
  db_from = sqlite.connect(filename)
284
413
  db_from.backup(self.conn.conn)
285
414
  db_from.close
286
- self.actualize_db()
287
415
 
288
416
  def load_script(self):
289
417
  """load a script file, ask validation of detected Python code"""
@@ -367,7 +495,12 @@ class App:
367
495
  initialdir=self.initialdir,
368
496
  defaultextension=".db",
369
497
  title="Choose a database to attach ",
370
- filetypes=[("default", "*.db"), ("other", "*.db*"), ("all", "*.*")],
498
+ filetypes=[
499
+ ("default", "*.db"),
500
+ ("duckdb", "*.duckdb"),
501
+ ("other", "*.db*"),
502
+ ("all", "*.*"),
503
+ ],
371
504
  )
372
505
  if attach_as == "":
373
506
  attach = os.path.basename(filename).split(".")[0]
@@ -379,7 +512,8 @@ class App:
379
512
  attach, indice = att + "_" + str(indice), indice + 1
380
513
  if filename != "":
381
514
  self.set_initialdir(filename)
382
- attach_order = "ATTACH DATABASE '%s' as '%s' " % (filename, attach)
515
+ # double-quoted alias : accepted by both SQLite and DuckDB
516
+ attach_order = 'ATTACH DATABASE \'%s\' as "%s" ' % (filename, attach)
383
517
  self.conn.execute(attach_order)
384
518
  self.actualize_db()
385
519
 
@@ -401,10 +535,22 @@ class App:
401
535
  # delete existing tree entries before re-creating them
402
536
  for node in self.db_tree.get_children():
403
537
  self.db_tree.delete(node)
404
- # create top node
538
+ # create top node, showing the engine driving the main database
539
+ engine_label = (
540
+ "DuckDB" if getattr(self.conn, "engine", "sqlite") == "duckdb"
541
+ else "SQLite"
542
+ )
405
543
  dbtext = os.path.basename(self.database_file)
544
+ self.tk_win.title(
545
+ "A graphic SQLite and DuckDB Client in 1 Python file (%s) - %s %s"
546
+ % (self.__version__, engine_label, self.database_file)
547
+ )
406
548
  id0 = self.db_tree.insert(
407
- "", 0, "Database", text="main (%s)" % dbtext, values=(dbtext, "")
549
+ "",
550
+ 0,
551
+ "Database",
552
+ text="main (%s %s)" % (engine_label, dbtext),
553
+ values=(dbtext, ""),
408
554
  )
409
555
  # add Database Objects, by Category
410
556
  for categ in ["master_table", "table", "view", "trigger", "index", "pydef"]:
@@ -948,11 +1094,12 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
948
1094
  values=(definition, ""),
949
1095
  )
950
1096
  # level 3 : Insert a line per column of the Table/View
1097
+ # (id by column position : DuckDB views may repeat names)
951
1098
  for c in range(len(sub_c)):
952
1099
  self.db_tree.insert(
953
1100
  idc,
954
1101
  "end",
955
- "%s%s%s" % (root_txt, t_id, sub_c[c][0]),
1102
+ "%s%s.%s" % (root_txt, t_id, c),
956
1103
  text=columns[c],
957
1104
  tags=("run_up",),
958
1105
  values=("", ""),
@@ -964,13 +1111,23 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
964
1111
  a_jouer = self.conn.get_sqlsplit(instructions, remove_comments=False)
965
1112
  # must read :https://www.youtube.com/watch?v=09tM18_st4I#t=1751
966
1113
  # stackoverflow.com/questions/15856976/transactions-with-python-sqlite3
967
- isolation = self.conn.conn.isolation_level
1114
+ # DuckDB connections have no isolation_level attribute
1115
+ isolation = getattr(self.conn.conn, "isolation_level", None)
968
1116
  counter = 0
969
1117
  shell_list = ["", ""]
970
1118
  if isolation == "": # Sqlite3 and dump.py default don't match
971
1119
  self.conn.conn.isolation_level = None # right behavior
972
1120
  cu = self.conn.conn.cursor()
973
1121
  sql_error = False
1122
+ log_rows = [] # one line per instruction : the "Logs" tab of the run
1123
+ grid_count = 0 # number of data grids ("Qry N°x" tabs) displayed
1124
+
1125
+ def add_log(result, status, first_line):
1126
+ """record one instruction outcome for the "Logs" tab"""
1127
+ timing = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1128
+ log_rows.append(
1129
+ (len(log_rows) + 1, timing, result, status, first_line)
1130
+ )
974
1131
 
975
1132
  def beurk(r):
976
1133
  """format data line log"""
@@ -998,16 +1155,16 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
998
1155
  first_line = (instru + "\n").splitlines()[0]
999
1156
  if instru[:5] == "pydef":
1000
1157
  pydef = self.conn.createpydef(instru)
1001
- titles = ("Creating embedded python function",)
1002
1158
  rows = self.conn.conn_def[pydef]["pydef"].splitlines()
1003
1159
  rows.append(self.conn.conn_def[pydef]["inst"])
1004
- self.n.add_treeview(tab_tk_id, titles, rows, "Info", pydef)
1160
+ add_log("python function %s created" % pydef, "Ok", first_line)
1005
1161
  if log is not None: # write to logFile
1006
1162
  log.write("\n".join(['("%s")' % r for r in rows]) + "\n")
1007
1163
  elif instru[:1] == ".": # a shell command !
1008
1164
  # handle a ".function" here !
1009
1165
  # import FILE TABLE
1010
1166
  shell_list = shlex.split(instru, posix=False) # magic standard library
1167
+ dot_result = ""
1011
1168
  try:
1012
1169
  if shell_list[0] == ".cd" and len(shell_list) >= 2:
1013
1170
  db_file = shell_list[1]
@@ -1109,12 +1266,9 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
1109
1266
  create_table=False,
1110
1267
  replace=False,
1111
1268
  )
1112
- self.n.add_treeview(
1113
- tab_tk_id,
1114
- ("table", "file"),
1115
- ((guess.table_name, csv_file),),
1116
- "Info",
1117
- first_line,
1269
+ dot_result = 'file %s imported in "%s"' % (
1270
+ csv_file,
1271
+ guess.table_name,
1118
1272
  )
1119
1273
  if log is not None: # write to logFile
1120
1274
  log.write(
@@ -1138,6 +1292,7 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
1138
1292
  ([("%s" % line) for line in self.conn.iterdump()]),
1139
1293
  "Dump",
1140
1294
  ".dump",
1295
+ position=0, # keep the "Dump" tab on the left
1141
1296
  )
1142
1297
  if shell_list[0] == ".read" and len(shell_list) >= 2:
1143
1298
  filename = shell_list[1]
@@ -1166,25 +1321,23 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
1166
1321
  filename = shell_list[1]
1167
1322
  if (filename + "z")[0] == "~":
1168
1323
  filename = os.path.join(self.home, filename[1:])
1169
- db_from = sqlite.connect(filename)
1170
- db_from.backup(self.conn.conn)
1171
- db_from.close
1324
+ self.restore_main_from(filename)
1172
1325
  self.actualize_db()
1173
1326
  if shell_list[0] == ".backup" and len(shell_list) >= 2:
1174
1327
  filename = shell_list[1]
1175
1328
  if (filename + "z")[0] == "~":
1176
1329
  filename = os.path.join(self.home, filename[1:])
1177
- db_to = sqlite.connect(filename)
1178
- self.conn.conn.backup(db_to)
1179
- db_to.close()
1330
+ self.backup_main_to(filename)
1180
1331
  if shell_list[0] == ".shell" and len(shell_list) >= 2:
1181
1332
  os.system(instru[len(".print") + 1 :] + "\n")
1333
+ add_log(dot_result, "Ok", first_line)
1182
1334
 
1183
1335
  except IOError as err:
1184
1336
  msg = "I/O error: {0}".format(err)
1185
1337
  self.n.add_treeview(
1186
1338
  tab_tk_id, ("Error !",), [(msg,)], "Error !", instru
1187
1339
  )
1340
+ add_log(msg, "Error", first_line)
1188
1341
  if not self.use_gui:
1189
1342
  print("Error !", [msg])
1190
1343
  if log is not None: # write to logFile
@@ -1205,13 +1358,9 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
1205
1358
  )
1206
1359
  if nb_columns > 0:
1207
1360
  self.once_mode, self.init_output = False, False
1208
- if nb_columns > 0:
1209
- self.n.add_treeview(
1210
- tab_tk_id,
1211
- ("qry_to_csv", "file"),
1212
- ((instruction, self.output_file),),
1213
- "Qry",
1214
- # ".once %s" % self.output_file,
1361
+ add_log(
1362
+ "query exported to %s" % self.output_file,
1363
+ "Ok",
1215
1364
  first_line,
1216
1365
  )
1217
1366
  if self.x_mode and nb_columns > 0:
@@ -1231,9 +1380,49 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
1231
1380
  cur.description is not None and len(cur.description) > 0
1232
1381
  ): # pypy needs this test
1233
1382
  titles = [row_info[0] for row_info in cur.description]
1234
- self.n.add_treeview(
1235
- tab_tk_id, titles, rows, "Qry", first_line
1236
- )
1383
+ # DuckDB DDL/DML return a synthetic 'Count' or
1384
+ # 'Success' resultset : log it, don't grid it
1385
+ # (unless the instruction is a genuine query)
1386
+ if (
1387
+ titles in (["Count"], ["Success"])
1388
+ and len(rows) <= 1
1389
+ and not instru.lstrip("( \t\n\r")[:9]
1390
+ .lower()
1391
+ .startswith(
1392
+ (
1393
+ "select",
1394
+ "with",
1395
+ "from",
1396
+ "values",
1397
+ "table",
1398
+ "show",
1399
+ "describe",
1400
+ "summarize",
1401
+ "pragma",
1402
+ "call",
1403
+ "explain",
1404
+ )
1405
+ )
1406
+ ):
1407
+ affected = rows[0][0] if rows else 0
1408
+ add_log(
1409
+ "%s rows affected" % affected
1410
+ if titles == ["Count"]
1411
+ else "",
1412
+ "Ok",
1413
+ first_line,
1414
+ )
1415
+ else:
1416
+ add_log("%s rows" % len(rows), "Ok", first_line)
1417
+ # grid tab title carries the "Logs" record N°
1418
+ self.n.add_treeview(
1419
+ tab_tk_id,
1420
+ titles,
1421
+ rows,
1422
+ "Qry_%s" % len(log_rows),
1423
+ first_line,
1424
+ )
1425
+ grid_count += 1
1237
1426
  if log is not None: # write to logFile
1238
1427
  log.write(beurk(titles) + "\n")
1239
1428
  log.write(
@@ -1241,16 +1430,38 @@ e/BqhsRJM2fHnD1puuQJ9GdQewIBKN23tOnSfTR5FgSQlKlVqlQXZs169anCrQOxrhyLMCAAOw==
1241
1430
  )
1242
1431
  if len(rows) > limit:
1243
1432
  log.write("%s more..." % len((rows) - limit))
1244
- except sqlite.Error as msg: # OperationalError
1433
+ else:
1434
+ rowcount = getattr(cur, "rowcount", -1)
1435
+ add_log(
1436
+ "%s rows affected" % rowcount
1437
+ if rowcount >= 0
1438
+ else "",
1439
+ "Ok",
1440
+ first_line,
1441
+ )
1442
+ except db_errors as msg: # OperationalError
1245
1443
  self.n.add_treeview(
1246
1444
  tab_tk_id, ("Error !",), [(msg,)], "Error !", first_line
1247
1445
  )
1446
+ add_log("%s" % msg, "Error", first_line)
1248
1447
  if log is not None: # write to logFile
1249
1448
  log.write("Error ! %s" % msg)
1250
1449
  sql_error = True
1251
1450
  break
1252
1451
 
1253
- if self.conn.conn.isolation_level != isolation:
1452
+ # display the "Logs" tab, unless the run was a single query
1453
+ # already fully rendered by its data grid (classic behavior)
1454
+ if len(log_rows) > 1 or (log_rows and grid_count == 0):
1455
+ self.n.add_treeview(
1456
+ tab_tk_id,
1457
+ ("N°", "Time", "Result", "Status", "Instruction"),
1458
+ log_rows,
1459
+ "Logs",
1460
+ "execution log",
1461
+ position=0, # keep the permanent "Logs" tab leftmost
1462
+ )
1463
+
1464
+ if getattr(self.conn.conn, "isolation_level", None) != isolation:
1254
1465
  # if we're in 'backward' compatible mode (automatic commit)
1255
1466
  try:
1256
1467
  if self.conn.conn.in_transaction: # python 3.2
@@ -1468,7 +1679,9 @@ class NotebookForQueries:
1468
1679
  xx.grid_forget()
1469
1680
  xx.destroy()
1470
1681
 
1471
- def add_treeview(self, given_tk_id, columns, data, title="__", subt=""):
1682
+ def add_treeview(
1683
+ self, given_tk_id, columns, data, title="__", subt="", position="end"
1684
+ ):
1472
1685
  """add a dataset result to the given tab tk_id"""
1473
1686
  if not self.use_gui:
1474
1687
  return
@@ -1491,7 +1704,9 @@ class NotebookForQueries:
1491
1704
  height=100,
1492
1705
  )
1493
1706
  f2.pack(fill="both", expand=True)
1494
- fw_result_nb.add(f2, text=title)
1707
+ if position != "end" and not fw_result_nb.tabs():
1708
+ position = "end" # Tk refuses insert(0) in an empty notebook
1709
+ fw_result_nb.insert(position, f2, text=title)
1495
1710
 
1496
1711
  # ttk.Style().configure('TLabelframe.label', font=("Arial",14, "bold"))
1497
1712
  # lines=queries
@@ -1514,7 +1729,8 @@ class NotebookForQueries:
1514
1729
  fw_Box.heading(
1515
1730
  col, text=col.title(), command=lambda c=col: self.sortby(fw_Box, c, 0)
1516
1731
  )
1517
- fw_Box.column(col, width=font.Font().measure(col.title()))
1732
+ # keep a little breathing room around the header text
1733
+ fw_Box.column(col, width=font.Font().measure(col.title()) + 12)
1518
1734
 
1519
1735
  def flat(x):
1520
1736
  """replace line_return by space, if given a string"""
@@ -1534,7 +1750,7 @@ class NotebookForQueries:
1534
1750
  # adjust columns length if necessary and possible
1535
1751
  for indx, val in enumerate(line_cells):
1536
1752
  try:
1537
- ilen = font.Font().measure(val)
1753
+ ilen = font.Font().measure(val) + 12
1538
1754
  if (
1539
1755
  fw_Box.column(tree_columns[indx], width=None) < ilen
1540
1756
  and ilen < 400
@@ -1633,7 +1849,10 @@ def guess_sql_creation(table_name, separ, decim, header, data, quoter='"'):
1633
1849
  head = ",\n".join([('"%s" %s' % (r[i], typ[i])) for i in range(len(r))])
1634
1850
  sql_crea = 'CREATE TABLE "%s" (%s);' % (table_name, head)
1635
1851
  else:
1636
- head = ",".join(["c_" + ("000" + str(i))[-3:] for i in range(len(r))])
1852
+ # typed columns : mandatory for DuckDB, harmless for SQLite
1853
+ head = ",".join(
1854
+ ["c_" + ("000" + str(i))[-3:] + " " + typ[i] for i in range(len(r))]
1855
+ )
1637
1856
  sql_crea = 'CREATE TABLE "%s" (%s);' % (table_name, head)
1638
1857
  return sql_crea, typ, head
1639
1858
 
@@ -1862,6 +2081,49 @@ def get_leaves(conn, category, attached_db="", tbl=""):
1862
2081
 
1863
2082
  if category == "pydef": # pydef request is not sql, answer is direct
1864
2083
  Tables = [[k, k, v["pydef"], ""] for k, v in conn.conn_def.items()]
2084
+ elif getattr(conn, "engine", "sqlite") == "duckdb":
2085
+ # DuckDB : sqlite_master only exists for the current database,
2086
+ # so rely on the duckdb_xxx() catalog functions instead
2087
+ dbf = (
2088
+ "current_database()"
2089
+ if attached_db in ("", "main", "temp")
2090
+ else "'%s'" % attached_db.replace("'", "''")
2091
+ )
2092
+ if category == "attached_databases":
2093
+ sql = """select database_oid, database_name, coalesce(path, '')
2094
+ from duckdb_databases()
2095
+ where not internal and database_name != current_database()"""
2096
+ for c in conn.execute(sql).fetchall():
2097
+ instruct = "ATTACH DATABASE %s as %s" % (f(c[2]), f(c[1]))
2098
+ Tables.append([c[0], c[1], instruct, ""])
2099
+ elif category == "fields":
2100
+ qualified = (
2101
+ tbl
2102
+ if attached_db in ("", "main")
2103
+ else "%s.%s" % (attached_db, tbl)
2104
+ )
2105
+ resu = conn.execute(
2106
+ "PRAGMA table_info('%s')" % qualified.replace("'", "''")
2107
+ ).fetchall()
2108
+ Tables = [[c[1], c[1], c[2], ""] for c in resu]
2109
+ elif category in ("table", "view", "index"):
2110
+ if category == "table":
2111
+ sql = """select 'table:' || table_name, table_name,
2112
+ coalesce(sql, '--auto'), 'fields'
2113
+ from duckdb_tables() where not internal
2114
+ and database_name = {0} order by table_name"""
2115
+ elif category == "view":
2116
+ sql = """select 'view:' || view_name, view_name,
2117
+ coalesce(sql, '--auto'), 'fields'
2118
+ from duckdb_views() where not internal
2119
+ and database_name = {0} order by view_name"""
2120
+ else:
2121
+ sql = """select 'index:' || index_name, index_name,
2122
+ coalesce(sql, '--auto'), ''
2123
+ from duckdb_indexes()
2124
+ where database_name = {0} order by index_name"""
2125
+ Tables = list(conn.execute(sql.format(dbf)).fetchall())
2126
+ # 'trigger' and 'master_table' : nothing to show for DuckDB
1865
2127
  elif category == "attached_databases":
1866
2128
  # get all attached database, but not the first one ('main')
1867
2129
  resu = list((conn.execute("PRAGMA database_list").fetchall()))[1:]
@@ -1889,23 +2151,70 @@ def get_leaves(conn, category, attached_db="", tbl=""):
1889
2151
  return Tables
1890
2152
 
1891
2153
 
2154
+ def duck_type_adapt(fn, nargs):
2155
+ """wrap an annotation-less pydef for DuckDB : mimic SQLite dynamic typing
2156
+ (arguments arrive as VARCHAR, numeric-looking ones are converted back)"""
2157
+
2158
+ def convert_call(args):
2159
+ converted = []
2160
+ for arg in args:
2161
+ if isinstance(arg, str):
2162
+ try:
2163
+ arg = int(arg)
2164
+ except ValueError:
2165
+ try:
2166
+ arg = float(arg)
2167
+ except ValueError:
2168
+ pass
2169
+ converted.append(arg)
2170
+ result = fn(*converted)
2171
+ return result if result is None else str(result)
2172
+
2173
+ if nargs == 0:
2174
+ return lambda: convert_call(())
2175
+ # build a wrapper of the exact arity DuckDB expects (*args confuses it)
2176
+ argnames = ",".join("a%s" % i for i in range(nargs))
2177
+ return eval("lambda %s: _c((%s,))" % (argnames, argnames), {"_c": convert_call})
2178
+
2179
+
1892
2180
  class Baresql:
1893
2181
  """a small wrapper around sqlite3 module"""
1894
2182
 
1895
2183
  def __init__(
1896
- self, connection="", keep_log=False, cte_inline=True, isolation_level=None
2184
+ self,
2185
+ connection="",
2186
+ keep_log=False,
2187
+ cte_inline=True,
2188
+ isolation_level=None,
2189
+ engine=None,
1897
2190
  ):
1898
2191
  self.dbname = connection.replace(":///", "://").replace("sqlite://", "")
1899
- self.conn = sqlite.connect(self.dbname, detect_types=sqlite.PARSE_DECLTYPES)
2192
+ if engine is None: # guess the engine from the file extension
2193
+ engine = (
2194
+ "duckdb"
2195
+ if self.dbname.lower().endswith((".duckdb", ".ddb"))
2196
+ else "sqlite"
2197
+ )
2198
+ self.engine = engine
2199
+ if self.engine == "duckdb":
2200
+ if duckdb is None:
2201
+ raise ImportError(
2202
+ "DuckDB engine requested, but 'duckdb' module is not installed"
2203
+ )
2204
+ self.conn = duckdb.connect(self.dbname)
2205
+ else:
2206
+ self.conn = sqlite.connect(
2207
+ self.dbname, detect_types=sqlite.PARSE_DECLTYPES
2208
+ )
2209
+ self.conn.isolation_level = isolation_level # commit experience
1900
2210
  # pydef and logging infrastructure
1901
2211
  self.conn_def = {}
1902
2212
  self.do_log = keep_log
1903
2213
  self.log = []
1904
- self.conn.isolation_level = isolation_level # commit experience
1905
2214
 
1906
2215
  def close(self):
1907
2216
  """close database and clear dictionnary of registered 'pydef'"""
1908
- self.conn.close
2217
+ self.conn.close()
1909
2218
  self.conn_def = {}
1910
2219
 
1911
2220
  def iterdump(self):
@@ -1915,6 +2224,42 @@ class Baresql:
1915
2224
  # add the Python functions pydef
1916
2225
  for k in self.conn_def.values():
1917
2226
  yield (k["pydef"] + ";\n")
2227
+ if self.engine == "duckdb":
2228
+ # DuckDB has no iterdump : rebuild one from catalog functions
2229
+ yield ("BEGIN TRANSACTION;")
2230
+ tables = self.conn.execute(
2231
+ """select table_name, sql from duckdb_tables()
2232
+ where not internal and database_name = current_database()
2233
+ order by table_name"""
2234
+ ).fetchall()
2235
+ for table_name, sql in tables:
2236
+ yield (sql if sql.rstrip().endswith(";") else sql + ";")
2237
+ quoted = table_name.replace('"', '""')
2238
+ for row in self.conn.execute(
2239
+ 'SELECT * FROM "%s"' % quoted
2240
+ ).fetchall():
2241
+ vals = ",".join(
2242
+ "NULL"
2243
+ if v is None
2244
+ else str(v)
2245
+ if isinstance(v, (int, float))
2246
+ else "'%s'" % str(v).replace("'", "''")
2247
+ for v in row
2248
+ )
2249
+ yield ('INSERT INTO "%s" VALUES(%s);' % (quoted, vals))
2250
+ for (sql,) in self.conn.execute(
2251
+ """select sql from duckdb_views()
2252
+ where not internal and database_name = current_database()"""
2253
+ ).fetchall():
2254
+ yield (sql if sql.rstrip().endswith(";") else sql + ";")
2255
+ for (sql,) in self.conn.execute(
2256
+ """select sql from duckdb_indexes()
2257
+ where database_name = current_database()"""
2258
+ ).fetchall():
2259
+ if sql:
2260
+ yield (sql if sql.rstrip().endswith(";") else sql + ";")
2261
+ yield ("COMMIT;")
2262
+ return
1918
2263
  # disable Foreign Constraints at Load
1919
2264
  yield ("PRAGMA foreign_keys = OFF; /*if SQlite */;")
1920
2265
  yield ("\n/* SET foreign_key_checks = 0;/*if Mysql*/;")
@@ -1950,7 +2295,38 @@ class Baresql:
1950
2295
  instr_name = instr_header[1]
1951
2296
  instr_parms = len(instr_header) - 2
1952
2297
  instr_pointer=eval(instr_name, globals(), pydef_locals)
1953
- self.conn.create_function(instr_name, instr_parms, instr_pointer)
2298
+ if self.engine == "duckdb":
2299
+ # DuckDB needs typed functions (and numpy installed) :
2300
+ # 1- clean up previous definitions of the same name
2301
+ for cleanup in (
2302
+ lambda: self.conn.remove_function(instr_name),
2303
+ lambda: self.conn.remove_function("_bro_" + instr_name),
2304
+ lambda: self.conn.execute('DROP MACRO IF EXISTS "%s"' % instr_name),
2305
+ ):
2306
+ try:
2307
+ cleanup()
2308
+ except Exception:
2309
+ pass
2310
+ # 2- a fully type-annotated pydef registers natively ...
2311
+ try:
2312
+ self.conn.create_function(instr_name, instr_pointer)
2313
+ except Exception:
2314
+ # ... otherwise register a VARCHAR implementation behind a
2315
+ # macro that casts any argument, to mimic SQLite duck-typing
2316
+ self.conn.create_function(
2317
+ "_bro_" + instr_name,
2318
+ duck_type_adapt(instr_pointer, instr_parms),
2319
+ ["VARCHAR"] * instr_parms,
2320
+ "VARCHAR",
2321
+ )
2322
+ parms = ["p%s" % i for i in range(instr_parms)]
2323
+ casts = ["CAST(p%s AS VARCHAR)" % i for i in range(instr_parms)]
2324
+ self.conn.execute(
2325
+ 'CREATE OR REPLACE MACRO "%s"(%s) AS "_bro_%s"(%s)'
2326
+ % (instr_name, ",".join(parms), instr_name, ",".join(casts))
2327
+ )
2328
+ else:
2329
+ self.conn.create_function(instr_name, instr_parms, instr_pointer)
1954
2330
  instr_add = "self.conn.create_function('%s', %s, %s)" % (
1955
2331
  instr_name,
1956
2332
  instr_parms,
@@ -2102,8 +2478,11 @@ class Baresql:
2102
2478
  curs.execute("begin transaction")
2103
2479
  except:
2104
2480
  pass
2105
- # check if table exists
2106
- here = curs.execute('PRAGMA table_info("%s")' % table_name).fetchall()
2481
+ # check if table exists (DuckDB raises where SQLite returns empty)
2482
+ try:
2483
+ here = curs.execute('PRAGMA table_info("%s")' % table_name).fetchall()
2484
+ except db_errors:
2485
+ here = []
2107
2486
  if create_sql and (create_table or len(here) == 0):
2108
2487
  curs.execute('drop TABLE if exists "%s";' % table_name)
2109
2488
  curs.execute(create_sql)
@@ -2117,7 +2496,10 @@ class Baresql:
2117
2496
  next(reader)
2118
2497
  # 2-push records
2119
2498
  curs.executemany(sql, reader)
2120
- self.conn.commit()
2499
+ if self.engine == "duckdb":
2500
+ curs.commit() # a DuckDB cursor is a duplicated connection
2501
+ else:
2502
+ self.conn.commit()
2121
2503
 
2122
2504
  def export_writer(
2123
2505
  self,
@@ -2135,8 +2517,21 @@ class Baresql:
2135
2517
  # do nothing if nothing
2136
2518
  if cursor.description is None or len(cursor.description) == 0:
2137
2519
  return -1
2138
- else:
2139
- nb_columns = len(cursor.description)
2520
+ if self.engine == "duckdb":
2521
+ # DuckDB gives a description ('Count') even to non-SELECT
2522
+ # statements : export only genuine queries, like SQLite does
2523
+ first_word = ""
2524
+ for line in sql.splitlines():
2525
+ line = line.strip()
2526
+ if line and not line.startswith("--"):
2527
+ first_word = line.split()[0].lower()
2528
+ break
2529
+ if first_word not in (
2530
+ "select", "with", "from", "pragma", "show",
2531
+ "describe", "values", "call", "summarize", "table",
2532
+ ):
2533
+ return -1
2534
+ nb_columns = len(cursor.description)
2140
2535
  # with PyPy, the "with io.open" for is more than necessary
2141
2536
  if sys.version_info[0] != 2: # python3
2142
2537
  write_mode = "w" if initialize else "a" # Write or Append
@@ -2170,6 +2565,7 @@ class Baresql:
2170
2565
 
2171
2566
  def _main():
2172
2567
  welcome_text = """-- SQLite Memo (Demo = click on green "->" and "@" icons)
2568
+ -- (tip : open or create a '.duckdb' file to use the DuckDB engine instead)
2173
2569
  \n-- to CREATE a table 'items' and a table 'parts' :
2174
2570
  DROP TABLE IF EXISTS item; DROP TABLE IF EXISTS part;
2175
2571
  CREATE TABLE item (ItemNo, Description,Kg , PRIMARY KEY (ItemNo));
@@ -2247,7 +2643,8 @@ CREATE TABLE toto.new_item as select * from "main"."item";
2247
2643
 
2248
2644
  if "argparse" in globals(): # not before Python-3.2
2249
2645
  parser = argparse.ArgumentParser(
2250
- description="sqlite_bro : a graphic SQLite browser in 1 Python file"
2646
+ description="sqlite_bro : a graphic SQLite and DuckDB browser"
2647
+ " in 1 Python file"
2251
2648
  )
2252
2649
  parser.add_argument(
2253
2650
  "-q", "--quiet", action="store_true", help="do not launch the gui"
@@ -2263,7 +2660,8 @@ CREATE TABLE toto.new_item as select * from "main"."item";
2263
2660
  "--database",
2264
2661
  default=":memory:",
2265
2662
  type=str,
2266
- help="specify initial Database if not ':memory:'",
2663
+ help="specify initial Database if not ':memory:'"
2664
+ " (a .duckdb/.ddb extension selects the DuckDB engine)",
2267
2665
  )
2268
2666
  parser.add_argument(
2269
2667
  "-sc", "--scripts", type=str, help="qive a list of initial scripts"
@@ -2290,7 +2688,7 @@ CREATE TABLE toto.new_item as select * from "main"."item";
2290
2688
  app.n.new_query_tab("Welcome", welcome_text)
2291
2689
  if not args.wait:
2292
2690
  app.run_tab()
2293
- else:
2691
+ elif app.conn.engine != "duckdb": # duckdb welcome tab auto-shows
2294
2692
  app.n.new_query_tab("Welcome", welcome_text)
2295
2693
  if args.quiet:
2296
2694
  app.close_db
@@ -0,0 +1,150 @@
1
+ # same scenarios as test_general_no_gui.py, but with the DuckDB engine
2
+ # (DuckDB needs typed CREATE TABLE and single-quoted string literals)
3
+ import pytest
4
+ import pathlib
5
+ import tempfile
6
+
7
+ import io
8
+
9
+ duckdb = pytest.importorskip("duckdb")
10
+ from sqlite_bro import sqlite_bro
11
+ app = sqlite_bro.App(use_gui=False)
12
+
13
+
14
+ def test_EngineDetection():
15
+ "a .duckdb file extension must select the duckdb engine"
16
+ with tempfile.TemporaryDirectory(prefix='.tmp') as tmp_dir:
17
+ db_file = str(pathlib.PurePath(tmp_dir, 'detect.duckdb'))
18
+ app.open_db(db_file)
19
+ assert app.conn.engine == "duckdb"
20
+ app.conn.close()
21
+ app.new_db(":memory:")
22
+ assert app.conn.engine == "sqlite"
23
+ app.new_db(":memory:", engine="duckdb")
24
+ assert app.conn.engine == "duckdb"
25
+
26
+
27
+ def test_Basics():
28
+ "create script, run script, output result, check result"
29
+ app.new_db(":memory:", engine="duckdb")
30
+ with tempfile.TemporaryDirectory(prefix='.tmp') as tmp_dir:
31
+ print(tmp_dir)
32
+ tmp_file = str(pathlib.PurePath(tmp_dir, 'sqlite_bro_test_Basics.tmp'))
33
+ welcome_text = """
34
+ create table item (ItemNo TEXT, Description TEXT, Kg INTEGER, PRIMARY KEY (ItemNo));
35
+ INSERT INTO item values('T','Ford',1000);
36
+ INSERT INTO item select 'A','Merced',1250 union all select 'W','Wheel',9 ;
37
+ .once %s
38
+ select ItemNo, Description, 1000*Kg Gramm from item order by ItemNo desc;
39
+ .import %s in_this_table""" % (tmp_file, tmp_file)
40
+ app.n.new_query_tab("Welcome", welcome_text)
41
+ app.run_tab()
42
+ app.close_db
43
+
44
+ file_encoding = sqlite_bro.guess_encoding(tmp_file)[0]
45
+ with io.open(tmp_file, mode='rt', encoding=file_encoding) as f:
46
+ result = f.readlines()
47
+ assert len(result) == 4
48
+ assert result[-1] == "A,Merced,1250000\n"
49
+
50
+
51
+ def test_Outputs():
52
+ "testing .output, .print, .header, .separator"
53
+
54
+ app.new_db(":memory:", engine="duckdb")
55
+ with tempfile.TemporaryDirectory(prefix='.tmp') as tmp_dir:
56
+ print(tmp_dir)
57
+ tmp_file = str(pathlib.PurePath(tmp_dir, 'sqlite_bro_test_Output.tmp'))
58
+ welcome_text = """
59
+ create table item (ItemNo TEXT, Description TEXT, PRIMARY KEY (ItemNo));
60
+ INSERT INTO item values('DS','Citroën');
61
+ .output %s
62
+ .separator ;
63
+ .headers off
64
+ .print a;b
65
+ select * from item;
66
+ .headers on
67
+ .separator !
68
+ select * from item;
69
+ .import %s in_this_table""" % (tmp_file, tmp_file)
70
+ app.n.new_query_tab("Welcome", welcome_text)
71
+ app.run_tab()
72
+ app.close_db
73
+
74
+ file_encoding = sqlite_bro.guess_encoding(tmp_file)[0]
75
+ with io.open(tmp_file, mode='rt', encoding=file_encoding) as f:
76
+ result = f.readlines()
77
+ print(result)
78
+ assert len(result) == 4
79
+ assert result[0] == "a;b\n"
80
+ assert result[1] == "DS;Citroën\n"
81
+ assert result[2] == "ItemNo!Description\n"
82
+ assert result[3] == "DS!Citroën\n"
83
+
84
+
85
+ def test_Pydef():
86
+ "python function embedded in sql, on the duckdb engine (needs numpy)"
87
+ pytest.importorskip("numpy")
88
+ app.new_db(":memory:", engine="duckdb")
89
+ app.default_separator, app.default_header = ",", True # reset prior state
90
+ with tempfile.TemporaryDirectory(prefix='.tmp') as tmp_dir:
91
+ tmp_file = str(pathlib.PurePath(tmp_dir, 'sqlite_bro_test_Pydef.tmp'))
92
+ welcome_text = """
93
+ pydef py_dup(a):
94
+ "duplicate a string"
95
+ return a + a;
96
+ pydef py_fib(n):
97
+ "fibonacci, annotation-less, called with an int literal"
98
+ fib = lambda n: n if n < 2 else fib(n-1) + fib(n-2)
99
+ return("%%s" %% fib(n*1));
100
+ pydef py_fib_t(n: int) -> int:
101
+ "fibonacci, type-annotated : registered natively"
102
+ fib = lambda n: n if n < 2 else fib(n-1) + fib(n-2)
103
+ return fib(n);
104
+ .once %s
105
+ select py_dup('ab') as dup, py_fib(6) as fib, py_fib_t(7) as fib_t;""" % tmp_file
106
+ app.n.new_query_tab("Welcome", welcome_text)
107
+ app.run_tab()
108
+ app.run_tab() # re-run : pydef re-registration must not fail
109
+
110
+ file_encoding = sqlite_bro.guess_encoding(tmp_file)[0]
111
+ with io.open(tmp_file, mode='rt', encoding=file_encoding) as f:
112
+ result = f.readlines()
113
+ assert result[0] == "dup,fib,fib_t\n"
114
+ assert result[1] == "abab,8,13\n"
115
+ app.close_db
116
+
117
+
118
+ def test_WelcomeDemo():
119
+ "the DuckDB welcome demo must run error-free (sql + pydef part)"
120
+ pytest.importorskip("numpy")
121
+ app.new_db(":memory:", engine="duckdb")
122
+ app.output_mode, app.once_mode = False, False
123
+ # keep only the sql + pydef part : the '.' commands part writes to ~
124
+ demo = sqlite_bro.WELCOME_DUCKDB.split(".headers on")[0]
125
+ # a sentinel : it is only reached if no previous instruction errored
126
+ demo += "\ncreate table demo_ok as select 1 as ok;"
127
+ app.n.new_query_tab("DuckDB Memo", demo)
128
+ app.run_tab()
129
+ assert app.conn.execute("select ok from demo_ok").fetchall() == [(1,)]
130
+ assert app.conn.execute("select py_fib(6)").fetchall() == [('8',)]
131
+ assert app.conn.execute("select py_fib_typed(7)").fetchall() == [(13,)]
132
+ app.close_db
133
+
134
+
135
+ def test_Dump():
136
+ "iterdump must produce a replayable sql script"
137
+ app.new_db(":memory:", engine="duckdb")
138
+ app.output_mode, app.once_mode = False, False # reset state of prior tests
139
+ app.n.new_query_tab(
140
+ "Welcome",
141
+ """create table item (ItemNo TEXT, Kg INTEGER);
142
+ INSERT INTO item values('T',1000);
143
+ create view v1 as select * from item;""",
144
+ )
145
+ app.run_tab()
146
+ dump = "\n".join(app.conn.iterdump())
147
+ assert "CREATE TABLE item" in dump
148
+ assert "INSERT INTO \"item\" VALUES('T',1000);" in dump
149
+ assert "CREATE VIEW v1" in dump
150
+ app.close_db
@@ -0,0 +1,68 @@
1
+ # from pyappveyordemo.extension import some_function
2
+ import pytest
3
+ import pathlib
4
+ import tempfile
5
+
6
+ import io
7
+ from sqlite_bro import sqlite_bro
8
+ app = sqlite_bro.App(use_gui=False)
9
+ def test_DeBase():
10
+ "learning the ropes"
11
+ assert 1 == 1
12
+
13
+
14
+ def test_Basics():
15
+ "create script, run script, output result, check result"
16
+ app.new_db(":memory:")
17
+ with tempfile.TemporaryDirectory(prefix='.tmp') as tmp_dir:
18
+ print(tmp_dir)
19
+ tmp_file = str(pathlib.PurePath(tmp_dir, 'sqlite_bro_test_Basics.tmp'))
20
+ welcome_text = """
21
+ create table item (ItemNo, Description,Kg , PRIMARY KEY (ItemNo));
22
+ INSERT INTO item values("T","Ford",1000);
23
+ INSERT INTO item select "A","Merced",1250 union all select "W","Wheel",9 ;
24
+ .once %s
25
+ select ItemNo, Description, 1000*Kg Gramm from item order by ItemNo desc;
26
+ .import %s in_this_table""" % (tmp_file, tmp_file)
27
+ app.n.new_query_tab("Welcome", welcome_text)
28
+ app.run_tab()
29
+ app.close_db
30
+
31
+ file_encoding = sqlite_bro.guess_encoding(tmp_file)[0]
32
+ with io.open(tmp_file, mode='rt', encoding=file_encoding) as f:
33
+ result = f.readlines()
34
+ assert len(result) == 4
35
+ assert result[-1] == "A,Merced,1250000\n"
36
+
37
+ def test_Outputs():
38
+ "testing .output, .print, .header, .separator"
39
+
40
+ app.new_db(":memory:")
41
+ with tempfile.TemporaryDirectory(prefix='.tmp') as tmp_dir:
42
+ print(tmp_dir)
43
+ tmp_file = str(pathlib.PurePath(tmp_dir, 'sqlite_bro_test_Output.tmp'))
44
+ welcome_text = """
45
+ create table item (ItemNo, Description , PRIMARY KEY (ItemNo));
46
+ INSERT INTO item values("DS","Citroën");
47
+ .output %s
48
+ .separator ;
49
+ .headers off
50
+ .print a;b
51
+ select * from item;
52
+ .headers on
53
+ .separator !
54
+ select * from item;
55
+ .import %s in_this_table""" % (tmp_file, tmp_file)
56
+ app.n.new_query_tab("Welcome", welcome_text)
57
+ app.run_tab()
58
+ app.close_db
59
+
60
+ file_encoding = sqlite_bro.guess_encoding(tmp_file)[0]
61
+ with io.open(tmp_file, mode='rt', encoding=file_encoding) as f:
62
+ result = f.readlines()
63
+ print(result)
64
+ assert len(result) == 4
65
+ assert result[0] == "a;b\n"
66
+ assert result[1] == "DS;Citroën\n"
67
+ assert result[2] == "ItemNo!Description\n"
68
+ assert result[3] == "DS!Citroën\n"
@@ -1 +0,0 @@
1
- __version__ = '0.13.1'
File without changes