codetac 0.1.0 → 0.2.0

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,435 @@
1
+ """Library boundaries: databases here (stage 6); the network (network.py) and
2
+ files (files.py) since stage 7. The classification and the fields are those
3
+ of src/boundaries.mjs:
4
+
5
+ boundary {id, parentId, requestId, kind: 'base-de-dados', library, operation, tables, sql}
6
+ boundary-end {id, requestId, durationNs, error, rows | affectedRows}
7
+
8
+ The SQL is recorded as a template (describe_sql, the port of describeSql):
9
+ string literals are replaced, and the parameters are never read.
10
+
11
+ Where each library is seen:
12
+ - SQLAlchemy / SQLModel, sync and async: the engine events
13
+ (before/after_cursor_execute, handle_error);
14
+ - sqlite3: connect() gives subclasses of Connection and Cursor (the C types
15
+ cannot be patched);
16
+ - psycopg (3): Cursor and AsyncCursor; psycopg2: connection and cursor
17
+ factories; PyMySQL and mysqlclient: their cursors; asyncpg: Connection;
18
+ - aiosqlite runs sqlite3 in its own thread: the calls it sends there carry
19
+ the caller's context.
20
+
21
+ A boundary inside another (SQLAlchemy over a driver, executemany over
22
+ execute) is recorded once: the outer one.
23
+
24
+ Keep it importable on old Pythons (3.8+): the minimal mode records boundaries.
25
+ """
26
+ import contextvars
27
+ import functools
28
+ import itertools
29
+ import re
30
+ import time
31
+
32
+ from .context import current, request_of
33
+ from .hooks import register
34
+
35
+ WRITES = {'INSERT', 'UPDATE', 'DELETE', 'UPSERT', 'REPLACE', 'MERGE'}
36
+ _boundaries = itertools.count(1)
37
+ # Inside a database boundary: an inner one (the driver under SQLAlchemy) is not recorded.
38
+ _inside = contextvars.ContextVar('codetac_boundary', default=False)
39
+
40
+
41
+ def _codetac():
42
+ import codetac_py
43
+ return codetac_py
44
+
45
+
46
+ # The SQL template (src/boundaries.mjs describeSql) --------------------------------
47
+
48
+ _literal = re.compile(r"'(?:[^'\\]|\\.|'')*'")
49
+ _space = re.compile(r'\s+')
50
+ _S = '[\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]'
51
+ _first = re.compile('^' + _S + '*(\\w+)', re.ASCII)
52
+ _tables = re.compile(r'\b(?:from|into|update|join|table(?: if (?:not )?exists)?)\s+[`"\[]?([\w.]+)', re.ASCII | re.IGNORECASE)
53
+ _keywords = re.compile(r'^(select|if|not|exists)$', re.ASCII | re.IGNORECASE)
54
+ _js_trim = re.compile('^' + _S + '+|' + _S + '+$')
55
+
56
+
57
+ def describe_sql(sql):
58
+ if isinstance(sql, bytes):
59
+ sql = sql.decode('utf-8', 'replace')
60
+ if not isinstance(sql, str):
61
+ return {'operation': 'consulta'}
62
+ text = _js_trim.sub('', _space.sub(' ', _literal.sub("'?'", sql)))
63
+ first = _first.match(text)
64
+ operation = (first.group(1) if first else 'consulta').upper()
65
+ tables = []
66
+ for match in _tables.finditer(text):
67
+ name = match.group(1)
68
+ if not _keywords.match(name) and name not in tables:
69
+ tables.append(name)
70
+ return {'operation': operation, 'tables': tables[:10], 'sql': text[:2000]}
71
+
72
+
73
+ # Boundaries ------------------------------------------------------------------------
74
+
75
+ def start_boundary(details):
76
+ """Records the start; returns end(extra), which records the end once."""
77
+ writer = _codetac().writer
78
+ state = current.get()
79
+ boundary_id = '%s:b%d' % (writer.process, next(_boundaries))
80
+ request, after = request_of(state[1])
81
+ event = {'type': 'boundary', 'id': boundary_id, 'parentId': None if state[0] is None else '%s:%d' % (writer.process, state[0]),
82
+ 'requestId': request}
83
+ if after:
84
+ event['afterResponse'] = True
85
+ event.update(details)
86
+ writer.emit(event)
87
+ started = time.perf_counter_ns()
88
+ done = []
89
+
90
+ def end(extra=None):
91
+ if done:
92
+ return
93
+ done.append(True)
94
+ result = {'type': 'boundary-end', 'id': boundary_id, 'requestId': request,
95
+ 'durationNs': time.perf_counter_ns() - started, 'error': False}
96
+ result.update(extra or {})
97
+ writer.emit(result)
98
+
99
+ return end
100
+
101
+
102
+ def _rows(operation, count):
103
+ """rows for reads, affectedRows for writes; nothing when the driver does not know (-1)."""
104
+ if not isinstance(count, int) or isinstance(count, bool) or count < 0:
105
+ return {}
106
+ return {'affectedRows': count} if operation in WRITES else {'rows': count}
107
+
108
+
109
+ def database(library, sql, run, count):
110
+ """Runs run() as a database boundary; count(result) gives the rows."""
111
+ if _inside.get():
112
+ return run()
113
+ try:
114
+ details = describe_sql(sql)
115
+ end = start_boundary(dict({'kind': 'base-de-dados', 'library': library}, **details))
116
+ except Exception:
117
+ return run()
118
+ token = _inside.set(True)
119
+ try:
120
+ result = run()
121
+ except BaseException:
122
+ end({'error': True})
123
+ raise
124
+ finally:
125
+ _inside.reset(token)
126
+ try:
127
+ end(_rows(details['operation'], count(result)))
128
+ except Exception:
129
+ end()
130
+ return result
131
+
132
+
133
+ async def database_async(library, sql, run, count):
134
+ if _inside.get():
135
+ return await run()
136
+ try:
137
+ details = describe_sql(sql)
138
+ end = start_boundary(dict({'kind': 'base-de-dados', 'library': library}, **details))
139
+ except Exception:
140
+ return await run()
141
+ token = _inside.set(True)
142
+ try:
143
+ result = await run()
144
+ except BaseException:
145
+ end({'error': True})
146
+ raise
147
+ finally:
148
+ _inside.reset(token)
149
+ try:
150
+ end(_rows(details['operation'], count(result)))
151
+ except Exception:
152
+ end()
153
+ return result
154
+
155
+
156
+ def _sql_of(args, kwargs, name='query'):
157
+ if args:
158
+ return args[0]
159
+ return kwargs.get(name, kwargs.get('sql', kwargs.get('operation')))
160
+
161
+
162
+ def _wrap_cursor_method(owner, method, library, count=None):
163
+ """cursor.execute(sql, ...) / executemany: rows from cursor.rowcount."""
164
+ original = getattr(owner, method, None)
165
+ if original is None or getattr(original, '__codetac__', False):
166
+ return
167
+
168
+ @functools.wraps(original)
169
+ def wrapper(self, *args, **kwargs):
170
+ return database(library, _sql_of(args, kwargs), lambda: original(self, *args, **kwargs),
171
+ count or (lambda result: getattr(self, 'rowcount', -1)))
172
+
173
+ wrapper.__codetac__ = True
174
+ setattr(owner, method, wrapper)
175
+
176
+
177
+ def _wrap_async_cursor_method(owner, method, library):
178
+ original = getattr(owner, method, None)
179
+ if original is None or getattr(original, '__codetac__', False):
180
+ return
181
+
182
+ @functools.wraps(original)
183
+ async def wrapper(self, *args, **kwargs):
184
+ return await database_async(library, _sql_of(args, kwargs), lambda: original(self, *args, **kwargs),
185
+ lambda result: getattr(self, 'rowcount', -1))
186
+
187
+ wrapper.__codetac__ = True
188
+ setattr(owner, method, wrapper)
189
+
190
+
191
+ # sqlite3 ---------------------------------------------------------------------------
192
+ # Connection and Cursor are C types: connect() returns subclasses whose execute
193
+ # methods are boundaries. The app's own factory= is subclassed the same way.
194
+
195
+ _sqlite_classes = {}
196
+
197
+
198
+ def _sqlite_cursor_class(base):
199
+ found = _sqlite_classes.get(('cursor', base))
200
+ if found is None:
201
+ found = type(base.__name__, (base,), {'__module__': base.__module__, '__qualname__': base.__qualname__})
202
+ for method in ('execute', 'executemany', 'executescript'):
203
+ _wrap_cursor_method(found, method, 'sqlite3')
204
+ _sqlite_classes[('cursor', base)] = found
205
+ return found
206
+
207
+
208
+ def _sqlite_connection_class(base, module):
209
+ found = _sqlite_classes.get(('connection', base))
210
+ if found is not None:
211
+ return found
212
+
213
+ def cursor(self, factory=None):
214
+ return base.cursor(self, _sqlite_cursor_class(factory or module.Cursor))
215
+
216
+ # Connection.execute* create a cursor in C: here they go through cursor().
217
+ def execute(self, sql, parameters=()):
218
+ return self.cursor().execute(sql, parameters)
219
+
220
+ def executemany(self, sql, parameters):
221
+ return self.cursor().executemany(sql, parameters)
222
+
223
+ def executescript(self, script):
224
+ return self.cursor().executescript(script)
225
+
226
+ found = type(base.__name__, (base,), {'__module__': base.__module__, '__qualname__': base.__qualname__,
227
+ 'cursor': cursor, 'execute': execute, 'executemany': executemany,
228
+ 'executescript': executescript})
229
+ _sqlite_classes[('connection', base)] = found
230
+ return found
231
+
232
+
233
+ def _patch_sqlite(module):
234
+ connect = module.connect
235
+ if getattr(connect, '__codetac__', False):
236
+ return
237
+
238
+ @functools.wraps(connect)
239
+ def codetac_connect(*args, **kwargs):
240
+ factory = kwargs.pop('factory', None) or module.Connection
241
+ try:
242
+ kwargs['factory'] = _sqlite_connection_class(factory, module)
243
+ except TypeError: # a factory that cannot be subclassed: unobserved
244
+ kwargs['factory'] = factory
245
+ return connect(*args, **kwargs)
246
+
247
+ codetac_connect.__codetac__ = True
248
+ module.connect = codetac_connect
249
+
250
+
251
+ # aiosqlite: sqlite3 in its own thread; the calls carry the caller's context.
252
+ def _patch_aiosqlite(module):
253
+ connection = module.Connection
254
+ original = getattr(connection, '_execute', None)
255
+ if original is None or getattr(original, '__codetac__', False):
256
+ return
257
+
258
+ @functools.wraps(original)
259
+ async def _execute(self, fn, *args, **kwargs):
260
+ context = contextvars.copy_context()
261
+ return await original(self, context.run, fn, *args, **kwargs)
262
+
263
+ _execute.__codetac__ = True
264
+ connection._execute = _execute
265
+
266
+
267
+ # SQLAlchemy / SQLModel ---------------------------------------------------------------
268
+
269
+ def _patch_sqlalchemy(module):
270
+ from sqlalchemy import event
271
+ from sqlalchemy.engine import Engine
272
+ if getattr(Engine, '__codetac__', False):
273
+ return
274
+ Engine.__codetac__ = True
275
+
276
+ def before(conn, cursor, statement, parameters, context, executemany):
277
+ if context is None or _inside.get():
278
+ return
279
+ try:
280
+ details = describe_sql(statement)
281
+ dialect = getattr(getattr(conn, 'dialect', None), 'name', None)
282
+ end = start_boundary(dict({'kind': 'base-de-dados', 'library': 'sqlalchemy' + ('/' + dialect if dialect else '')}, **details))
283
+ context._codetac = (end, details['operation'], _inside.set(True))
284
+ except Exception:
285
+ pass
286
+
287
+ def finish(context, extra):
288
+ state = getattr(context, '_codetac', None)
289
+ if state is None:
290
+ return
291
+ context._codetac = None
292
+ end, operation, token = state
293
+ try:
294
+ _inside.reset(token)
295
+ except ValueError: # reset from another context (an async driver): let it go
296
+ _inside.set(False)
297
+ end(extra(operation))
298
+
299
+ def after(conn, cursor, statement, parameters, context, executemany):
300
+ finish(context, lambda operation: _rows(operation, getattr(cursor, 'rowcount', -1)))
301
+
302
+ def error(exception_context):
303
+ finish(exception_context.execution_context, lambda operation: {'error': True})
304
+
305
+ event.listen(Engine, 'before_cursor_execute', before)
306
+ event.listen(Engine, 'after_cursor_execute', after)
307
+ event.listen(Engine, 'handle_error', error)
308
+
309
+
310
+ # PostgreSQL and MySQL drivers ------------------------------------------------------
311
+
312
+ def _patch_psycopg(module):
313
+ for name in ('execute', 'executemany'):
314
+ _wrap_cursor_method(module.Cursor, name, 'psycopg')
315
+ _wrap_async_cursor_method(module.AsyncCursor, name, 'psycopg')
316
+ # ServerCursor and AsyncServerCursor derive from these, and Connection.execute uses them.
317
+
318
+
319
+ def _patch_psycopg2(module):
320
+ extensions = module.extensions
321
+ classes = {}
322
+
323
+ def cursor_class(base):
324
+ found = classes.get(base)
325
+ if found is None:
326
+ found = type(base.__name__, (base,), {'__module__': base.__module__})
327
+ for name in ('execute', 'executemany'):
328
+ _wrap_cursor_method(found, name, 'psycopg2')
329
+ classes[base] = found
330
+ return found
331
+
332
+ def connection_class(base):
333
+ found = classes.get(('connection', base))
334
+ if found is None:
335
+ def cursor(self, *args, **kwargs):
336
+ factory = kwargs.get('cursor_factory') or self.cursor_factory or extensions.cursor
337
+ kwargs['cursor_factory'] = cursor_class(factory)
338
+ return base.cursor(self, *args, **kwargs)
339
+ found = type(base.__name__, (base,), {'__module__': base.__module__, 'cursor': cursor})
340
+ classes[('connection', base)] = found
341
+ return found
342
+
343
+ connect = module.connect
344
+ if getattr(connect, '__codetac__', False):
345
+ return
346
+
347
+ @functools.wraps(connect)
348
+ def codetac_connect(*args, **kwargs):
349
+ kwargs['connection_factory'] = connection_class(kwargs.get('connection_factory') or extensions.connection)
350
+ return connect(*args, **kwargs)
351
+
352
+ codetac_connect.__codetac__ = True
353
+ module.connect = codetac_connect
354
+
355
+
356
+ def _patch_pymysql_cursors(module):
357
+ for name in ('execute', 'executemany'):
358
+ _wrap_cursor_method(module.Cursor, name, 'pymysql')
359
+
360
+
361
+ def _patch_mysqldb_cursors(module):
362
+ for name in ('execute', 'executemany'):
363
+ _wrap_cursor_method(module.BaseCursor, name, 'mysqlclient')
364
+
365
+
366
+ def _asyncpg_count(method):
367
+ def count(result):
368
+ if method in ('fetch',):
369
+ return len(result)
370
+ if method == 'fetchrow':
371
+ return 0 if result is None else 1
372
+ if method == 'execute' and isinstance(result, str):
373
+ # The status: "INSERT 0 3", "UPDATE 2", "SELECT 5", "CREATE TABLE".
374
+ last = result.rsplit(' ', 1)[-1]
375
+ return int(last) if last.isdigit() else -1
376
+ return -1
377
+ return count
378
+
379
+
380
+ def _patch_asyncpg_connection(module):
381
+ connection = module.Connection
382
+ for method in ('execute', 'executemany', 'fetch', 'fetchrow', 'fetchval'):
383
+ original = getattr(connection, method, None)
384
+ if original is None or getattr(original, '__codetac__', False):
385
+ continue
386
+
387
+ def make(original, method):
388
+ @functools.wraps(original)
389
+ async def wrapper(self, *args, **kwargs):
390
+ return await database_async('asyncpg', _sql_of(args, kwargs), lambda: original(self, *args, **kwargs), _asyncpg_count(method))
391
+ wrapper.__codetac__ = True
392
+ return wrapper
393
+
394
+ setattr(connection, method, make(original, method))
395
+
396
+
397
+ def _patch_asyncpg_transaction(module):
398
+ # Transaction control (BEGIN, COMMIT, ROLLBACK, SAVEPOINT) is not a read or
399
+ # a write: as with the other drivers, whose commit() is not a query, it is
400
+ # not a boundary (SQLAlchemy async issues it through this class).
401
+ transaction = module.Transaction
402
+ for method in ('start', 'commit', 'rollback'):
403
+ original = getattr(transaction, method, None)
404
+ if original is None or getattr(original, '__codetac__', False):
405
+ continue
406
+
407
+ def make(original):
408
+ @functools.wraps(original)
409
+ async def wrapper(self, *args, **kwargs):
410
+ token = _inside.set(True)
411
+ try:
412
+ return await original(self, *args, **kwargs)
413
+ finally:
414
+ _inside.reset(token)
415
+ wrapper.__codetac__ = True
416
+ return wrapper
417
+
418
+ setattr(transaction, method, make(original))
419
+
420
+
421
+ def install(root=None, data=None):
422
+ from . import files, network
423
+ network.install()
424
+ if root is not None and data is not None:
425
+ files.install(root, data)
426
+ register('sqlite3.dbapi2', _patch_sqlite)
427
+ register('sqlite3', _patch_sqlite) # sqlite3 imported before the capture
428
+ register('aiosqlite.core', _patch_aiosqlite)
429
+ register('sqlalchemy', _patch_sqlalchemy)
430
+ register('psycopg', _patch_psycopg)
431
+ register('psycopg2', _patch_psycopg2)
432
+ register('pymysql.cursors', _patch_pymysql_cursors)
433
+ register('MySQLdb.cursors', _patch_mysqldb_cursors)
434
+ register('asyncpg.connection', _patch_asyncpg_connection)
435
+ register('asyncpg.transaction', _patch_asyncpg_transaction)