sqlite3-client 1.0.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.
@@ -0,0 +1,642 @@
1
+ """database client"""
2
+
3
+ import atexit
4
+ import csv
5
+ import io
6
+ import queue
7
+ import sqlite3
8
+ import threading
9
+ import time
10
+ from collections.abc import AsyncGenerator, Generator
11
+ from datetime import datetime
12
+
13
+ from .query_by_key.query import Query
14
+
15
+ # pylint: disable=relative-beyond-top-level
16
+ from .query_by_key.query_util import (
17
+ get_query_with_value,
18
+ )
19
+ from .query_by_key.settings import Settings as QrySettings
20
+ from .settings import Settings
21
+
22
+
23
+ class RealDictRow(dict):
24
+ """RealDictRow mimicking psycopg2.extras.RealDictRow"""
25
+
26
+ pass
27
+
28
+
29
+ class RealDictCursor(sqlite3.Cursor):
30
+ """RealDictCursor mimicking psycopg2.extras.RealDictCursor"""
31
+
32
+ pass
33
+
34
+
35
+ connection = sqlite3.Connection
36
+
37
+
38
+ def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> RealDictRow:
39
+ """Row factory that returns rows as RealDictRow (dict subclass)"""
40
+ fields = [col[0] for col in cursor.description]
41
+ return RealDictRow(zip(fields, row))
42
+
43
+
44
+ class ClientPool:
45
+ """database connection pool"""
46
+
47
+ def __init__(self, db_settings_pool: Settings):
48
+ self.db_settings_pool = db_settings_pool
49
+ self._lock = threading.Lock()
50
+ self._pool: queue.Queue[sqlite3.Connection] = queue.Queue(
51
+ maxsize=db_settings_pool.maxconn
52
+ )
53
+ self._all_conns: list[sqlite3.Connection] = []
54
+ self._closed = False
55
+
56
+ for _ in range(db_settings_pool.minconn):
57
+ conn = self._create_connection()
58
+ self._pool.put(conn)
59
+ self._all_conns.append(conn)
60
+
61
+ print(datetime.now(), self.__class__.__name__, self.__init__.__name__)
62
+
63
+ def _create_connection(self) -> sqlite3.Connection:
64
+ uri = (
65
+ self.db_settings_pool.database.startswith("file:")
66
+ or "?" in self.db_settings_pool.database
67
+ )
68
+ timeout = getattr(
69
+ self.db_settings_pool,
70
+ "timeout",
71
+ self.db_settings_pool.connect_timeout,
72
+ )
73
+ conn = sqlite3.connect(
74
+ self.db_settings_pool.database,
75
+ timeout=timeout,
76
+ check_same_thread=False,
77
+ uri=uri,
78
+ )
79
+ conn.row_factory = dict_factory
80
+ if not self.db_settings_pool.database.startswith(":memory:"):
81
+ try:
82
+ conn.execute("PRAGMA journal_mode=WAL;")
83
+ except Exception:
84
+ pass
85
+ return conn
86
+
87
+ def __exit__(self, exc_type, exc_value, traceback):
88
+ """Close the shared connection pool."""
89
+ self.closeall()
90
+ print(datetime.now(), self.__class__.__name__, self.__exit__.__name__)
91
+
92
+ def getconn(self) -> sqlite3.Connection:
93
+ """return conn_pool"""
94
+ with self._lock:
95
+ if self._closed:
96
+ raise RuntimeError("Connection pool is closed")
97
+ try:
98
+ return self._pool.get_nowait()
99
+ except queue.Empty:
100
+ if len(self._all_conns) < self.db_settings_pool.maxconn:
101
+ conn = self._create_connection()
102
+ self._all_conns.append(conn)
103
+ return conn
104
+ try:
105
+ return self._pool.get(timeout=self.db_settings_pool.connect_timeout)
106
+ except queue.Empty:
107
+ raise TimeoutError("Timeout waiting for connection from pool") from None
108
+
109
+ def putconn(self, conn: sqlite3.Connection):
110
+ """putconn"""
111
+ with self._lock:
112
+ if self._closed:
113
+ try:
114
+ conn.close()
115
+ except Exception:
116
+ pass
117
+ return
118
+ try:
119
+ self._pool.put_nowait(conn)
120
+ except queue.Full:
121
+ try:
122
+ conn.close()
123
+ except Exception:
124
+ pass
125
+ if conn in self._all_conns:
126
+ self._all_conns.remove(conn)
127
+
128
+ def closeall(self):
129
+ """close all connections in pool"""
130
+ with self._lock:
131
+ self._closed = True
132
+ for conn in self._all_conns:
133
+ try:
134
+ conn.close()
135
+ except Exception:
136
+ pass
137
+ self._all_conns.clear()
138
+ while not self._pool.empty():
139
+ try:
140
+ self._pool.get_nowait()
141
+ except queue.Empty:
142
+ break
143
+
144
+
145
+ db_set_and_pool: dict[str, ClientPool] = {}
146
+
147
+
148
+ class Client:
149
+ """database client"""
150
+
151
+ # Class-level shared connection pool
152
+ _conn_pool: ClientPool
153
+
154
+ def __init__(self, db_settings: Settings):
155
+ # pylint:disable=global-statement,global-variable-not-assigned
156
+ global db_set_and_pool
157
+
158
+ self.conn: connection
159
+ self.in_with_block = False
160
+ self.db_settings = db_settings
161
+ self.qry = Query(
162
+ qry_settings=QrySettings(
163
+ use_en_ko_column_alias=db_settings.use_en_ko_column_alias,
164
+ use_conditional=db_settings.use_conditional,
165
+ all_query=db_settings.all_query,
166
+ )
167
+ )
168
+ self.query_recent = ""
169
+
170
+ db_set_key = db_settings.key
171
+ if db_set_key not in db_set_and_pool:
172
+ client_pool = ClientPool(db_settings)
173
+ db_set_and_pool[db_set_key] = client_pool
174
+ Client._conn_pool = client_pool
175
+
176
+ def __enter__(self):
177
+ # Called when entering the 'with' block
178
+ self.conn = Client._conn_pool.getconn()
179
+ self.in_with_block = True
180
+ return self
181
+
182
+ def __exit__(self, exc_type, exc_value, traceback):
183
+ # Called when exiting the 'with' block
184
+ try:
185
+ if exc_type is None:
186
+ # No exception, commit the transaction
187
+ self.conn.commit()
188
+ else:
189
+ # Exception occurred, rollback the transaction
190
+ self.conn.rollback()
191
+ finally:
192
+ if self.conn:
193
+ self._conn_pool.putconn(self.conn)
194
+
195
+ self.in_with_block = False
196
+
197
+ def read_rows(
198
+ self,
199
+ qry_key: str,
200
+ params: dict,
201
+ *,
202
+ en: bool = False,
203
+ fetchone: bool = False,
204
+ ) -> list[RealDictRow]:
205
+ """Returns all rows
206
+
207
+ Arguments:
208
+ qry_key: Key of the Dictionary registered in the clients/queries folder
209
+ params: Key, Value pairs to pass as parameters to the SQL query.
210
+
211
+ Returns:
212
+ a List of Dictionaries;
213
+ """
214
+
215
+ def read_rows_by_param(
216
+ qry_key: str,
217
+ params: dict,
218
+ *,
219
+ en: bool = False,
220
+ fetchone: bool = False,
221
+ cursor: sqlite3.Cursor,
222
+ ):
223
+ if not isinstance(params, dict):
224
+ params = vars(params)
225
+
226
+ qry_str = self.qry.get_query_by_key(qry_key, params, "read", en)
227
+
228
+ start = 0
229
+ if self.db_settings.before_read_execute:
230
+ self.db_settings.before_read_execute(
231
+ qry_key,
232
+ params,
233
+ qry_str,
234
+ get_query_with_value(qry_str, params),
235
+ )
236
+ start = time.time()
237
+
238
+ rows: list[RealDictRow] = []
239
+ cursor.execute(qry_str, params)
240
+
241
+ if not fetchone:
242
+ rows = cursor.fetchall()
243
+ else:
244
+ row = cursor.fetchone()
245
+ if row:
246
+ rows.append(row)
247
+
248
+ if self.db_settings.after_read_execute:
249
+ duration = int(round((time.time() - start) * 1000))
250
+ self.db_settings.after_read_execute(qry_key, duration)
251
+
252
+ if not rows:
253
+ return rows
254
+
255
+ return rows
256
+
257
+ rows: list[RealDictRow] = []
258
+ if self.in_with_block:
259
+ cursor = self.conn.cursor()
260
+ rows = read_rows_by_param(
261
+ qry_key,
262
+ params,
263
+ en=en,
264
+ fetchone=fetchone,
265
+ cursor=cursor,
266
+ )
267
+ else:
268
+ conn_pool = Client._conn_pool
269
+ conn = conn_pool.getconn()
270
+ cursor = conn.cursor()
271
+ try:
272
+ rows = read_rows_by_param(
273
+ qry_key,
274
+ params,
275
+ en=en,
276
+ fetchone=fetchone,
277
+ cursor=cursor,
278
+ )
279
+ finally:
280
+ cursor.close()
281
+ conn_pool.putconn(conn)
282
+
283
+ return rows
284
+
285
+ def read_row(
286
+ self,
287
+ qry_key: str,
288
+ params: dict,
289
+ *,
290
+ en: bool = False,
291
+ ) -> RealDictRow | None:
292
+ """call read_rows"""
293
+
294
+ rows = self.read_rows(
295
+ qry_key,
296
+ params,
297
+ en=en,
298
+ fetchone=True,
299
+ )
300
+ if not rows:
301
+ return None
302
+
303
+ return rows[0]
304
+
305
+ async def read_csv_partial_async(
306
+ self,
307
+ qry_key: str,
308
+ params: dict,
309
+ *,
310
+ row_count_partial: int = 100,
311
+ en: bool = False,
312
+ ) -> AsyncGenerator[bytes]:
313
+ """Return rows partially in batches with async
314
+
315
+ Arguments:
316
+ qry_key: key of the Dictionary registered in the clients/queries folder
317
+ params: key, value pairs to pass as parameters to the SQL query.
318
+ row_count_partial: Number of rows to return at a time
319
+
320
+ Returns:
321
+ CSV format converted to UTF-8-BOM
322
+ """
323
+
324
+ async def read_csv_partial_async_by_param(
325
+ qry_key: str,
326
+ params: dict,
327
+ *,
328
+ row_count_partial: int = 100,
329
+ en: bool = False,
330
+ cursor: sqlite3.Cursor,
331
+ ) -> AsyncGenerator[bytes]:
332
+ if not isinstance(params, dict):
333
+ params = vars(params)
334
+
335
+ qry_str = self.qry.get_query_by_key(qry_key, params, "csv", en)
336
+
337
+ is_second = False
338
+
339
+ # without UTF-8 BOM, hangul will be broken.
340
+ utf8_bom = b"\xef\xbb\xbf"
341
+ yield utf8_bom
342
+
343
+ start = 0
344
+ if self.db_settings.before_read_execute:
345
+ self.db_settings.before_read_execute(
346
+ qry_key,
347
+ params,
348
+ qry_str,
349
+ get_query_with_value(qry_str, params),
350
+ )
351
+ start = time.time()
352
+
353
+ cursor.execute(qry_str, params)
354
+ while True:
355
+ rows = (
356
+ cursor.fetchmany(row_count_partial)
357
+ if hasattr(cursor, "fetchmany")
358
+ else cursor.fetchall()
359
+ )
360
+
361
+ if not is_second:
362
+ if self.db_settings.after_read_execute:
363
+ duration = int(round((time.time() - start) * 1000))
364
+ self.db_settings.after_read_execute(qry_key, duration)
365
+
366
+ if not rows:
367
+ break
368
+
369
+ csv_out = io.StringIO()
370
+ csv_w = csv.writer(csv_out)
371
+ if not is_second and cursor.description:
372
+ column_names = [desc[0] for desc in cursor.description]
373
+ csv_w.writerow(column_names)
374
+ csv_w.writerows(
375
+ [list(r.values()) if isinstance(r, dict) else r for r in rows]
376
+ )
377
+
378
+ yield csv_out.getvalue().encode("utf-8")
379
+
380
+ is_second = True
381
+
382
+ if self.in_with_block:
383
+ cursor = self.conn.cursor()
384
+ async for value in read_csv_partial_async_by_param(
385
+ qry_key,
386
+ params,
387
+ row_count_partial=row_count_partial,
388
+ en=en,
389
+ cursor=cursor,
390
+ ):
391
+ yield value
392
+ else:
393
+ conn_pool = Client._conn_pool
394
+ conn = conn_pool.getconn()
395
+ cursor = conn.cursor()
396
+ try:
397
+ async for value in read_csv_partial_async_by_param(
398
+ qry_key,
399
+ params,
400
+ row_count_partial=row_count_partial,
401
+ en=en,
402
+ cursor=cursor,
403
+ ):
404
+ yield value
405
+ finally:
406
+ cursor.close()
407
+ conn_pool.putconn(conn)
408
+
409
+ def read_csv_partial(
410
+ self,
411
+ qry_key: str,
412
+ params: dict,
413
+ *,
414
+ row_count_partial: int = 100,
415
+ en: bool = False,
416
+ ) -> Generator[bytes]:
417
+ """Return rows partially in batches
418
+
419
+ Arguments:
420
+ qry_key: key of the Dictionary registered in the clients/queries folder
421
+ params: key, value pairs to pass as parameters to the SQL query.
422
+ row_count_partial: Number of rows to return at a time
423
+
424
+ Returns:
425
+ CSV format converted to UTF-8-BOM
426
+ """
427
+
428
+ def read_csv_partial_by_param(
429
+ qry_key: str,
430
+ params: dict,
431
+ *,
432
+ row_count_partial: int = 100,
433
+ en: bool = False,
434
+ cursor: sqlite3.Cursor,
435
+ ) -> Generator[bytes]:
436
+ if not isinstance(params, dict):
437
+ params = vars(params)
438
+
439
+ qry_str = self.qry.get_query_by_key(qry_key, params, "csv", en)
440
+
441
+ is_second = False
442
+
443
+ # without UTF-8 BOM, hangul will be broken.
444
+ utf8_bom = b"\xef\xbb\xbf"
445
+ yield utf8_bom
446
+
447
+ start = 0
448
+ if self.db_settings.before_read_execute:
449
+ self.db_settings.before_read_execute(
450
+ qry_key,
451
+ params,
452
+ qry_str,
453
+ get_query_with_value(qry_str, params),
454
+ )
455
+ start = time.time()
456
+
457
+ cursor.execute(qry_str, params)
458
+ while True:
459
+ rows = (
460
+ cursor.fetchmany(row_count_partial)
461
+ if hasattr(cursor, "fetchmany")
462
+ else cursor.fetchall()
463
+ )
464
+
465
+ if not is_second:
466
+ if self.db_settings.after_read_execute:
467
+ duration = int(round((time.time() - start) * 1000))
468
+ self.db_settings.after_read_execute(qry_key, duration)
469
+ if not rows:
470
+ break
471
+
472
+ csv_out = io.StringIO()
473
+ csv_w = csv.writer(csv_out)
474
+ if not is_second and cursor.description:
475
+ column_names = [desc[0] for desc in cursor.description]
476
+ csv_w.writerow(column_names)
477
+ csv_w.writerows(
478
+ [list(r.values()) if isinstance(r, dict) else r for r in rows]
479
+ )
480
+
481
+ yield csv_out.getvalue().encode("utf-8")
482
+
483
+ is_second = True
484
+
485
+ if self.in_with_block:
486
+ cursor = self.conn.cursor()
487
+ yield from read_csv_partial_by_param(
488
+ qry_key,
489
+ params,
490
+ row_count_partial=row_count_partial,
491
+ en=en,
492
+ cursor=cursor,
493
+ )
494
+ else:
495
+ conn_pool = Client._conn_pool
496
+ conn = conn_pool.getconn()
497
+ cursor = conn.cursor()
498
+ try:
499
+ yield from read_csv_partial_by_param(
500
+ qry_key,
501
+ params,
502
+ row_count_partial=row_count_partial,
503
+ en=en,
504
+ cursor=cursor,
505
+ )
506
+ finally:
507
+ cursor.close()
508
+ conn_pool.putconn(conn)
509
+
510
+ def updates(
511
+ self,
512
+ qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
513
+ ) -> list[int]:
514
+ """Executes a list of SQL statements within a single transaction.
515
+ If all SQL commands succeed, returns a list of the number of rows affected by each qry_key.
516
+ If any command fails, an error is raised.
517
+
518
+ Arguments:
519
+ qry_key_params_list: A list of tuples, each containing the following two values:
520
+ qry_key: key of the dictionary registered in the clients/queries folder
521
+ params: key, value pairs to pass as parameters to the SQL query.
522
+
523
+ Returns:
524
+ A list of the number of rows affected
525
+ """
526
+
527
+ def normalize_qry_key_params_list(
528
+ qry_key_params_list: list[any], # type: ignore
529
+ ) -> list[tuple[str, dict, dict]]:
530
+ """normalize all item from parameter of Sqlite3Client.updates"""
531
+
532
+ qry_key_params_list_new: list[tuple[str, dict, dict]] = []
533
+ for item in qry_key_params_list:
534
+ # append params_out if not exists
535
+ item_new: tuple[str, dict, dict] = (
536
+ item if len(item) == 3 else (item[0], item[1], {})
537
+ )
538
+
539
+ qry_key, params, params_out = item_new
540
+ if not isinstance(params, dict):
541
+ params: dict = vars(params)
542
+
543
+ if params_out is None:
544
+ params_out = {}
545
+ if not isinstance(params_out, dict):
546
+ params_out: dict = vars(params_out)
547
+
548
+ qry_key_params_list_new.append((qry_key, params, params_out))
549
+
550
+ return qry_key_params_list_new
551
+
552
+ def updates_by_param(
553
+ qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
554
+ cursor: sqlite3.Cursor,
555
+ ) -> list[int]:
556
+ row_counts: list[int] = []
557
+ qry_strs: list[str] = []
558
+
559
+ qry_key_params_list_new = normalize_qry_key_params_list(qry_key_params_list)
560
+
561
+ for item in qry_key_params_list_new:
562
+ qry_key, params, params_out = item
563
+
564
+ qry_str = self.qry.get_query_by_key(qry_key, params, "update")
565
+
566
+ start = 0
567
+ if self.db_settings.before_update_execute:
568
+ self.db_settings.before_update_execute(
569
+ qry_key,
570
+ params,
571
+ params_out,
572
+ qry_str,
573
+ get_query_with_value(qry_str, params),
574
+ )
575
+ start = time.time()
576
+
577
+ cursor.execute(qry_str, params)
578
+
579
+ if cursor.description:
580
+ rows = cursor.fetchall()
581
+ if params_out and rows:
582
+ row = rows[0]
583
+ for k, v in row.items():
584
+ if k in params_out:
585
+ params_out[k] = v
586
+
587
+ row_count = max(0, cursor.rowcount)
588
+
589
+ if self.db_settings.after_update_execute:
590
+ duration = int(round((time.time() - start) * 1000))
591
+ self.db_settings.after_update_execute(
592
+ qry_key, row_count, params_out, duration
593
+ )
594
+
595
+ row_counts.append(row_count)
596
+ qry_strs.append(qry_str)
597
+
598
+ return row_counts
599
+
600
+ row_counts: list[int] = []
601
+ if self.in_with_block:
602
+ cursor = self.conn.cursor()
603
+ row_counts = updates_by_param(qry_key_params_list, cursor)
604
+ else:
605
+ conn_pool = Client._conn_pool
606
+ conn = conn_pool.getconn()
607
+ try:
608
+ with conn:
609
+ cursor = conn.cursor()
610
+ row_counts = updates_by_param(qry_key_params_list, cursor)
611
+ cursor.close()
612
+ finally:
613
+ conn_pool.putconn(conn)
614
+
615
+ return row_counts
616
+
617
+ def update(
618
+ self,
619
+ qry_key: str,
620
+ params: dict,
621
+ # pylint: disable=dangerous-default-value
622
+ params_out: dict = {},
623
+ ) -> int:
624
+ """call updates"""
625
+
626
+ row_counts = self.updates([(qry_key, params, params_out)])
627
+ return row_counts[0] if row_counts else 0
628
+
629
+
630
+ @atexit.register
631
+ def close_all_connection():
632
+ """call when python exits"""
633
+
634
+ # pylint:disable=global-statement
635
+ global db_set_and_pool
636
+
637
+ for v in db_set_and_pool.values():
638
+ if v:
639
+ if hasattr(v, "closeall"):
640
+ v.closeall()
641
+ v = None
642
+ db_set_and_pool = {}
@@ -0,0 +1,52 @@
1
+ """client_util"""
2
+
3
+ import json
4
+ from datetime import date, datetime
5
+ from typing import Literal
6
+
7
+ # pylint: disable=relative-beyond-top-level
8
+ from .query_util import get_conditional, replace_en_ko_column_alias
9
+ from .settings import Settings
10
+
11
+
12
+ class Query:
13
+ """query"""
14
+
15
+ def __init__(self, qry_settings: Settings):
16
+ self.qry_settings = qry_settings
17
+
18
+ def get_query_by_key(
19
+ self,
20
+ qry_key: str,
21
+ params: dict,
22
+ func_type: Literal["update", "read", "csv"],
23
+ en: bool = False,
24
+ ) -> str:
25
+ """get query string by qry_key"""
26
+
27
+ def serial_date(obj):
28
+ """JSON serializer for objects not serializable by default json code"""
29
+
30
+ if isinstance(obj, (datetime, date)):
31
+ return obj.isoformat()
32
+ return str(obj)
33
+
34
+ query = self.qry_settings.all_query.get(qry_key)
35
+ if not query:
36
+ raise KeyError(f"{qry_key} not exists")
37
+
38
+ info = {
39
+ "qry_key": qry_key,
40
+ "params": params,
41
+ "func_type": func_type,
42
+ "en": en,
43
+ }
44
+
45
+ if self.qry_settings.use_en_ko_column_alias and isinstance(en, bool):
46
+ query = replace_en_ko_column_alias(query, en)
47
+ if self.qry_settings.use_conditional and "#if" in query:
48
+ query = get_conditional(query, params)
49
+
50
+ return (
51
+ f"/* {json.dumps(info, ensure_ascii=False, default=serial_date)} */{query}"
52
+ )