openbox-sdk-python 0.2.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.
Files changed (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,689 @@
1
+ """DB wrappers — SQLAlchemy, DB-API (via OTel dbapi CursorTracer), asyncpg.
2
+
3
+ Blocking semantics: the started preflight runs BEFORE the driver executes the
4
+ statement; a BLOCK/HALT raises out of the listener/patch so the query never
5
+ reaches the database.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ from typing import Any
13
+
14
+ from ..contracts.otel_spans import HookType
15
+ from ..otel.provider import get_tracer
16
+ from .shared import get_hook_runtime
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ __all__ = [
21
+ "install_sqlalchemy",
22
+ "uninstall_sqlalchemy",
23
+ "install_dbapi",
24
+ "uninstall_dbapi",
25
+ "install_asyncpg",
26
+ "uninstall_asyncpg",
27
+ "install_redis",
28
+ "uninstall_redis",
29
+ "install_pymongo",
30
+ "uninstall_pymongo",
31
+ ]
32
+
33
+ _SPAN_KEY = "_openbox_db_span"
34
+
35
+
36
+ def _db_fields(
37
+ statement: str | None,
38
+ system: str,
39
+ operation: str | None = None,
40
+ *,
41
+ db_name: str | None = None,
42
+ server_address: str | None = None,
43
+ server_port: int | None = None,
44
+ ) -> dict:
45
+ """Assemble DB wire root fields.
46
+
47
+ ``db_name``/``server_address``/``server_port`` are populated per driver
48
+ when available; the projection keeps the keys present even when a driver
49
+ exposes no value.
50
+ """
51
+ op = operation
52
+ if op is None and statement:
53
+ op = statement.strip().split(" ", 1)[0].upper() if statement.strip() else None
54
+ port = server_port
55
+ if port is not None:
56
+ try:
57
+ port = int(port)
58
+ except (TypeError, ValueError):
59
+ port = None
60
+ return {
61
+ "db_system": system,
62
+ "db_name": str(db_name) if db_name else None,
63
+ "db_operation": op,
64
+ "db_statement": statement,
65
+ "server_address": server_address,
66
+ "server_port": port,
67
+ }
68
+
69
+
70
+ def _sqlalchemy_conn_meta(conn: Any) -> tuple[str, str | None, str | None, int | None]:
71
+ """(dialect, db_name, host, port) from a SQLAlchemy Connection."""
72
+ engine = getattr(conn, "engine", None)
73
+ url = getattr(engine, "url", None)
74
+ dialect = getattr(getattr(conn, "dialect", None), "name", "sql") or "sql"
75
+ return (
76
+ dialect,
77
+ getattr(url, "database", None),
78
+ getattr(url, "host", None),
79
+ getattr(url, "port", None),
80
+ )
81
+
82
+
83
+ def _dbapi_conn_meta(tracer_self: Any) -> tuple[str, str | None, str | None, int | None]:
84
+ """(db_system, db_name, host, port) from an OTel dbapi CursorTracer."""
85
+ integ = getattr(tracer_self, "_db_api_integration", None)
86
+ system = getattr(integ, "database_system", "sql") if integ else "sql"
87
+ db_name = getattr(integ, "database", None) if integ else None
88
+ props = getattr(integ, "connection_props", None) if integ else None
89
+ host = props.get("host") if isinstance(props, dict) else None
90
+ port = props.get("port") if isinstance(props, dict) else None
91
+ return system or "sql", db_name, host, port
92
+
93
+
94
+ def _asyncpg_conn_meta(conn_self: Any) -> tuple[str | None, str | None, int | None]:
95
+ """(db_name, host, port) from an asyncpg Connection."""
96
+ addr = getattr(conn_self, "_addr", None)
97
+ host = addr[0] if isinstance(addr, (tuple, list)) and len(addr) >= 1 else None
98
+ port = addr[1] if isinstance(addr, (tuple, list)) and len(addr) >= 2 else None
99
+ params = getattr(conn_self, "_params", None)
100
+ db_name = getattr(params, "database", None) if params is not None else None
101
+ return db_name, host, port
102
+
103
+
104
+ # ── SQLAlchemy (event listeners — no monkeypatching) ────────────────────────
105
+
106
+ _sqlalchemy_installed = False
107
+
108
+
109
+ def _before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
110
+ runtime = get_hook_runtime()
111
+ if runtime is None:
112
+ return
113
+ span = get_tracer().start_span(f"db {statement.strip().split(' ', 1)[0].lower()}")
114
+ if context is not None:
115
+ setattr(context, _SPAN_KEY, span)
116
+ dialect, db_name, host, port = _sqlalchemy_conn_meta(conn)
117
+ # Raising here (BLOCK/HALT via adapter) aborts execution — the statement
118
+ # never reaches cursor.execute.
119
+ runtime.preflight(
120
+ span,
121
+ hook_type=HookType.DB_QUERY,
122
+ identifier=statement or "",
123
+ fields=_db_fields(
124
+ statement, dialect, db_name=db_name, server_address=host, server_port=port
125
+ ),
126
+ )
127
+
128
+
129
+ def _after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
130
+ runtime = get_hook_runtime()
131
+ if runtime is None:
132
+ return
133
+ span = getattr(context, _SPAN_KEY, None) if context is not None else None
134
+ if span is None:
135
+ return
136
+ try:
137
+ rowcount = getattr(cursor, "rowcount", None)
138
+ dialect, db_name, host, port = _sqlalchemy_conn_meta(conn)
139
+ span.end()
140
+ runtime.completed(
141
+ span,
142
+ hook_type=HookType.DB_QUERY,
143
+ fields={
144
+ **_db_fields(
145
+ statement, dialect, db_name=db_name, server_address=host, server_port=port
146
+ ),
147
+ "rowcount": rowcount,
148
+ },
149
+ )
150
+ finally:
151
+ if context is not None and hasattr(context, _SPAN_KEY):
152
+ delattr(context, _SPAN_KEY)
153
+
154
+
155
+ def _handle_error(exception_context) -> None:
156
+ runtime = get_hook_runtime()
157
+ if runtime is None:
158
+ return
159
+ context = getattr(exception_context, "execution_context", None)
160
+ span = getattr(context, _SPAN_KEY, None) if context is not None else None
161
+ if span is None:
162
+ return
163
+ try:
164
+ span.end()
165
+ statement = getattr(exception_context, "statement", None)
166
+ engine = getattr(exception_context, "engine", None)
167
+ url = getattr(engine, "url", None)
168
+ dialect = getattr(getattr(engine, "dialect", None), "name", "sql") or "sql"
169
+ runtime.completed(
170
+ span,
171
+ hook_type=HookType.DB_QUERY,
172
+ fields={
173
+ **_db_fields(
174
+ statement,
175
+ dialect,
176
+ db_name=getattr(url, "database", None),
177
+ server_address=getattr(url, "host", None),
178
+ server_port=getattr(url, "port", None),
179
+ ),
180
+ "error": str(getattr(exception_context, "original_exception", "error")),
181
+ },
182
+ )
183
+ finally:
184
+ if context is not None and hasattr(context, _SPAN_KEY):
185
+ delattr(context, _SPAN_KEY)
186
+
187
+
188
+ def install_sqlalchemy() -> bool:
189
+ global _sqlalchemy_installed
190
+ if _sqlalchemy_installed:
191
+ return True
192
+ try:
193
+ from sqlalchemy import event
194
+ from sqlalchemy.engine import Engine
195
+ except ImportError:
196
+ logger.info("sqlalchemy not available (install extra [db]) — deferred")
197
+ return False
198
+ event.listen(Engine, "before_cursor_execute", _before_cursor_execute)
199
+ event.listen(Engine, "after_cursor_execute", _after_cursor_execute)
200
+ event.listen(Engine, "handle_error", _handle_error)
201
+ _sqlalchemy_installed = True
202
+ return True
203
+
204
+
205
+ def uninstall_sqlalchemy() -> None:
206
+ global _sqlalchemy_installed
207
+ if not _sqlalchemy_installed:
208
+ return
209
+ try:
210
+ from sqlalchemy import event
211
+ from sqlalchemy.engine import Engine
212
+
213
+ event.remove(Engine, "before_cursor_execute", _before_cursor_execute)
214
+ event.remove(Engine, "after_cursor_execute", _after_cursor_execute)
215
+ event.remove(Engine, "handle_error", _handle_error)
216
+ except Exception:
217
+ logger.debug("sqlalchemy listener removal skipped", exc_info=True)
218
+ _sqlalchemy_installed = False
219
+
220
+
221
+ # ── DB-API (OTel dbapi CursorTracer — governs psycopg2/mysql/sqlite3) ───────
222
+
223
+ _original_traced_execution: Any = None
224
+
225
+
226
+ def install_dbapi() -> bool:
227
+ """Patch CursorTracer.traced_execution so OTel-instrumented DB-API drivers
228
+ run through governance. Patch ordering: apply AFTER the OTel dbapi
229
+ instrumentors are set up (the manager guarantees this)."""
230
+ global _original_traced_execution
231
+ if _original_traced_execution is not None:
232
+ return True
233
+ try:
234
+ from opentelemetry.instrumentation import dbapi
235
+ except ImportError:
236
+ logger.info("opentelemetry dbapi instrumentation not available — deferred")
237
+ return False
238
+
239
+ _original_traced_execution = dbapi.CursorTracer.traced_execution
240
+
241
+ def governed_traced_execution(tracer_self, cursor, query_method, *args, **kwargs):
242
+ runtime = get_hook_runtime()
243
+ if runtime is None:
244
+ return _original_traced_execution(tracer_self, cursor, query_method, *args, **kwargs)
245
+ statement = tracer_self.get_statement(cursor, args) if args else ""
246
+ system_name, db_name, host, port = _dbapi_conn_meta(tracer_self)
247
+ span = get_tracer().start_span("db query")
248
+
249
+ def _fields() -> dict:
250
+ return _db_fields(
251
+ str(statement),
252
+ system_name,
253
+ db_name=db_name,
254
+ server_address=host,
255
+ server_port=port,
256
+ )
257
+
258
+ runtime.preflight(
259
+ span,
260
+ hook_type=HookType.DB_QUERY,
261
+ identifier=str(statement),
262
+ fields=_fields(),
263
+ )
264
+ try:
265
+ result = _original_traced_execution(tracer_self, cursor, query_method, *args, **kwargs)
266
+ except Exception as exc:
267
+ span.end()
268
+ runtime.completed(
269
+ span,
270
+ hook_type=HookType.DB_QUERY,
271
+ fields={**_fields(), "error": str(exc)},
272
+ )
273
+ raise
274
+ span.end()
275
+ runtime.completed(
276
+ span,
277
+ hook_type=HookType.DB_QUERY,
278
+ fields={**_fields(), "rowcount": getattr(cursor, "rowcount", None)},
279
+ )
280
+ return result
281
+
282
+ dbapi.CursorTracer.traced_execution = governed_traced_execution
283
+ return True
284
+
285
+
286
+ def uninstall_dbapi() -> None:
287
+ global _original_traced_execution
288
+ if _original_traced_execution is None:
289
+ return
290
+ try:
291
+ from opentelemetry.instrumentation import dbapi
292
+
293
+ dbapi.CursorTracer.traced_execution = _original_traced_execution
294
+ except Exception:
295
+ logger.debug("dbapi restore skipped", exc_info=True)
296
+ _original_traced_execution = None
297
+
298
+
299
+ # ── asyncpg ──────────────────────────────────────────────────────────────────
300
+
301
+ _original_asyncpg_execute: Any = None
302
+
303
+
304
+ def install_asyncpg() -> bool:
305
+ """Patch asyncpg.Connection._execute — the funnel every fetch/execute
306
+ variant goes through."""
307
+ global _original_asyncpg_execute
308
+ if _original_asyncpg_execute is not None:
309
+ return True
310
+ try:
311
+ from asyncpg import Connection
312
+ except ImportError:
313
+ logger.info("asyncpg not available (install extra [db]) — deferred")
314
+ return False
315
+
316
+ _original_asyncpg_execute = Connection._execute
317
+
318
+ async def governed_execute(conn_self, query, *args, **kwargs):
319
+ runtime = get_hook_runtime()
320
+ if runtime is None:
321
+ return await _original_asyncpg_execute(conn_self, query, *args, **kwargs)
322
+ db_name, host, port = _asyncpg_conn_meta(conn_self)
323
+ span = get_tracer().start_span("db query")
324
+
325
+ def _fields() -> dict:
326
+ return _db_fields(
327
+ str(query),
328
+ "postgresql",
329
+ db_name=db_name,
330
+ server_address=host,
331
+ server_port=port,
332
+ )
333
+
334
+ await runtime.apreflight(
335
+ span,
336
+ hook_type=HookType.DB_QUERY,
337
+ identifier=str(query),
338
+ fields=_fields(),
339
+ )
340
+ try:
341
+ result = await _original_asyncpg_execute(conn_self, query, *args, **kwargs)
342
+ except Exception as exc:
343
+ span.end()
344
+ await runtime.acompleted(
345
+ span,
346
+ hook_type=HookType.DB_QUERY,
347
+ fields={**_fields(), "error": str(exc)},
348
+ )
349
+ raise
350
+ span.end()
351
+ await runtime.acompleted(
352
+ span,
353
+ hook_type=HookType.DB_QUERY,
354
+ fields=_fields(),
355
+ )
356
+ return result
357
+
358
+ Connection._execute = governed_execute
359
+ return True
360
+
361
+
362
+ def uninstall_asyncpg() -> None:
363
+ global _original_asyncpg_execute
364
+ if _original_asyncpg_execute is None:
365
+ return
366
+ try:
367
+ from asyncpg import Connection
368
+
369
+ Connection._execute = _original_asyncpg_execute
370
+ except Exception:
371
+ logger.debug("asyncpg restore skipped", exc_info=True)
372
+ _original_asyncpg_execute = None
373
+
374
+
375
+ # ── redis (OTel RedisInstrumentor request/response hooks) ────────────────────
376
+
377
+ _redis_installed = False
378
+ # span_id -> (command, statement) stashed at request so the response hook (which
379
+ # receives only span+response) can carry the same operation into completed.
380
+ _redis_pending: dict[int, tuple[str, str]] = {}
381
+ _REDIS_PENDING_MAX = 4096
382
+
383
+
384
+ def _span_id_of(span: Any) -> int | None:
385
+ try:
386
+ return span.get_span_context().span_id
387
+ except Exception:
388
+ return None
389
+
390
+
391
+ def _redis_conn_meta(instance: Any) -> tuple[str | None, int | None, str | None]:
392
+ """(host, port, db_name) from a redis client's connection pool."""
393
+ try:
394
+ kwargs = instance.connection_pool.connection_kwargs
395
+ return kwargs.get("host", "localhost"), kwargs.get("port", 6379), str(kwargs.get("db", 0))
396
+ except AttributeError:
397
+ return "localhost", 6379, "0"
398
+
399
+
400
+ def _redis_request_hook(span: Any, instance: Any, args: Any, kwargs: Any) -> None:
401
+ runtime = get_hook_runtime()
402
+ if runtime is None:
403
+ return
404
+ command = str(args[0]) if args else "UNKNOWN"
405
+ statement = " ".join(str(a) for a in args) if args else ""
406
+ host, port, db_name = _redis_conn_meta(instance)
407
+ span_id = _span_id_of(span)
408
+ if span_id is not None:
409
+ if len(_redis_pending) >= _REDIS_PENDING_MAX:
410
+ _redis_pending.clear()
411
+ _redis_pending[span_id] = (command, statement)
412
+ # Raising (BLOCK/HALT via adapter) aborts before the command reaches redis.
413
+ runtime.preflight(
414
+ span,
415
+ hook_type=HookType.DB_QUERY,
416
+ identifier=statement or command,
417
+ fields=_db_fields(
418
+ statement, "redis", operation=command,
419
+ db_name=db_name, server_address=host, server_port=port,
420
+ ),
421
+ )
422
+
423
+
424
+ def _redis_response_hook(span: Any, instance: Any, response: Any) -> None:
425
+ runtime = get_hook_runtime()
426
+ if runtime is None:
427
+ return
428
+ host, port, db_name = _redis_conn_meta(instance)
429
+ span_id = _span_id_of(span)
430
+ command, statement = (
431
+ _redis_pending.pop(span_id, ("UNKNOWN", "")) if span_id is not None else ("UNKNOWN", "")
432
+ )
433
+ runtime.completed(
434
+ span,
435
+ hook_type=HookType.DB_QUERY,
436
+ fields=_db_fields(
437
+ statement, "redis", operation=command,
438
+ db_name=db_name, server_address=host, server_port=port,
439
+ ),
440
+ )
441
+
442
+
443
+ def install_redis() -> bool:
444
+ global _redis_installed
445
+ if _redis_installed:
446
+ return True
447
+ try:
448
+ from opentelemetry.instrumentation.redis import RedisInstrumentor
449
+ except ImportError:
450
+ logger.info("redis instrumentation not available (install extra [db]) — deferred")
451
+ return False
452
+ RedisInstrumentor().instrument(
453
+ request_hook=_redis_request_hook, response_hook=_redis_response_hook
454
+ )
455
+ _redis_installed = True
456
+ return True
457
+
458
+
459
+ def uninstall_redis() -> None:
460
+ global _redis_installed
461
+ if not _redis_installed:
462
+ return
463
+ try:
464
+ from opentelemetry.instrumentation.redis import RedisInstrumentor
465
+
466
+ RedisInstrumentor().uninstrument()
467
+ except Exception:
468
+ logger.debug("redis uninstrument skipped", exc_info=True)
469
+ _redis_pending.clear()
470
+ _redis_installed = False
471
+
472
+
473
+ # ── pymongo (CommandListener telemetry + wrapt Collection blocking) ──────────
474
+ #
475
+ # pymongo monitoring listeners are OBSERVE-ONLY — raising from them cannot stop
476
+ # a command. Blocking therefore rides on a wrapt wrapper around Collection CRUD
477
+ # methods (a Python-level wrapper whose raise propagates to the caller), while
478
+ # the CommandListener supplies telemetry for every OTHER command. A thread-local
479
+ # depth counter suppresses the listener while a wrapt-governed method is on the
480
+ # stack so one logical operation is never evaluated twice. The listener cannot
481
+ # be unregistered (pymongo has no public API); after uninstall it goes dormant
482
+ # because ``get_hook_runtime()`` returns None.
483
+
484
+ _pymongo_listener: Any = None
485
+ _pymongo_wrapt_installed = False
486
+ _pymongo_wrapt_depth = threading.local()
487
+ # request_id -> (span, command string) correlating started with succeeded/failed.
488
+ _pymongo_pending: dict[int, tuple[Any, str]] = {}
489
+ _PYMONGO_PENDING_MAX = 4096
490
+ _pymongo_patched: list[tuple[str, str]] = []
491
+
492
+ _PYMONGO_METHODS = (
493
+ "find", "find_one", "insert_one", "insert_many",
494
+ "update_one", "update_many", "delete_one", "delete_many",
495
+ "aggregate", "count_documents",
496
+ )
497
+
498
+
499
+ def _pymongo_address(event: Any) -> tuple[str | None, int | None]:
500
+ try:
501
+ addr = event.connection_id
502
+ if addr and len(addr) >= 2:
503
+ return str(addr[0]), int(addr[1])
504
+ except (AttributeError, TypeError, IndexError):
505
+ pass
506
+ return None, 27017
507
+
508
+
509
+ def _pymongo_collection_address(instance: Any) -> tuple[str | None, int | None]:
510
+ # ``client.address`` triggers pymongo server selection (a live connection).
511
+ # Catch broadly (incl. ServerSelectionTimeoutError) so metadata resolution
512
+ # never blocks or crashes governance — call only AFTER an op has run, when
513
+ # the address is already cached.
514
+ try:
515
+ address = instance.database.client.address
516
+ if address:
517
+ return address[0], int(address[1])
518
+ except Exception:
519
+ pass
520
+ return None, 27017
521
+
522
+
523
+ def install_pymongo() -> bool:
524
+ global _pymongo_listener
525
+ installed = False
526
+ if _pymongo_listener is None:
527
+ try:
528
+ import pymongo.monitoring
529
+
530
+ class _GovernanceCommandListener(pymongo.monitoring.CommandListener):
531
+ def started(self, event: Any) -> None:
532
+ if getattr(_pymongo_wrapt_depth, "value", 0) > 0:
533
+ return
534
+ runtime = get_hook_runtime()
535
+ if runtime is None:
536
+ return
537
+ try:
538
+ span = get_tracer().start_span(f"mongodb {event.command_name}")
539
+ host, port = _pymongo_address(event)
540
+ cmd_str = str(event.command)[:2000]
541
+ if len(_pymongo_pending) >= _PYMONGO_PENDING_MAX:
542
+ _pymongo_pending.clear()
543
+ _pymongo_pending[event.request_id] = (span, cmd_str)
544
+ # A BLOCK sets the abort flag (future ops) before raising;
545
+ # the listener cannot stop THIS command, so swallow so the
546
+ # pymongo monitoring loop is never crashed.
547
+ runtime.preflight(
548
+ span,
549
+ hook_type=HookType.DB_QUERY,
550
+ identifier=cmd_str,
551
+ fields=_db_fields(
552
+ cmd_str, "mongodb", operation=event.command_name,
553
+ db_name=event.database_name, server_address=host, server_port=port,
554
+ ),
555
+ )
556
+ except Exception:
557
+ logger.debug("pymongo started governance error", exc_info=True)
558
+
559
+ def succeeded(self, event: Any) -> None:
560
+ self._completed(event)
561
+
562
+ def failed(self, event: Any) -> None:
563
+ self._completed(event, error=str(getattr(event, "failure", "error")))
564
+
565
+ def _completed(self, event: Any, error: str | None = None) -> None:
566
+ if getattr(_pymongo_wrapt_depth, "value", 0) > 0:
567
+ _pymongo_pending.pop(event.request_id, None)
568
+ return
569
+ runtime = get_hook_runtime()
570
+ span, cmd_str = _pymongo_pending.pop(
571
+ event.request_id, (None, event.command_name)
572
+ )
573
+ if runtime is None:
574
+ return
575
+ if span is None:
576
+ span = get_tracer().start_span(f"mongodb {event.command_name}")
577
+ host, port = _pymongo_address(event)
578
+ try:
579
+ span.end()
580
+ except Exception:
581
+ pass
582
+ fields = _db_fields(
583
+ cmd_str, "mongodb", operation=event.command_name,
584
+ db_name=event.database_name, server_address=host, server_port=port,
585
+ )
586
+ if error:
587
+ fields["error"] = error
588
+ runtime.completed(span, hook_type=HookType.DB_QUERY, fields=fields)
589
+
590
+ _pymongo_listener = _GovernanceCommandListener()
591
+ pymongo.monitoring.register(_pymongo_listener)
592
+ installed = True
593
+ except ImportError:
594
+ logger.info("pymongo not available (install extra [db]) — deferred")
595
+ else:
596
+ installed = True
597
+
598
+ if _install_pymongo_wrapt():
599
+ installed = True
600
+ return installed
601
+
602
+
603
+ def _install_pymongo_wrapt() -> bool:
604
+ global _pymongo_wrapt_installed
605
+ if _pymongo_wrapt_installed:
606
+ return True
607
+ try:
608
+ import wrapt
609
+ except ImportError:
610
+ logger.debug("wrapt not available — pymongo blocking disabled")
611
+ return False
612
+
613
+ def _collection_wrapper(wrapped, instance, args, kwargs):
614
+ depth = getattr(_pymongo_wrapt_depth, "value", 0)
615
+ _pymongo_wrapt_depth.value = depth + 1
616
+ try:
617
+ if depth > 0:
618
+ return wrapped(*args, **kwargs)
619
+ runtime = get_hook_runtime()
620
+ if runtime is None:
621
+ return wrapped(*args, **kwargs)
622
+ db_name = getattr(getattr(instance, "database", None), "name", None)
623
+ operation = getattr(wrapped, "__name__", "query")
624
+ statement = f"{getattr(instance, 'name', '?')}.{operation}"
625
+ span = get_tracer().start_span(f"mongodb {operation}")
626
+
627
+ def _fields(host: Any = None, port: Any = None) -> dict:
628
+ return _db_fields(
629
+ statement, "mongodb", operation=operation,
630
+ db_name=db_name, server_address=host, server_port=port,
631
+ )
632
+
633
+ # Preflight WITHOUT resolving the server address — doing so would
634
+ # trigger a connection and defeat block-before-connect. A BLOCK here
635
+ # raises before the driver runs (and connects).
636
+ runtime.preflight(
637
+ span, hook_type=HookType.DB_QUERY, identifier=statement, fields=_fields()
638
+ )
639
+ try:
640
+ result = wrapped(*args, **kwargs)
641
+ except Exception as exc:
642
+ span.end()
643
+ runtime.completed(
644
+ span, hook_type=HookType.DB_QUERY, fields={**_fields(), "error": str(exc)}
645
+ )
646
+ raise
647
+ span.end()
648
+ # Address is cached now the op has run — safe to resolve for telemetry.
649
+ host, port = _pymongo_collection_address(instance)
650
+ runtime.completed(span, hook_type=HookType.DB_QUERY, fields=_fields(host, port))
651
+ return result
652
+ finally:
653
+ _pymongo_wrapt_depth.value = getattr(_pymongo_wrapt_depth, "value", 1) - 1
654
+
655
+ patched = 0
656
+ for method in _PYMONGO_METHODS:
657
+ try:
658
+ wrapt.wrap_function_wrapper(
659
+ "pymongo.collection", f"Collection.{method}", _collection_wrapper
660
+ )
661
+ _pymongo_patched.append(("pymongo.collection", f"Collection.{method}"))
662
+ patched += 1
663
+ except (AttributeError, TypeError, ImportError):
664
+ pass
665
+ _pymongo_wrapt_installed = patched > 0
666
+ return _pymongo_wrapt_installed
667
+
668
+
669
+ def uninstall_pymongo() -> None:
670
+ """Remove wrapt Collection patches and clear pending state. The monitoring
671
+ listener stays registered (no pymongo deregister API) but goes dormant once
672
+ the hook runtime is cleared."""
673
+ global _pymongo_wrapt_installed
674
+ import importlib
675
+
676
+ for module, name in _pymongo_patched:
677
+ try:
678
+ mod = importlib.import_module(module)
679
+ cls_name, meth = name.split(".")
680
+ cls = getattr(mod, cls_name)
681
+ bound = getattr(cls, meth)
682
+ original = getattr(bound, "__wrapped__", None)
683
+ if original is not None:
684
+ setattr(cls, meth, original)
685
+ except Exception:
686
+ logger.debug("pymongo wrapt removal skipped", exc_info=True)
687
+ _pymongo_patched.clear()
688
+ _pymongo_pending.clear()
689
+ _pymongo_wrapt_installed = False