wrapture-instrumentation-postgresql 1.0.0.dev1__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,26 @@
1
+ """
2
+ Instrumentation for the PostgreSQL client libraries, applied through
3
+ wrapture.
4
+
5
+ Each target lives in its own subpackage here (psycopg and psycopg2
6
+ now, asyncpg later), holding one wrapture.Instrumentation subclass
7
+ registered in the wrapture.instrumentation entry point group under the
8
+ bare target name. This module carries only the version: importing it
9
+ loads no instrumentation and no target.
10
+ """
11
+
12
+
13
+ def _format_version(parts: tuple[str, ...]) -> str:
14
+ base = ".".join(parts[:3])
15
+
16
+ if len(parts) == 3:
17
+ return base
18
+
19
+ suffix = parts[3]
20
+ return (
21
+ f"{base}.{suffix}" if suffix.startswith(("dev", "post")) else f"{base}{suffix}"
22
+ )
23
+
24
+
25
+ __version_info__ = ("1", "0", "0", "dev1")
26
+ __version__ = _format_version(__version_info__)
@@ -0,0 +1,136 @@
1
+ # asyncpg instrumentation
2
+
3
+ Query and transaction tracing for
4
+ [asyncpg](https://magicstack.github.io/asyncpg/), the asyncio
5
+ PostgreSQL client. Entry point name `asyncpg`, the package it
6
+ patches; supports asyncpg 0.29 and later, below 1.0; fully
7
+ removable.
8
+
9
+ ## Enabling it
10
+
11
+ An `[[instrument]]` entry in `wrapture.toml` (with at least one sink
12
+ to hear the events):
13
+
14
+ ```toml
15
+ [[instrument]]
16
+ name = "asyncpg"
17
+
18
+ [[sink]]
19
+ type = "printer"
20
+ ```
21
+
22
+ run under wrapture's runner (`python -m wrapture -m myapp`), or in a
23
+ test through the context manager:
24
+
25
+ ```python
26
+ with wrapture.instrumentation("asyncpg"):
27
+ ...
28
+ ```
29
+
30
+ ## What you see
31
+
32
+ One `database` leaf per operation, recorded around its await: the
33
+ connection being opened, each query through a connection's `execute`,
34
+ `executemany`, `fetch`, `fetchrow`, `fetchval` or `fetchmany`, each
35
+ of a prepared statement's own fetches, each round trip a server-side
36
+ cursor makes, each COPY, and each transaction boundary:
37
+
38
+ ```
39
+ asyncpg:connect() -> '<Connection>'
40
+ asyncpg.connection:Connection.execute(query='<6 chars>', args='<0 values>', timeout=None) -> '<str>'
41
+ asyncpg.connection:Connection.fetch(query='<43 chars>', args='<1 values>', timeout=None, record_class=None) -> '<list>'
42
+ asyncpg.prepared_stmt:PreparedStatement.fetchval(args='<1 values>', column=0, timeout=None) -> '<int>'
43
+ ```
44
+
45
+ - asyncpg's public surface is pure Python coroutine methods, so the
46
+ instrumentation binds them in place on the classes and records
47
+ each around its await; the Cython protocol beneath does the socket
48
+ work and is where the seams stop. There is no callback-style
49
+ completion to make spans overlap: one call, one span, closed when
50
+ the await returns or raises. Two tasks querying at once give
51
+ sibling spans that overlap in wall time, which is what they are.
52
+
53
+ - Every event carries the database contract keys `system`
54
+ (`postgresql`) and `operation` (the SQL's leading keyword, or
55
+ `CONNECT`, `DECLARE`, `FETCH`, `COPY`, `EXPLAIN`), which
56
+ wrapture's OpenTelemetry export maps to `db.system.name` and
57
+ `db.operation.name`, plus the `database`, `host` and `port` the
58
+ connection reached. A COPY to or from a table names it as
59
+ `collection`.
60
+
61
+ - Transactions need nothing of their own: `async with
62
+ conn.transaction():` issues its BEGIN, COMMIT, ROLLBACK, SAVEPOINT,
63
+ RELEASE SAVEPOINT and ROLLBACK TO through `Connection.execute`, so
64
+ each boundary is an ordinary execute event with its own operation.
65
+
66
+ - A prepared statement (`await conn.prepare(query)`) records each of
67
+ its `fetch`, `fetchrow`, `fetchval`, `fetchmany`, `executemany` and
68
+ `explain` calls, with the statement's SQL; the `prepare` itself is
69
+ not a query and is not recorded.
70
+
71
+ - A server-side cursor (`conn.cursor(query)` inside a transaction,
72
+ iterated with `async for` or driven by `fetch`, `fetchrow` and
73
+ `forward`) never passes through the connection's public methods,
74
+ so its round trips are recorded where they happen: one `DECLARE`
75
+ event when the cursor is bound (with the first batch of rows, for
76
+ an iteration), one `FETCH` event per batch fetched, and a `MOVE`
77
+ for a `forward`. The paths name asyncpg's private `BaseCursor`
78
+ methods, since those are the round trips.
79
+
80
+ - The capture policy is deliberate about sensitive data: query
81
+ arguments are never recorded, under any setting (they reduce to a
82
+ count); the SQL text reduces to its length unless the `statement`
83
+ setting is on; a COPY's records, source and output reduce to a
84
+ count or a type; and the connect event captures none of its
85
+ arguments, which carry the password. asyncpg sends arguments
86
+ server-side, so the recorded text always carries `$1` placeholders
87
+ rather than data, unless the application interpolated its own.
88
+
89
+ - A failing statement records the driver's exception
90
+ (`asyncpg.UndefinedTableError`, say) on the event as it escapes; a
91
+ timeout or cancellation arrives the same way. A pool made after the
92
+ instrumentation is applied opens its connections through the
93
+ bound `connect`, on the task that awaited it, so those record too.
94
+
95
+ ## Settings
96
+
97
+ | Setting | Default | Controls |
98
+ | ------- | ------- | -------- |
99
+ | `statement` | `false` | Whether each query event records the SQL text as handed to the driver, as `statement`. Off by default because the driver cannot tell a literal an application interpolated from a placeholder; turn it on when your queries are parameterized, the text then carrying `$n` placeholders rather than data. Query arguments are never recorded either way. |
100
+
101
+ ```toml
102
+ [[instrument]]
103
+ name = "asyncpg"
104
+ statement = true
105
+ ```
106
+
107
+ ## With the sqlalchemy instrumentation
108
+
109
+ An instrumented asyncpg beneath the core package's `sqlalchemy`
110
+ target (the `postgresql+asyncpg` async engine) composes through that
111
+ target's `leaf` setting. With the default `leaf = true` each
112
+ statement is one event and the driver's own events stay out of the
113
+ tree; with `leaf = false` they nest beneath each statement. The
114
+ asyncpg dialect prepares every statement and fetches through the
115
+ prepared statement, so what appears beneath `do_execute` is the
116
+ prepared statement's own fetch, and the commit's `execute` beneath
117
+ `_commit_impl`. Raw asyncpg use beside the engine records at the top
118
+ level either way, and the dialect's setup queries show up beside the
119
+ tree regardless, since they run outside the recorded seams.
120
+
121
+ ## Known gap
122
+
123
+ A connection taken from a pool (`async with pool.acquire() as conn:`)
124
+ does not yet record its queries: asyncpg's pool proxy calls the
125
+ connection's methods through the class, `Connection.fetchval(
126
+ connection, query)`, a calling convention wrapture's signature check
127
+ does not yet handle. A fix is on wrapture's side and this note goes
128
+ when it lands. The pool's connects record, and so does everything on
129
+ a connection you opened yourself.
130
+
131
+ ## How it patches
132
+
133
+ For the implementation detail see the module docstrings of
134
+ [connection.py](connection.py) (connect and the Connection methods),
135
+ [prepared.py](prepared.py) (prepared statements) and
136
+ [cursor.py](cursor.py) (server-side cursors).
@@ -0,0 +1,75 @@
1
+ """Instrumentation for asyncpg: every query, the connections opened
2
+ and the transaction boundaries recorded as database events, by
3
+ bindings on the pure Python classes the driver is made of, each
4
+ recording around its await.
5
+
6
+ This module imports only wrapture. Everything that touches asyncpg
7
+ lives in the sibling modules, one per asyncpg module patched
8
+ (connection.py for asyncpg and asyncpg.connection, prepared.py for
9
+ asyncpg.prepared_stmt, cursor.py for asyncpg.cursor), each importing
10
+ only wrapture at top level and reaching asyncpg through the module
11
+ the hook is handed, so loading this class when a config loads never
12
+ imports asyncpg ahead of the hooks meant to fire on its import.
13
+
14
+ Importing asyncpg initialises every one of those modules, so all four
15
+ hooks fire from the one import; each binds through the module it is
16
+ handed and cleans up its own bindings.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ import wrapture
24
+ from wrapture import Setting
25
+
26
+ from . import connection, cursor, prepared
27
+
28
+
29
+ class AsyncpgInstrumentation(wrapture.Instrumentation):
30
+ """Query and transaction tracing for asyncpg."""
31
+
32
+ description = "Query and transaction tracing for asyncpg."
33
+
34
+ target = "asyncpg"
35
+ supports = ">=0.29,<1"
36
+ removable = True
37
+
38
+ settings = {
39
+ "statement": Setting(
40
+ False,
41
+ "record the SQL text as handed to the driver on each query"
42
+ " event; off by default because the driver cannot tell a"
43
+ " literal an application interpolated from a placeholder,"
44
+ " and the text is only safe to record when queries are"
45
+ " parameterized",
46
+ ),
47
+ }
48
+
49
+ @wrapture.instrumentation_hook("asyncpg")
50
+ def asyncpg(self, name: str, module: Any) -> None:
51
+ """Bind the package's own spelling of connect once asyncpg
52
+ exists."""
53
+
54
+ connection.instrument_package(module, self)
55
+
56
+ @wrapture.instrumentation_hook("asyncpg.connection")
57
+ def asyncpg_connection(self, name: str, module: Any) -> None:
58
+ """Bind connect and the Connection methods once
59
+ asyncpg.connection exists."""
60
+
61
+ connection.instrument(module, self)
62
+
63
+ @wrapture.instrumentation_hook("asyncpg.prepared_stmt")
64
+ def asyncpg_prepared_stmt(self, name: str, module: Any) -> None:
65
+ """Bind the prepared statement's own fetch family once
66
+ asyncpg.prepared_stmt exists."""
67
+
68
+ prepared.instrument(module, self)
69
+
70
+ @wrapture.instrumentation_hook("asyncpg.cursor")
71
+ def asyncpg_cursor(self, name: str, module: Any) -> None:
72
+ """Bind the server-side cursor round trips once asyncpg.cursor
73
+ exists."""
74
+
75
+ cursor.instrument(module, self)
@@ -0,0 +1,220 @@
1
+ """The connection seams: connections being opened, and Connection's
2
+ execute, fetch and COPY families, recorded as database events around
3
+ their awaits.
4
+
5
+ asyncpg's public surface is pure Python coroutine methods on
6
+ `Connection`, so each is bound in place on the class and records
7
+ around its await: `execute` (with or without arguments, a script of
8
+ several statements included), `executemany`, `fetch`, `fetchrow`,
9
+ `fetchval`, `fetchmany` (0.30 and later), and the four COPY methods.
10
+ The Cython protocol beneath them does the socket work and is where
11
+ the seams stop. A `PoolConnectionProxy` forwards each of these to the
12
+ real Connection's class attribute, so pooled connections record
13
+ through the same bindings.
14
+
15
+ `asyncpg.connection.connect` is the coroutine function every
16
+ connection comes from; `asyncpg.connect` is the same function under
17
+ the package's name, bound at import, so it is bound as a module
18
+ attribute too, as sqlite3's two spellings of `connect` are. A pool
19
+ looks `connection.connect` up when it is created, so a pool made after
20
+ the instrumentation is applied opens its connections through the
21
+ binding, on the task that awaits it. The connect event's arguments are
22
+ never captured (the dsn and the keyword form carry the password); the
23
+ server reached is annotated from the connection afterwards.
24
+
25
+ Transactions need no bindings of their own: `Transaction.start()`,
26
+ its commit and its rollback issue BEGIN, COMMIT, ROLLBACK, SAVEPOINT,
27
+ RELEASE SAVEPOINT and ROLLBACK TO through `Connection.execute`, so
28
+ each boundary records as a statement event with its own operation.
29
+
30
+ Every event carries the database contract keys and the server the
31
+ connection reached, which asyncpg keeps privately (`_params.database`
32
+ and `_addr`, a host and port pair or a socket path, stable since its
33
+ 0.1x line) and which are read through a guarded helper. The SQL text
34
+ rides as `statement` only when the setting is on; the query arguments
35
+ (asyncpg's `*args`, sent server-side) are never recorded and reduce
36
+ to a count.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import Any
42
+
43
+ import wrapture
44
+
45
+ from ..common import SYSTEM, captured, statement_data
46
+
47
+
48
+ def server_of(connection: Any) -> dict[str, Any]:
49
+ """The `database`, `host` and `port` keys for an asyncpg
50
+ connection, from what it keeps privately; whichever it can
51
+ supply."""
52
+
53
+ data: dict[str, Any] = {}
54
+
55
+ params = getattr(connection, "_params", None)
56
+ database = getattr(params, "database", None)
57
+ if isinstance(database, str) and database:
58
+ data["database"] = database
59
+
60
+ addr = getattr(connection, "_addr", None)
61
+ if isinstance(addr, tuple) and len(addr) == 2:
62
+ host, port = addr
63
+ if isinstance(host, str):
64
+ data["host"] = host
65
+ if isinstance(port, int):
66
+ data["port"] = port
67
+ elif isinstance(addr, str) and addr:
68
+ data["host"] = addr
69
+
70
+ return data
71
+
72
+
73
+ def query_of(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
74
+ """The query a Connection method was handed: the first positional,
75
+ else the keyword the method spells it as."""
76
+
77
+ if args:
78
+ return args[0]
79
+
80
+ return kwargs.get("query", kwargs.get("command"))
81
+
82
+
83
+ def database_binding(
84
+ owner: Any, name: str, capture_args: Any = captured
85
+ ) -> wrapture.Binding:
86
+ """A ready binding on one of the driver's methods, shared with the
87
+ prepared statement and cursor modules."""
88
+
89
+ return wrapture.binding(
90
+ owner,
91
+ name,
92
+ category="database",
93
+ leaf=True,
94
+ capture_args=capture_args,
95
+ capture_result=captured,
96
+ )
97
+
98
+
99
+ def opens_binding(owner: Any) -> wrapture.Binding:
100
+ """The connect binding for either spelling of connect."""
101
+
102
+ async def opens(
103
+ wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
104
+ ) -> Any:
105
+ wrapture.annotate(system=SYSTEM, operation="CONNECT")
106
+
107
+ connection = await wrapped(*args, **kwargs)
108
+ wrapture.annotate(**server_of(connection))
109
+
110
+ return connection
111
+
112
+ binding = database_binding(owner, "connect", "none")
113
+ binding.on_call.decorates(opens)
114
+
115
+ return binding
116
+
117
+
118
+ def instrument_package(module: Any, instrumentation: wrapture.Instrumentation) -> None:
119
+ """Bind the package's own spelling of connect; register its
120
+ removal as this trigger's cleanup."""
121
+
122
+ group = wrapture.bindings(connect=opens_binding(module))
123
+ group.apply()
124
+
125
+ instrumentation.on_cleanup(group.remove)
126
+
127
+
128
+ def instrument(module: Any, instrumentation: wrapture.Instrumentation) -> None:
129
+ """Bind connect and the Connection methods; register their removal
130
+ as this trigger's cleanup."""
131
+
132
+ settings = instrumentation.settings
133
+ record_statement = bool(settings["statement"])
134
+
135
+ def data_for(
136
+ connection: Any, query: Any, operation: str | None = None
137
+ ) -> dict[str, Any]:
138
+ return statement_data(
139
+ query, connection, server_of(connection), record_statement, operation
140
+ )
141
+
142
+ def queries(
143
+ wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
144
+ ) -> Any:
145
+ async def record() -> Any:
146
+ wrapture.annotate(**data_for(instance, query_of(args, kwargs)))
147
+
148
+ return await wrapped(*args, **kwargs)
149
+
150
+ return record()
151
+
152
+ def copies_table(
153
+ wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
154
+ ) -> Any:
155
+ async def record() -> Any:
156
+ table = args[0] if args else kwargs.get("table_name")
157
+ data = data_for(instance, None, "COPY")
158
+ if isinstance(table, str):
159
+ data["collection"] = table
160
+ wrapture.annotate(**data)
161
+
162
+ return await wrapped(*args, **kwargs)
163
+
164
+ return record()
165
+
166
+ def copies_query(
167
+ wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
168
+ ) -> Any:
169
+ async def record() -> Any:
170
+ data = data_for(instance, query_of(args, kwargs), "COPY")
171
+ wrapture.annotate(**data)
172
+
173
+ # With arguments, asyncpg renders them into the query
174
+ # through a SELECT on this same connection before the COPY:
175
+ # a bound call beneath this leaf, silenced, whose own
176
+ # annotation would otherwise leave the event saying
177
+ # SELECT. The COPY's data is written again on the way out.
178
+
179
+ try:
180
+ return await wrapped(*args, **kwargs)
181
+ finally:
182
+ wrapture.annotate(**data)
183
+
184
+ return record()
185
+
186
+ named: dict[str, wrapture.Binding] = {"connect": opens_binding(module)}
187
+
188
+ connection_class = module.Connection
189
+
190
+ # The execute and fetch family; fetchmany arrived in 0.30.
191
+
192
+ for method in (
193
+ "execute",
194
+ "executemany",
195
+ "fetch",
196
+ "fetchrow",
197
+ "fetchval",
198
+ "fetchmany",
199
+ ):
200
+ if method not in vars(connection_class):
201
+ continue
202
+ bound = database_binding(connection_class, method)
203
+ bound.on_call.decorates(queries)
204
+ named[method] = bound
205
+
206
+ # COPY: three methods name a table, one takes a query.
207
+
208
+ for method in ("copy_from_table", "copy_to_table", "copy_records_to_table"):
209
+ bound = database_binding(connection_class, method)
210
+ bound.on_call.decorates(copies_table)
211
+ named[method] = bound
212
+
213
+ bound = database_binding(connection_class, "copy_from_query")
214
+ bound.on_call.decorates(copies_query)
215
+ named["copy_from_query"] = bound
216
+
217
+ group = wrapture.bindings(**named)
218
+ group.apply()
219
+
220
+ instrumentation.on_cleanup(group.remove)
@@ -0,0 +1,88 @@
1
+ """The server-side cursor seams: each round trip a cursor makes to
2
+ its portal, recorded as a database event around its await.
3
+
4
+ `connection.cursor(query, *args)` inside a transaction gives a
5
+ server-side cursor, iterated with `async for` or driven by `fetch`,
6
+ `fetchrow` and `forward`. Nothing there passes through Connection's
7
+ public methods: the DECLARE happens inside the cursor's own bind, and
8
+ every FETCH inside its exec. With no binding at all a query run this
9
+ way would leave no event, so the three private methods on
10
+ `BaseCursor` that make the round trips are bound: `_bind_exec` (the
11
+ DECLARE with the first batch, what `async for` does), `_bind` (the
12
+ DECLARE alone, for a cursor awaited then driven by hand) and `_exec`
13
+ (each FETCH). One event per round trip, whichever public method drove
14
+ it; binding `__anext__` instead would make an event per buffered row,
15
+ fifty times over. sqlalchemy's `_commit_impl` is the precedent for a
16
+ private seam where the public ones do not bound the work. `forward`,
17
+ which skips rows with a MOVE of its own rather than through `_exec`,
18
+ is bound as the public method it is.
19
+
20
+ The cursor knows its query (`_query`) and its connection, which
21
+ supply the statement (when the setting is on) and the server keys.
22
+ The operation is DECLARE for the two binds, FETCH for the exec and
23
+ MOVE for a forward.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+ import wrapture
31
+
32
+ from ..common import statement_data
33
+ from .connection import database_binding, server_of
34
+
35
+
36
+ def instrument(module: Any, instrumentation: wrapture.Instrumentation) -> None:
37
+ """Bind the cursor's round trips; register their removal as this
38
+ trigger's cleanup."""
39
+
40
+ settings = instrumentation.settings
41
+ record_statement = bool(settings["statement"])
42
+
43
+ def round_trips(operation: str) -> Any:
44
+ def decorator(
45
+ wrapped: Any,
46
+ instance: Any,
47
+ args: tuple[Any, ...],
48
+ kwargs: dict[str, Any],
49
+ ) -> Any:
50
+ async def record() -> Any:
51
+ connection = getattr(instance, "_connection", None)
52
+ query = getattr(instance, "_query", None)
53
+ wrapture.annotate(
54
+ **statement_data(
55
+ query,
56
+ connection,
57
+ server_of(connection),
58
+ record_statement,
59
+ operation,
60
+ )
61
+ )
62
+
63
+ return await wrapped(*args, **kwargs)
64
+
65
+ return record()
66
+
67
+ return decorator
68
+
69
+ cursor_class = module.BaseCursor
70
+ named: dict[str, wrapture.Binding] = {}
71
+
72
+ for method, operation in (
73
+ ("_bind_exec", "DECLARE"),
74
+ ("_bind", "DECLARE"),
75
+ ("_exec", "FETCH"),
76
+ ):
77
+ bound = database_binding(cursor_class, method)
78
+ bound.on_call.decorates(round_trips(operation))
79
+ named[method.lstrip("_")] = bound
80
+
81
+ forward = database_binding(module.Cursor, "forward")
82
+ forward.on_call.decorates(round_trips("MOVE"))
83
+ named["forward"] = forward
84
+
85
+ group = wrapture.bindings(**named)
86
+ group.apply()
87
+
88
+ instrumentation.on_cleanup(group.remove)
@@ -0,0 +1,74 @@
1
+ """The prepared statement seams: a PreparedStatement's own fetch and
2
+ execute family, recorded as database events around their awaits.
3
+
4
+ `await connection.prepare(query)` hands back a PreparedStatement whose
5
+ `fetch`, `fetchrow`, `fetchval`, `fetchmany`, `executemany` and
6
+ `explain` bypass Connection's public methods, driving the protocol
7
+ through the statement's own private path, so each is bound in its
8
+ own right. The statement's SQL is public (`get_query()`), and the
9
+ connection it belongs to supplies the server keys. The `prepare` call
10
+ itself is not a query and is not recorded; the round trip that
11
+ prepares happens inside it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ import wrapture
19
+
20
+ from ..common import statement_data
21
+ from .connection import database_binding, server_of
22
+
23
+
24
+ def instrument(module: Any, instrumentation: wrapture.Instrumentation) -> None:
25
+ """Bind the prepared statement's methods; register their removal
26
+ as this trigger's cleanup."""
27
+
28
+ settings = instrumentation.settings
29
+ record_statement = bool(settings["statement"])
30
+
31
+ def executes(operation: str | None = None) -> Any:
32
+ def decorator(
33
+ wrapped: Any,
34
+ instance: Any,
35
+ args: tuple[Any, ...],
36
+ kwargs: dict[str, Any],
37
+ ) -> Any:
38
+ async def record() -> Any:
39
+ connection = getattr(instance, "_connection", None)
40
+ query = instance.get_query()
41
+ wrapture.annotate(
42
+ **statement_data(
43
+ query,
44
+ connection,
45
+ server_of(connection),
46
+ record_statement,
47
+ operation,
48
+ )
49
+ )
50
+
51
+ return await wrapped(*args, **kwargs)
52
+
53
+ return record()
54
+
55
+ return decorator
56
+
57
+ statement_class = module.PreparedStatement
58
+ named: dict[str, wrapture.Binding] = {}
59
+
60
+ for method in ("fetch", "fetchrow", "fetchval", "fetchmany", "executemany"):
61
+ if method not in vars(statement_class):
62
+ continue
63
+ bound = database_binding(statement_class, method)
64
+ bound.on_call.decorates(executes())
65
+ named[method] = bound
66
+
67
+ explain = database_binding(statement_class, "explain")
68
+ explain.on_call.decorates(executes("EXPLAIN"))
69
+ named["explain"] = explain
70
+
71
+ group = wrapture.bindings(**named)
72
+ group.apply()
73
+
74
+ instrumentation.on_cleanup(group.remove)