mysqlclient-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,594 @@
1
+ """database client"""
2
+
3
+ import atexit
4
+ import csv
5
+ import io
6
+ import queue
7
+ import threading
8
+ import time
9
+ from collections.abc import AsyncGenerator, Generator
10
+ from datetime import UTC, datetime
11
+
12
+ import MySQLdb
13
+ import MySQLdb.cursors
14
+
15
+ from .query_by_key.query import Query
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
+ connection = MySQLdb.Connection
23
+
24
+
25
+ class ClientPool:
26
+ """database connection pool"""
27
+
28
+ def __init__(self, db_settings_pool: Settings):
29
+ self.db_settings_pool = db_settings_pool
30
+ self._lock = threading.Lock()
31
+ self._pool: queue.Queue[MySQLdb.Connection] = queue.Queue(
32
+ maxsize=db_settings_pool.maxconn
33
+ )
34
+ self._all_conns: list[MySQLdb.Connection] = []
35
+ self._closed = False
36
+
37
+ for _ in range(db_settings_pool.minconn):
38
+ conn = self._create_connection()
39
+ self._pool.put(conn)
40
+ self._all_conns.append(conn)
41
+
42
+ print(datetime.now(UTC), self.__class__.__name__, self.__init__.__name__)
43
+
44
+ def _create_connection(self) -> MySQLdb.Connection:
45
+ conn = MySQLdb.connect(
46
+ host=self.db_settings_pool.host,
47
+ port=self.db_settings_pool.port,
48
+ user=self.db_settings_pool.user,
49
+ passwd=self.db_settings_pool.password,
50
+ db=self.db_settings_pool.database,
51
+ connect_timeout=self.db_settings_pool.connect_timeout,
52
+ charset="utf8mb4",
53
+ autocommit=False,
54
+ cursorclass=MySQLdb.cursors.DictCursor,
55
+ )
56
+ return conn
57
+
58
+ def __exit__(self, exc_type, exc_value, traceback):
59
+ """Close the shared connection pool."""
60
+ self.closeall()
61
+ print(datetime.now(UTC), self.__class__.__name__, self.__exit__.__name__)
62
+
63
+ def getconn(self) -> MySQLdb.Connection:
64
+ """return conn_pool"""
65
+ with self._lock:
66
+ if self._closed:
67
+ raise RuntimeError("Connection pool is closed")
68
+ try:
69
+ conn = self._pool.get_nowait()
70
+ try:
71
+ conn.ping(True)
72
+ except Exception:
73
+ conn = self._create_connection()
74
+ return conn
75
+ except queue.Empty:
76
+ if len(self._all_conns) < self.db_settings_pool.maxconn:
77
+ conn = self._create_connection()
78
+ self._all_conns.append(conn)
79
+ return conn
80
+ try:
81
+ conn = self._pool.get(timeout=self.db_settings_pool.connect_timeout)
82
+ try:
83
+ conn.ping(True)
84
+ except Exception:
85
+ conn = self._create_connection()
86
+ return conn
87
+ except queue.Empty:
88
+ raise TimeoutError("Timeout waiting for connection from pool") from None
89
+
90
+ def putconn(self, conn: MySQLdb.Connection):
91
+ """putconn"""
92
+ with self._lock:
93
+ if self._closed:
94
+ try:
95
+ conn.close()
96
+ except Exception:
97
+ pass
98
+ return
99
+ try:
100
+ self._pool.put_nowait(conn)
101
+ except queue.Full:
102
+ try:
103
+ conn.close()
104
+ except Exception:
105
+ pass
106
+ if conn in self._all_conns:
107
+ self._all_conns.remove(conn)
108
+
109
+ def closeall(self):
110
+ """close all connections in pool"""
111
+ with self._lock:
112
+ self._closed = True
113
+ for conn in self._all_conns:
114
+ try:
115
+ conn.close()
116
+ except Exception:
117
+ pass
118
+ self._all_conns.clear()
119
+ while not self._pool.empty():
120
+ try:
121
+ self._pool.get_nowait()
122
+ except queue.Empty:
123
+ break
124
+
125
+
126
+ db_set_and_pool: dict[str, ClientPool] = {}
127
+
128
+
129
+ def cleanup():
130
+ for pool_inst in db_set_and_pool.values():
131
+ try:
132
+ pool_inst.closeall()
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ atexit.register(cleanup)
138
+
139
+
140
+ class Client:
141
+ """database client"""
142
+
143
+ # Class-level shared connection pool
144
+ _conn_pool: ClientPool
145
+
146
+ def __init__(self, db_settings: Settings):
147
+ self.conn: connection
148
+ self.in_with_block = False
149
+ self.db_settings = db_settings
150
+ self.qry = Query(
151
+ qry_settings=QrySettings(
152
+ use_en_ko_column_alias=db_settings.use_en_ko_column_alias,
153
+ use_conditional=db_settings.use_conditional,
154
+ all_query=db_settings.all_query,
155
+ )
156
+ )
157
+ self.query_recent = ""
158
+
159
+ db_set_key = db_settings.key
160
+ if db_set_key not in db_set_and_pool:
161
+ client_pool = ClientPool(db_settings)
162
+ db_set_and_pool[db_set_key] = client_pool
163
+ Client._conn_pool = client_pool
164
+
165
+ def __enter__(self):
166
+ # Called when entering the 'with' block
167
+ self.conn = Client._conn_pool.getconn()
168
+ self.in_with_block = True
169
+ return self
170
+
171
+ def __exit__(self, exc_type, exc_value, traceback):
172
+ # Called when exiting the 'with' block
173
+ try:
174
+ if exc_type is None:
175
+ # No exception, commit the transaction
176
+ self.conn.commit()
177
+ else:
178
+ # Exception occurred, roll back the transaction
179
+ self.conn.rollback()
180
+ finally:
181
+ Client._conn_pool.putconn(self.conn)
182
+ self.in_with_block = False
183
+
184
+ def read_row(
185
+ self,
186
+ qry_key: str,
187
+ params: dict,
188
+ *,
189
+ en: bool = False,
190
+ ) -> dict | None:
191
+ """Return single row"""
192
+
193
+ rows = self.read_rows(qry_key, params, en=en)
194
+ if not rows:
195
+ return None
196
+
197
+ return rows[0]
198
+
199
+ def read_rows(
200
+ self,
201
+ qry_key: str,
202
+ params: dict,
203
+ *,
204
+ en: bool = False,
205
+ ) -> list[dict]:
206
+ """Return all rows"""
207
+
208
+ def read_rows_by_param(
209
+ qry_key: str,
210
+ params: dict,
211
+ *,
212
+ en: bool = False,
213
+ cursor: MySQLdb.cursors.DictCursor,
214
+ ) -> list[dict]:
215
+ if not isinstance(params, dict):
216
+ params = vars(params)
217
+
218
+ qry_str = self.qry.get_query_by_key(qry_key, params, "read", en)
219
+ self.query_recent = qry_str
220
+
221
+ start = 0
222
+ if self.db_settings.before_read_execute:
223
+ self.db_settings.before_read_execute(
224
+ qry_key,
225
+ params,
226
+ qry_str,
227
+ get_query_with_value(qry_str, params),
228
+ )
229
+ start = time.time()
230
+
231
+ cursor.execute(qry_str, params)
232
+ rows = cursor.fetchall()
233
+
234
+ if self.db_settings.after_read_execute:
235
+ duration = round((time.time() - start) * 1000)
236
+ self.db_settings.after_read_execute(qry_key, duration)
237
+
238
+ if not rows:
239
+ return []
240
+
241
+ return list(rows)
242
+
243
+ rows: list[dict] = []
244
+ if self.in_with_block:
245
+ cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
246
+ rows = read_rows_by_param(qry_key, params, en=en, cursor=cursor)
247
+ cursor.close()
248
+ else:
249
+ conn_pool = Client._conn_pool
250
+ conn = conn_pool.getconn()
251
+ try:
252
+ cursor = conn.cursor(MySQLdb.cursors.DictCursor)
253
+ rows = read_rows_by_param(qry_key, params, en=en, cursor=cursor)
254
+ cursor.close()
255
+ finally:
256
+ conn_pool.putconn(conn)
257
+
258
+ return rows
259
+
260
+ async def read_csv_partial_async(
261
+ self,
262
+ qry_key: str,
263
+ params: dict,
264
+ *,
265
+ row_count_partial: int = 100,
266
+ en: bool = False,
267
+ ) -> AsyncGenerator[bytes]:
268
+ """Return rows partially in batches with async
269
+
270
+ Arguments:
271
+ qry_key: key of the Dictionary registered in the clients/queries folder
272
+ params: key, value pairs to pass as parameters to the SQL query.
273
+ row_count_partial: Number of rows to return at a time
274
+
275
+ Returns:
276
+ CSV format converted to UTF-8-BOM
277
+ """
278
+
279
+ async def read_csv_partial_async_by_param(
280
+ qry_key: str,
281
+ params: dict,
282
+ *,
283
+ row_count_partial: int = 100,
284
+ en: bool = False,
285
+ cursor: MySQLdb.cursors.DictCursor,
286
+ ) -> AsyncGenerator[bytes]:
287
+ if not isinstance(params, dict):
288
+ params = vars(params)
289
+
290
+ qry_str = self.qry.get_query_by_key(qry_key, params, "csv", en)
291
+
292
+ is_second = False
293
+
294
+ # without UTF-8 BOM, hangul will be broken.
295
+ utf8_bom = b"\xef\xbb\xbf"
296
+ yield utf8_bom
297
+
298
+ start = 0
299
+ if self.db_settings.before_read_execute:
300
+ self.db_settings.before_read_execute(
301
+ qry_key,
302
+ params,
303
+ qry_str,
304
+ get_query_with_value(qry_str, params),
305
+ )
306
+ start = time.time()
307
+
308
+ cursor.execute(qry_str, params)
309
+ while True:
310
+ rows = (
311
+ cursor.fetchmany(row_count_partial)
312
+ if hasattr(cursor, "fetchmany")
313
+ else cursor.fetchall()
314
+ )
315
+
316
+ if not is_second and self.db_settings.after_read_execute:
317
+ duration = round((time.time() - start) * 1000)
318
+ self.db_settings.after_read_execute(qry_key, duration)
319
+
320
+ if not rows:
321
+ break
322
+
323
+ csv_out = io.StringIO()
324
+ csv_w = csv.writer(csv_out)
325
+ if not is_second and cursor.description:
326
+ column_names = [desc[0] for desc in cursor.description]
327
+ csv_w.writerow(column_names)
328
+ csv_w.writerows(
329
+ [list(r.values()) if isinstance(r, dict) else r for r in rows]
330
+ )
331
+
332
+ yield csv_out.getvalue().encode("utf-8")
333
+
334
+ is_second = True
335
+
336
+ if self.in_with_block:
337
+ cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
338
+ try:
339
+ async for value in read_csv_partial_async_by_param(
340
+ qry_key,
341
+ params,
342
+ row_count_partial=row_count_partial,
343
+ en=en,
344
+ cursor=cursor,
345
+ ):
346
+ yield value
347
+ finally:
348
+ cursor.close()
349
+ else:
350
+ conn_pool = Client._conn_pool
351
+ conn = conn_pool.getconn()
352
+ try:
353
+ cursor = conn.cursor(MySQLdb.cursors.DictCursor)
354
+ try:
355
+ async for value in read_csv_partial_async_by_param(
356
+ qry_key,
357
+ params,
358
+ row_count_partial=row_count_partial,
359
+ en=en,
360
+ cursor=cursor,
361
+ ):
362
+ yield value
363
+ finally:
364
+ cursor.close()
365
+ finally:
366
+ conn_pool.putconn(conn)
367
+
368
+ def read_csv_partial(
369
+ self,
370
+ qry_key: str,
371
+ params: dict,
372
+ *,
373
+ row_count_partial: int = 100,
374
+ en: bool = False,
375
+ ) -> Generator[bytes]:
376
+ """Return rows partially in batches
377
+
378
+ Arguments:
379
+ qry_key: key of the Dictionary registered in the clients/queries folder
380
+ params: key, value pairs to pass as parameters to the SQL query.
381
+ row_count_partial: Number of rows to return at a time
382
+
383
+ Returns:
384
+ CSV format converted to UTF-8-BOM
385
+ """
386
+
387
+ def read_csv_partial_by_param(
388
+ qry_key: str,
389
+ params: dict,
390
+ *,
391
+ row_count_partial: int = 100,
392
+ en: bool = False,
393
+ cursor: MySQLdb.cursors.DictCursor,
394
+ ) -> Generator[bytes]:
395
+ if not isinstance(params, dict):
396
+ params = vars(params)
397
+
398
+ qry_str = self.qry.get_query_by_key(qry_key, params, "csv", en)
399
+
400
+ is_second = False
401
+
402
+ # without UTF-8 BOM, hangul will be broken.
403
+ utf8_bom = b"\xef\xbb\xbf"
404
+ yield utf8_bom
405
+
406
+ start = 0
407
+ if self.db_settings.before_read_execute:
408
+ self.db_settings.before_read_execute(
409
+ qry_key,
410
+ params,
411
+ qry_str,
412
+ get_query_with_value(qry_str, params),
413
+ )
414
+ start = time.time()
415
+
416
+ cursor.execute(qry_str, params)
417
+ while True:
418
+ rows = (
419
+ cursor.fetchmany(row_count_partial)
420
+ if hasattr(cursor, "fetchmany")
421
+ else cursor.fetchall()
422
+ )
423
+
424
+ if not is_second and self.db_settings.after_read_execute:
425
+ duration = round((time.time() - start) * 1000)
426
+ self.db_settings.after_read_execute(qry_key, duration)
427
+ if not rows:
428
+ break
429
+
430
+ csv_out = io.StringIO()
431
+ csv_w = csv.writer(csv_out)
432
+ if not is_second and cursor.description:
433
+ column_names = [desc[0] for desc in cursor.description]
434
+ csv_w.writerow(column_names)
435
+ csv_w.writerows(
436
+ [list(r.values()) if isinstance(r, dict) else r for r in rows]
437
+ )
438
+
439
+ yield csv_out.getvalue().encode("utf-8")
440
+
441
+ is_second = True
442
+
443
+ if self.in_with_block:
444
+ cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
445
+ try:
446
+ yield from read_csv_partial_by_param(
447
+ qry_key,
448
+ params,
449
+ row_count_partial=row_count_partial,
450
+ en=en,
451
+ cursor=cursor,
452
+ )
453
+ finally:
454
+ cursor.close()
455
+ else:
456
+ conn_pool = Client._conn_pool
457
+ conn = conn_pool.getconn()
458
+ try:
459
+ cursor = conn.cursor(MySQLdb.cursors.DictCursor)
460
+ try:
461
+ yield from read_csv_partial_by_param(
462
+ qry_key,
463
+ params,
464
+ row_count_partial=row_count_partial,
465
+ en=en,
466
+ cursor=cursor,
467
+ )
468
+ finally:
469
+ cursor.close()
470
+ finally:
471
+ conn_pool.putconn(conn)
472
+
473
+ def update(
474
+ self,
475
+ qry_key: str,
476
+ params: dict,
477
+ params_out: dict | None = None,
478
+ ) -> int:
479
+ """call updates"""
480
+
481
+ params_out_used = params_out if params_out is not None else {}
482
+ row_counts = self.updates([(qry_key, params, params_out_used)])
483
+ return row_counts[0]
484
+
485
+ def updates(
486
+ self,
487
+ qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
488
+ ) -> list[int]:
489
+ """Executes a list of SQL statements within a single transaction.
490
+ If all SQL commands succeed, returns a list of the number of rows affected
491
+ by each qry_key.
492
+ If any command fails, an error is raised.
493
+
494
+ Arguments:
495
+ qry_key_params_list: A list of tuples, each containing following two values:
496
+ qry_key: key of the dictionary registered in the clients/queries folder
497
+ params: key, value pairs to pass as parameters to the SQL query.
498
+
499
+ Returns:
500
+ The number of rows affected by the last SQL query.
501
+ """
502
+
503
+ def normalize_qry_key_params_list(
504
+ qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
505
+ ) -> list[tuple[str, dict, dict]]:
506
+ qry_key_params_list_new: list[tuple[str, dict, dict]] = []
507
+ for item in qry_key_params_list:
508
+ if len(item) == 2:
509
+ qry_key, params = item
510
+ params_out = {}
511
+ else:
512
+ qry_key, params, params_out = item
513
+
514
+ if not isinstance(params, (dict, list, tuple)):
515
+ params = vars(params)
516
+
517
+ qry_key_params_list_new.append((qry_key, params, params_out))
518
+
519
+ return qry_key_params_list_new
520
+
521
+ def updates_by_param(
522
+ qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
523
+ cursor: MySQLdb.cursors.DictCursor,
524
+ ) -> list[int]:
525
+ row_counts: list[int] = []
526
+ qry_strs: list[str] = []
527
+
528
+ qry_key_params_list_new = normalize_qry_key_params_list(qry_key_params_list)
529
+
530
+ for item in qry_key_params_list_new:
531
+ qry_key, params, params_out = item
532
+
533
+ qry_str = self.qry.get_query_by_key(qry_key, params, "update")
534
+
535
+ start = 0
536
+ if self.db_settings.before_update_execute:
537
+ self.db_settings.before_update_execute(
538
+ qry_key,
539
+ params,
540
+ params_out,
541
+ qry_str,
542
+ get_query_with_value(qry_str, params),
543
+ )
544
+ start = time.time()
545
+
546
+ if isinstance(params, (list, tuple)):
547
+ cursor.executemany(qry_str, params)
548
+ else:
549
+ cursor.execute(qry_str, params)
550
+
551
+ row_count = cursor.rowcount
552
+
553
+ if params_out and cursor.description:
554
+ row = cursor.fetchone()
555
+ if row:
556
+ for k, v in row.items():
557
+ if k in params_out:
558
+ params_out[k] = v
559
+
560
+ if self.db_settings.after_update_execute:
561
+ duration = round((time.time() - start) * 1000)
562
+ self.db_settings.after_update_execute(
563
+ qry_key, row_count, params_out, duration
564
+ )
565
+
566
+ row_counts.append(row_count)
567
+ qry_strs.append(qry_str)
568
+
569
+ return row_counts
570
+
571
+ row_counts: list[int] = []
572
+ if self.in_with_block:
573
+ cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
574
+ try:
575
+ row_counts = updates_by_param(qry_key_params_list, cursor)
576
+ finally:
577
+ cursor.close()
578
+ else:
579
+ conn_pool = Client._conn_pool
580
+ conn = conn_pool.getconn()
581
+ try:
582
+ cursor = conn.cursor(MySQLdb.cursors.DictCursor)
583
+ try:
584
+ row_counts = updates_by_param(qry_key_params_list, cursor)
585
+ conn.commit()
586
+ except Exception:
587
+ conn.rollback()
588
+ raise
589
+ finally:
590
+ cursor.close()
591
+ finally:
592
+ conn_pool.putconn(conn)
593
+
594
+ return row_counts
@@ -0,0 +1,63 @@
1
+ """client_util"""
2
+
3
+ import json
4
+ from datetime import date, datetime
5
+ from typing import Literal
6
+
7
+ from .query_util import get_conditional, replace_en_ko_column_alias
8
+ from .settings import Settings
9
+
10
+
11
+ class Query:
12
+ """query"""
13
+
14
+ def __init__(self, qry_settings: Settings):
15
+ self.qry_settings = qry_settings
16
+
17
+ def get_query_by_key(
18
+ self,
19
+ qry_key: str,
20
+ params: dict,
21
+ func_type: Literal["update", "read", "csv"],
22
+ en: bool = False,
23
+ ) -> str:
24
+ """get query string by qry_key"""
25
+
26
+ def serial_date(obj):
27
+ """JSON serializer for objects not serializable by default json code"""
28
+
29
+ if isinstance(obj, (datetime, date)):
30
+ return obj.isoformat()
31
+ return str(obj)
32
+
33
+ query = self.qry_settings.all_query.get(qry_key)
34
+ if not query:
35
+ raise KeyError(f"{qry_key} not exists")
36
+
37
+ cond_params = (
38
+ params[0]
39
+ if isinstance(params, (list, tuple))
40
+ and params
41
+ and isinstance(params[0], dict)
42
+ else (params if isinstance(params, dict) else {})
43
+ )
44
+ info_params = (
45
+ f"[executemany: {len(params)} rows]"
46
+ if isinstance(params, (list, tuple)) and len(params) > 1
47
+ else params
48
+ )
49
+
50
+ info = {
51
+ "qry_key": qry_key,
52
+ "params": info_params,
53
+ "func_type": func_type,
54
+ "en": en,
55
+ }
56
+
57
+ if self.qry_settings.use_en_ko_column_alias and isinstance(en, bool):
58
+ query = replace_en_ko_column_alias(query, en)
59
+ if self.qry_settings.use_conditional and "#if" in query:
60
+ query = get_conditional(query, cond_params)
61
+
62
+ info_str = json.dumps(info, ensure_ascii=False, default=serial_date)
63
+ return f"/* {info_str.replace('%', '{percent}')} */{query}"
@@ -0,0 +1,144 @@
1
+ """client_util"""
2
+
3
+ import re
4
+ from datetime import datetime
5
+
6
+
7
+ def get_conditional(qry_str: str, params: dict) -> str:
8
+ """
9
+ return true or false part by condition.
10
+ `#if target == 'korea' ... #elif target == 'vietnam' ... #else ... #endif`
11
+ """
12
+
13
+ def eval_safe(to_eval: str, params: dict) -> bool:
14
+ """
15
+ # assert eval_safe('%(target)s != ""', {"target": ""}) is False
16
+ # assert eval_safe('"A" in %(targets)s', {"targets": ["A", "B"]}) is True
17
+ # assert eval_safe('"A" not in %(targets)s', {"targets": ["A", "B"]}) is False
18
+ # assert eval_safe("%(t)s in [i for i in range(10)]", {"t": 1}) is True
19
+ """
20
+
21
+ # remove '%(' and ')s' from %(target)s
22
+ to_eval = re.sub(r"%\((.*?)\)s", r"\1", to_eval)
23
+
24
+ # allow below:
25
+ # - string inside quotes
26
+ # - digit
27
+ to_check = re.sub(r"""(".*?"|'.*?'|\b\d+\b)""", "", to_eval)
28
+
29
+ param_set = {key for key in params}
30
+ op_set = {
31
+ "==",
32
+ "!=",
33
+ ">=",
34
+ "<=",
35
+ ">",
36
+ "<",
37
+ "in",
38
+ "not",
39
+ "and",
40
+ "or",
41
+ "[",
42
+ "]",
43
+ "(",
44
+ ")",
45
+ ",",
46
+ }
47
+
48
+ eval_set = set(to_check.split())
49
+ diff = eval_set - (param_set | op_set)
50
+ if diff:
51
+ raise ValueError(f"'{diff}' not in {param_set | op_set}")
52
+
53
+ is_include = bool(eval(to_eval, {}, params.copy()))
54
+ return is_include
55
+
56
+ lines = qry_str.split("\n")
57
+ rets = []
58
+ is_include = True
59
+ is_checked = False
60
+ for line in lines:
61
+ line_strip = line.strip()
62
+ if line_strip.startswith(("#if", "#elif")):
63
+ if not is_checked:
64
+ _, condition = line_strip.split(maxsplit=1)
65
+ is_include = eval_safe(condition, params.copy())
66
+ if is_include:
67
+ is_checked = True
68
+ else:
69
+ is_include = False
70
+ elif line_strip.startswith("#else"):
71
+ is_include = not is_checked
72
+ elif line_strip.startswith("#endif"):
73
+ is_include = True
74
+ is_checked = False
75
+ elif is_include:
76
+ rets.append(line)
77
+
78
+ return "\n".join(rets)
79
+
80
+
81
+ def rep_kv(query: str, tab_count: int, **kwargs) -> str:
82
+ """
83
+ replace {key} with value when `rev_ky("WHERE user_name = {key}", key="u.user_name")`
84
+ """
85
+
86
+ ret = query
87
+ ret = re.sub(r"^", " " * 4 * tab_count, ret, flags=re.MULTILINE)
88
+ for k, v in kwargs.items():
89
+ ret = ret.replace("{" + k + "}", str(v))
90
+
91
+ return ret
92
+
93
+
94
+ def get_query_with_value(qry_str: str, params: dict) -> str:
95
+ """replace raw query to value filled query"""
96
+
97
+ def escape_literal(value) -> str:
98
+ ret = ""
99
+ if isinstance(value, str):
100
+ ret = "'" + value.replace("'", "''") + "'"
101
+ elif isinstance(value, datetime):
102
+ ret = f"'{value.strftime('%Y-%m-%d %H:%M:%S.%f')}'"
103
+ elif isinstance(value, (list, tuple)):
104
+ ret = str(value)
105
+ elif value is None:
106
+ ret = "NULL"
107
+ else:
108
+ ret = str(value)
109
+ return ret
110
+
111
+ if isinstance(params, (list, tuple)):
112
+ if not params:
113
+ return qry_str
114
+ params = params[0] if isinstance(params[0], dict) else {}
115
+
116
+ query_replaced = qry_str
117
+ for key, value in params.items():
118
+ find = f"%({key})s"
119
+ if find in query_replaced:
120
+ replace = escape_literal(value)
121
+ query_replaced = query_replaced.replace(find, replace)
122
+ # %% -> % : mysqlclient / db-api
123
+ # {{}} -> {} : python
124
+ query_replaced = (
125
+ query_replaced.replace("%%", "%").replace("{{", "{").replace("}}", "}")
126
+ )
127
+
128
+ return query_replaced
129
+
130
+
131
+ def replace_en_ko_column_alias(qry_str: str, en: bool) -> str:
132
+ """ "
133
+ return en part or ko part separated by '|' using en variable
134
+ ex:
135
+ tbl.obj_nm "File Name|파일명"
136
+ ->
137
+ tbl.obj_nm "File Name"
138
+ """
139
+
140
+ pattern = r'(?P<ws>\s)"(?P<en>[^"]+)\|(?P<ko>[^"]+)"'
141
+ en_ko = "en" if en else "ko"
142
+ repl = rf'\g<ws>"\g<{en_ko}>"'
143
+ qry_str_new = re.sub(pattern, repl, qry_str, flags=re.MULTILINE | re.IGNORECASE)
144
+ return qry_str_new
@@ -0,0 +1,23 @@
1
+ """settings"""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, kw_only=True)
7
+ class Settings:
8
+ """query by key settings"""
9
+
10
+ use_en_ko_column_alias: bool
11
+ """SELECT file_name "File Name|파일명" """
12
+
13
+ use_conditional: bool
14
+ """
15
+ #if target == 'korea'
16
+ FROM tbl_korea
17
+ #else
18
+ FROM tbl_vietnam
19
+ #endif
20
+ """
21
+
22
+ all_query: dict[str, str]
23
+ """all query information"""
@@ -0,0 +1,61 @@
1
+ """settings"""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True, kw_only=True)
8
+ class Settings:
9
+ """db client settings"""
10
+
11
+ host: str
12
+ port: int
13
+ database: str
14
+ user: str
15
+ password: str
16
+
17
+ minconn: int
18
+ maxconn: int
19
+ connect_timeout: int
20
+
21
+ use_en_ko_column_alias: bool
22
+ """SELECT file_name "File Name|파일명" """
23
+ use_conditional: bool
24
+ """
25
+ #if target == 'korea'
26
+ FROM tbl_korea
27
+ #else
28
+ FROM tbl_vietnam
29
+ #endif
30
+ """
31
+ all_query: dict[str, str]
32
+ """all query information"""
33
+
34
+ before_read_execute: Callable[[str, dict, str, str], None]
35
+ """
36
+ qry_key: str, params: dict, qry_str: str, qry_with_value: str
37
+ """
38
+ after_read_execute: Callable[[str, int], None]
39
+ """
40
+ qry_key: str, duration: int
41
+ """
42
+ before_update_execute: Callable[
43
+ [str, dict, dict, str, str],
44
+ None,
45
+ ]
46
+ """
47
+ qry_key: str, params: dict, params_out: dict, qry_str: str, qry_with_value: str
48
+ """
49
+ after_update_execute: Callable[[str, int, dict, int], None]
50
+ """
51
+ qry_key: str, row_count: int, params_out: dict, duration: int
52
+ """
53
+
54
+ @property
55
+ def key(self):
56
+ """key for another dictionary"""
57
+
58
+ return (
59
+ f"{self.host},{self.port},{self.database},{self.user},{self.password}"
60
+ f"{self.minconn},{self.maxconn},{self.connect_timeout}"
61
+ )
@@ -0,0 +1,296 @@
1
+ Metadata-Version: 2.4
2
+ Name: mysqlclient-client
3
+ Version: 1.0.0
4
+ Summary: MySQL helper function to run MySQL query with #if support
5
+ Author-email: Gu Park <doctorgu@kakao.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 doctorgu
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: homepage, https://github.com/doctorgu/mysqlclient-client
29
+ Project-URL: repository, https://github.com/doctorgu/mysqlclient-client
30
+ Project-URL: documentation, https://github.com/doctorgu/mysqlclient-client
31
+ Keywords: mysqlclient client,mysqlclient helper,mysql helper
32
+ Requires-Python: >=3.11
33
+ Description-Content-Type: text/markdown
34
+ License-File: LICENSE
35
+ Requires-Dist: mysqlclient
36
+ Requires-Dist: pyhumps
37
+ Requires-Dist: typing-extensions
38
+ Requires-Dist: python-dotenv
39
+ Requires-Dist: pydantic-settings
40
+ Requires-Dist: Flask
41
+ Requires-Dist: PyYAML
42
+ Requires-Dist: ruff
43
+ Provides-Extra: test
44
+ Requires-Dist: pytest; extra == "test"
45
+ Requires-Dist: pytest-cov; extra == "test"
46
+ Requires-Dist: pytest-env; extra == "test"
47
+ Requires-Dist: pytest-mock; extra == "test"
48
+ Requires-Dist: pytest-asyncio; extra == "test"
49
+ Requires-Dist: build; extra == "test"
50
+ Dynamic: license-file
51
+
52
+ # MysqlclientClient — Modern MySQL Helper for Python
53
+
54
+ A lightweight, opinionated wrapper around **mysqlclient** (`MySQLdb`) with built-in support for:
55
+
56
+ - Connection pooling (`minconn` / `maxconn`)
57
+ - Query dictionary management
58
+ - Conditional SQL (`#if` / `#elif` / `#endif`)
59
+ - Bilingual column aliases (`en|ko`)
60
+ - Simple transaction handling via context manager
61
+ - Safe parameter binding (`%(param)s` syntax)
62
+ - Streaming CSV export support
63
+
64
+ > Successor-friendly alternative to raw mysqlclient with better developer experience.
65
+
66
+ ## Installation
67
+
68
+ ```bash
69
+ pip install mysqlclient-client
70
+ ```
71
+
72
+ > Note: `mysqlclient-client` is a custom helper class. See full source in repository.
73
+
74
+ ## Quick Start
75
+
76
+ ### 1. Define Queries
77
+
78
+ YAML format in `queries` folder (or dictionary):
79
+
80
+ ```yaml
81
+ - name: read_user_id_all
82
+ value: |
83
+ SELECT user_id
84
+ FROM t_user
85
+
86
+ - name: upsert_user
87
+ value: |
88
+ INSERT INTO t_user
89
+ (
90
+ user_id, user_name, user_rank
91
+ )
92
+ VALUES
93
+ (
94
+ %(user_id)s, %(user_name)s, %(user_rank)s
95
+ )
96
+ ON DUPLICATE KEY UPDATE
97
+ user_name = %(user_name)s,
98
+ user_rank = %(user_rank)s,
99
+ update_time = CURRENT_TIMESTAMP
100
+ ```
101
+
102
+ ### 2. Configure Database Connection
103
+
104
+ ```python
105
+ from mysqlclient_client.settings import Settings
106
+
107
+ db_settings = Settings(
108
+ host="127.0.0.1",
109
+ port=3306,
110
+ database="test",
111
+ user="root",
112
+ password="password",
113
+ minconn=3,
114
+ maxconn=10,
115
+ connect_timeout=5,
116
+ use_en_ko_column_alias=True,
117
+ use_conditional=True,
118
+ all_query=qry_dic,
119
+ before_read_execute=lambda qry_key, params, qry_str, qry_with_value: print(
120
+ f'READ_ROWS_START, QRY_KEY: "{qry_key}", QRY_WITH_VALUE: {qry_with_value}'
121
+ ),
122
+ after_read_execute=lambda qry_key, duration: print(
123
+ f'READ_ROWS_END, QRY_KEY: "{qry_key}", DURATION: {duration}'
124
+ ),
125
+ before_update_execute=lambda qry_key, params, params_out, qry_str, qry_with_value: (
126
+ print(f'UPDATES_START, QRY_KEY: "{qry_key}", QRY_WITH_VALUE: {qry_with_value}')
127
+ ),
128
+ after_update_execute=lambda qry_key, row_count, params_out, duration: print(
129
+ f'UPDATES_END, QRY_KEY: "{qry_key}", DURATION: {duration}'
130
+ ),
131
+ )
132
+ ```
133
+
134
+ ### 3. Basic Usage
135
+
136
+ ```python
137
+ from mysqlclient_client.client import Client
138
+
139
+ db = Client(db_settings=db_settings)
140
+
141
+ # Read single row
142
+ row = db.read_row("read_user_id_all", {})
143
+ print(row) # {'user_id': 'gildong.hong'}
144
+
145
+ # Read all rows
146
+ rows = db.read_rows("read_user_id_all", {})
147
+ print(rows[:2])
148
+ ```
149
+
150
+ ## Create / Update / Delete Operations
151
+
152
+ ### `update()` — Single CUD Statement
153
+
154
+ Returns affected row count:
155
+
156
+ ```python
157
+ affected = db.update(
158
+ "upsert_user", {"user_id": "gildong.hong", "user_name": "홍길동", "user_rank": 1}
159
+ )
160
+ print("Affected rows:", affected) # 1
161
+ ```
162
+
163
+ ### Capture Output Parameters
164
+
165
+ ```python
166
+ params_out = {"user_name": "", "user_rank": 0}
167
+ db.update(
168
+ "upsert_user",
169
+ {"user_id": "gildong.hong", "user_name": "홍길동", "user_rank": 1},
170
+ params_out=params_out,
171
+ )
172
+ print("Returned name:", params_out["user_name"], params_out["user_rank"])
173
+ ```
174
+
175
+ ### `updates()` — Batch Execution
176
+
177
+ ```python
178
+ batch = [
179
+ ("upsert_user", {"user_id": "sunja.kim", "user_name": "김순자", "user_rank": 2}),
180
+ ("upsert_user", {"user_id": "malja.kim", "user_name": "김말자", "user_rank": 3}),
181
+ ]
182
+
183
+ results = db.updates(batch)
184
+ print("Batch results:", results) # [1, 1]
185
+ ```
186
+
187
+ ## Transaction Support with `with`
188
+
189
+ Automatically commits on success, rolls back on exception:
190
+
191
+ ```python
192
+ with Client(db_settings=db_settings) as db:
193
+ new_id = "youngja.lee"
194
+ db.update("upsert_user", {"user_id": new_id, "user_name": "이영자", "user_rank": 4})
195
+ db.update("delete_user", {"user_id": new_id})
196
+ print("Committed successfully")
197
+ ```
198
+
199
+ ## Partially return CSV
200
+
201
+ Read rows partially and return immediately to client to show progress:
202
+
203
+ ```python
204
+ # Flask
205
+ @app.route("/read-csv-partial")
206
+ def read_csv_partial():
207
+ """read csv partial"""
208
+
209
+ db_client = Client(db_settings=db_settings)
210
+ filename = f"{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}.csv"
211
+
212
+ return Response(
213
+ db_client.read_csv_partial("read_csv_partial", {}),
214
+ mimetype="text/csv",
215
+ headers={
216
+ "Access-Control-Expose-Headers": "Content-Disposition",
217
+ "Content-Disposition": f'attachment; filename="{filename}"',
218
+ "Cache-Control": "no-cache, no-store, must-revalidate",
219
+ "Pragma": "no-cache",
220
+ "Expires": "0",
221
+ "X-Accel-Buffering": "no",
222
+ "Transfer-Encoding": "chunked",
223
+ },
224
+ )
225
+ ```
226
+
227
+ ## Bilingual Column Aliases (English ↔ Korean)
228
+
229
+ Enabled when `use_en_ko_column_alias=True` and `en` not omitted:
230
+
231
+ ```yaml
232
+ - name: read_user_alias
233
+ value: |
234
+ SELECT user_id "Id|아이디", user_name "Name|이름", user_rank "Rank|순위"
235
+ FROM t_user
236
+ WHERE user_id = %(user_id)s
237
+ ```
238
+
239
+ ### English mode (`en=True`)
240
+
241
+ ```python
242
+ rows = db.read_rows("read_user_alias", {"user_id": "gildong.hong"}, en=True)
243
+ print(rows[0])
244
+ # {'Id': 'gildong.hong', 'Name': '홍길동', 'Rank': 1}
245
+ ```
246
+
247
+ ### Korean mode (`en=False`)
248
+
249
+ ```python
250
+ rows = db.read_rows("read_user_alias", {"user_id": "gildong.hong"}, en=False)
251
+ print(rows[0])
252
+ # {'아이디': 'gildong.hong', '이름': '홍길동', '순위': 1}
253
+ ```
254
+
255
+ ## Conditional SQL (`#if`, `#elif`, `#endif`)
256
+
257
+ Enabled when `use_conditional=True`:
258
+
259
+ ```yaml
260
+ - name: read_user_search
261
+ value: |
262
+ SELECT user_id, user_name, user_rank, insert_time, update_time
263
+ FROM t_user
264
+ WHERE 1 = 1
265
+ #if user_id
266
+ AND user_id = %(user_id)s
267
+ #elif user_name
268
+ AND user_name LIKE %(user_name)s
269
+ #elif user_rank
270
+ AND user_rank <= %(user_rank)s
271
+ #endif
272
+ ```
273
+
274
+ ## Safety & Security
275
+
276
+ The `#if` preprocessor only allows parameter names, string literals, numbers, and basic boolean operators. Any attempt to inject raw SQL will raise a parsing error before execution.
277
+
278
+ ## Features Summary
279
+
280
+ | Feature | Notes |
281
+ | ----------------------------- | ------------------------------------------------------------------------------- |
282
+ | Connection pooling | Thread-safe pool via `minconn` / `maxconn` |
283
+ | Named queries | Stored in YAML / dictionary |
284
+ | Single-row / multi-row fetch | `read_row()` / `read_rows()` |
285
+ | Single / batch CUD operations | `update()` / `updates()` |
286
+ | Output parameters | Via `params_out` dict |
287
+ | Transactions via `with` | Auto rollback on exception |
288
+ | Partially return CSV | `read_csv_partial` / `read_csv_partial_async` |
289
+ | Bilingual column aliases | `"Name\|이름"` syntax |
290
+ | Conditional SQL | `#if` / `#elif` / `#endif` |
291
+ | Logging support | Before and after execute hooks via `before...` and `after...` callables |
292
+ | SQL injection protection | Strict parsing in conditionals |
293
+
294
+ ## License
295
+
296
+ MIT
@@ -0,0 +1,10 @@
1
+ mysqlclient_client/client.py,sha256=JPRPJKcy6Avx30uGm6z7Tebc9UTuEWyZx-Z2hEjjH5E,19318
2
+ mysqlclient_client/settings.py,sha256=DXxrIMkaPZTfXh0Im7oDC929rdUVnULFJZAU2WECYM4,1433
3
+ mysqlclient_client/query_by_key/query.py,sha256=7RVBZKdxVkCE-17met40mcvTuklKbLTQFaMlBZ5m2Qs,1868
4
+ mysqlclient_client/query_by_key/query_util.py,sha256=4vUlhdejomFLI7hk2ThbTPAS3ME4q6iGawSs7Q0PK8I,4227
5
+ mysqlclient_client/query_by_key/settings.py,sha256=v3HEvUeTqYzYfdLb8Z9vvR2Jof5t5FavrJbXztzuQL8,423
6
+ mysqlclient_client-1.0.0.dist-info/licenses/LICENSE,sha256=gFDlDjmqY3ZYsdSoSZrY2jivE561CTz5IV-1wo4gjyU,1065
7
+ mysqlclient_client-1.0.0.dist-info/METADATA,sha256=Zh0JEd_2YM5KIXkAbtPvUKRixDcbF-LAZiJ7mTg53XQ,9740
8
+ mysqlclient_client-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ mysqlclient_client-1.0.0.dist-info/top_level.txt,sha256=CnkGv3Ejoc5QVxNA_1KnHnL0d3VZPgxlURVm61B8g7E,19
10
+ mysqlclient_client-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 doctorgu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mysqlclient_client