traceseed 0.1.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.
traceseed/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """TraceSeed — captura de falhas sem dependências externas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from .api import (
8
+ capture,
9
+ capture_exception,
10
+ get_last_capture,
11
+ guard,
12
+ install,
13
+ install_asyncio,
14
+ register_codec,
15
+ register_collector,
16
+ uninstall,
17
+ uninstall_asyncio,
18
+ unregister_codec,
19
+ unregister_collector,
20
+ )
21
+ from .config import TraceSeedConfig, configure, get_config, reset_config
22
+ from .context import (
23
+ breadcrumb,
24
+ clear_context,
25
+ context,
26
+ current_breadcrumbs,
27
+ current_context,
28
+ reset_context,
29
+ set_context,
30
+ )
31
+ from .errors import (
32
+ CallbackError,
33
+ ConfigurationError,
34
+ IntegrityError,
35
+ InvalidPackageError,
36
+ ReplayError,
37
+ SerializationError,
38
+ StorageError,
39
+ TraceSeedError,
40
+ )
41
+ from .models import (
42
+ Breadcrumb,
43
+ CallableInfo,
44
+ CaptureContext,
45
+ CaptureResult,
46
+ ExceptionInfo,
47
+ FailureRecord,
48
+ FrameInfo,
49
+ RuntimeInfo,
50
+ )
51
+ from .storage import ArchiveStorage, DirectoryStorage, MemoryStorage, StoredFailure
52
+
53
+ __all__ = [
54
+ "__version__",
55
+ # api
56
+ "capture",
57
+ "capture_exception",
58
+ "get_last_capture",
59
+ "guard",
60
+ "install",
61
+ "install_asyncio",
62
+ "register_codec",
63
+ "register_collector",
64
+ "uninstall",
65
+ "uninstall_asyncio",
66
+ "unregister_codec",
67
+ "unregister_collector",
68
+ # errors
69
+ "TraceSeedError",
70
+ "CallbackError",
71
+ "ConfigurationError",
72
+ "IntegrityError",
73
+ "InvalidPackageError",
74
+ "ReplayError",
75
+ "SerializationError",
76
+ "StorageError",
77
+ # config
78
+ "TraceSeedConfig",
79
+ "configure",
80
+ "get_config",
81
+ "reset_config",
82
+ # context
83
+ "breadcrumb",
84
+ "clear_context",
85
+ "context",
86
+ "current_breadcrumbs",
87
+ "current_context",
88
+ "reset_context",
89
+ "set_context",
90
+ # models
91
+ "Breadcrumb",
92
+ "CallableInfo",
93
+ "CaptureContext",
94
+ "CaptureResult",
95
+ "ExceptionInfo",
96
+ "FailureRecord",
97
+ "FrameInfo",
98
+ "RuntimeInfo",
99
+ # storage
100
+ "ArchiveStorage",
101
+ "DirectoryStorage",
102
+ "MemoryStorage",
103
+ "StoredFailure",
104
+ ]
traceseed/api.py ADDED
@@ -0,0 +1,473 @@
1
+ """API pública: decoradores, context managers, hooks globais e registro.
2
+
3
+ Semântica de strict:
4
+ strict=False (padrão): falhas internas de captura não substituem a exceção
5
+ original; o erro vai para stderr; re_raise continua funcionando normalmente.
6
+ strict=True: falhas internas levantam StorageError (com exceção original como
7
+ __cause__); falha de callback levanta CallbackError; o comportamento é igual
8
+ nos quatro contextos de captura.
9
+
10
+ asyncio hooks:
11
+ install_asyncio() é idempotente por loop: instalar duas vezes não empilha
12
+ wrappers. Usa WeakKeyDictionary para evitar retenção de loops e colisão
13
+ de id() após garbage collection.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import functools
20
+ import inspect
21
+ import sys
22
+ import threading
23
+ import weakref
24
+ from collections.abc import Callable, Generator
25
+ from contextlib import contextmanager, suppress
26
+ from inspect import BoundArguments
27
+ from typing import Any, TypeVar
28
+
29
+ from .collectors import CollectorRegistry
30
+ from .config import TraceSeedConfig, get_config
31
+ from .engine import CaptureEngine
32
+ from .errors import CallbackError, StorageError
33
+ from .models import CallableInfo, CaptureContext, CaptureResult
34
+ from .serialization import SafeSerializer
35
+ from .storage.archive import ArchiveStorage
36
+
37
+ F = TypeVar("F", bound=Callable[..., Any])
38
+
39
+ _global_registry: CollectorRegistry = CollectorRegistry()
40
+ _global_codecs: dict[str, Any] = {}
41
+ _installed: bool = False
42
+ _old_excepthook: Any = None
43
+ _old_thread_excepthook: Any = None
44
+ _last_capture: CaptureResult | None = None
45
+
46
+ # Asyncio: WeakKeyDictionary usa a referência real do loop como chave.
47
+ # Quando o loop é coletado pelo GC, a entrada some automaticamente.
48
+ # Isso evita colisões de id() entre loops destruídos e novos.
49
+ _asyncio_handlers: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, Any] = (
50
+ weakref.WeakKeyDictionary()
51
+ )
52
+
53
+ _SKIP_TYPES = (KeyboardInterrupt, SystemExit)
54
+
55
+
56
+ def _make_serializer(config: TraceSeedConfig) -> SafeSerializer:
57
+ ser = SafeSerializer(config)
58
+ for codec in _global_codecs.values():
59
+ ser.register_codec(codec)
60
+ return ser
61
+
62
+
63
+ def _default_storage(config: TraceSeedConfig, serializer: SafeSerializer) -> ArchiveStorage:
64
+ return ArchiveStorage(config, serializer)
65
+
66
+
67
+ def _is_importable(func: Callable[..., Any]) -> bool:
68
+ module = getattr(func, "__module__", None)
69
+ qualname = getattr(func, "__qualname__", "")
70
+ if module in (None, "__main__"):
71
+ return False
72
+ return "<locals>" not in qualname
73
+
74
+
75
+ def _bind_arguments(func: Callable[..., Any], args: tuple, kwargs: dict) -> dict[str, Any]:
76
+ try:
77
+ sig = inspect.signature(func)
78
+ bound: BoundArguments = sig.bind(*args, **kwargs)
79
+ bound.apply_defaults()
80
+ return dict(bound.arguments)
81
+ except (TypeError, ValueError):
82
+ return {}
83
+
84
+
85
+ def _raise_strict(storage_error_msg: str, original: BaseException) -> None:
86
+ """Levanta StorageError preservando a exceção original como __cause__."""
87
+ raise StorageError(storage_error_msg) from original
88
+
89
+
90
+ def _invoke_callback(
91
+ on_captured: Callable[[CaptureResult], Any],
92
+ result: CaptureResult,
93
+ strict: bool,
94
+ original: BaseException,
95
+ ) -> None:
96
+ """Invoca on_captured respeitando strict."""
97
+ if strict:
98
+ try:
99
+ on_captured(result)
100
+ except Exception as cb_err:
101
+ raise CallbackError(str(cb_err)) from original
102
+ else:
103
+ with suppress(Exception):
104
+ on_captured(result)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # capture_exception
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def capture_exception(
113
+ exception: BaseException,
114
+ *,
115
+ config: TraceSeedConfig | None = None,
116
+ storage: Any = None,
117
+ metadata: dict[str, Any] | None = None,
118
+ operation: str | None = None,
119
+ callable_info: CallableInfo | None = None,
120
+ replay_arguments: tuple | None = None,
121
+ replay_keyword_arguments: dict | None = None,
122
+ strict: bool = False,
123
+ on_captured: Callable[[CaptureResult], Any] | None = None,
124
+ ) -> CaptureResult | None:
125
+ if not isinstance(exception, BaseException):
126
+ raise TypeError(f"esperado BaseException, recebeu {type(exception).__name__}")
127
+
128
+ cfg = config or get_config()
129
+ ser = _make_serializer(cfg)
130
+ stor = storage if storage is not None else _default_storage(cfg, ser)
131
+
132
+ ctx = CaptureContext(
133
+ operation=operation,
134
+ metadata=metadata or {},
135
+ arguments={},
136
+ callable_info=callable_info,
137
+ replay_arguments=replay_arguments,
138
+ replay_keyword_arguments=replay_keyword_arguments,
139
+ )
140
+
141
+ engine = CaptureEngine(
142
+ config=cfg,
143
+ collectors=_global_registry,
144
+ storage=stor,
145
+ serializer=ser,
146
+ )
147
+
148
+ result = engine.capture(exception, ctx)
149
+
150
+ if result.capture_error:
151
+ if strict:
152
+ raise StorageError(result.capture_error) from exception
153
+ print(f"traceseed: {result.capture_error}", file=sys.stderr)
154
+ return None
155
+
156
+ global _last_capture
157
+ _last_capture = result
158
+
159
+ if on_captured is not None:
160
+ _invoke_callback(on_captured, result, strict, exception)
161
+
162
+ return result
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Decorator @capture
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def capture(
171
+ func: F | None = None,
172
+ *,
173
+ storage: Any = None,
174
+ config: TraceSeedConfig | None = None,
175
+ replayable: bool = False,
176
+ operation: str | None = None,
177
+ on_captured: Callable[[CaptureResult], Any] | None = None,
178
+ strict: bool = False,
179
+ ) -> Any:
180
+ def decorator(fn: F) -> F:
181
+ if asyncio.iscoroutinefunction(fn):
182
+
183
+ @functools.wraps(fn)
184
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
185
+ cfg = config or get_config()
186
+ try:
187
+ return await fn(*args, **kwargs)
188
+ except _SKIP_TYPES:
189
+ raise
190
+ except BaseException as exc:
191
+ _capture_in_wrapper(
192
+ fn,
193
+ args,
194
+ kwargs,
195
+ exc,
196
+ cfg,
197
+ replayable,
198
+ operation,
199
+ storage,
200
+ on_captured,
201
+ strict,
202
+ )
203
+ if cfg.re_raise:
204
+ raise
205
+ return None
206
+
207
+ return async_wrapper # type: ignore
208
+ else:
209
+
210
+ @functools.wraps(fn)
211
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
212
+ cfg = config or get_config()
213
+ try:
214
+ return fn(*args, **kwargs)
215
+ except _SKIP_TYPES:
216
+ raise
217
+ except BaseException as exc:
218
+ _capture_in_wrapper(
219
+ fn,
220
+ args,
221
+ kwargs,
222
+ exc,
223
+ cfg,
224
+ replayable,
225
+ operation,
226
+ storage,
227
+ on_captured,
228
+ strict,
229
+ )
230
+ if cfg.re_raise:
231
+ raise
232
+ return None
233
+
234
+ return sync_wrapper # type: ignore
235
+
236
+ if func is not None:
237
+ return decorator(func)
238
+ return decorator
239
+
240
+
241
+ def _capture_in_wrapper(
242
+ fn: Callable[..., Any],
243
+ args: tuple,
244
+ kwargs: dict,
245
+ exc: BaseException,
246
+ cfg: TraceSeedConfig,
247
+ replayable: bool,
248
+ operation: str | None,
249
+ storage: Any,
250
+ on_captured: Callable | None,
251
+ strict: bool,
252
+ ) -> None:
253
+ """Lógica comum de captura para decoradores sync e async."""
254
+ arguments = _bind_arguments(fn, args, kwargs)
255
+ importable = _is_importable(fn) and replayable
256
+ ci = CallableInfo(
257
+ module=getattr(fn, "__module__", ""),
258
+ qualname=getattr(fn, "__qualname__", ""),
259
+ replayable=importable,
260
+ reason=None if importable else ("callable não importável" if replayable else None),
261
+ )
262
+ op = operation or getattr(fn, "__qualname__", None)
263
+ ser = _make_serializer(cfg)
264
+ stor = storage if storage is not None else _default_storage(cfg, ser)
265
+ ctx = CaptureContext(
266
+ operation=op,
267
+ metadata={},
268
+ arguments=arguments,
269
+ callable_info=ci,
270
+ replay_arguments=args if importable else None,
271
+ replay_keyword_arguments=kwargs if importable else None,
272
+ )
273
+ engine = CaptureEngine(cfg, _global_registry, stor, ser)
274
+ result = engine.capture(exc, ctx)
275
+
276
+ if result.capture_error:
277
+ if strict:
278
+ raise StorageError(result.capture_error) from exc
279
+ print(f"traceseed: {result.capture_error}", file=sys.stderr)
280
+ return
281
+
282
+ global _last_capture
283
+ _last_capture = result
284
+ if on_captured:
285
+ _invoke_callback(on_captured, result, strict, exc)
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # guard context manager
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ @contextmanager
294
+ def guard(
295
+ operation: str,
296
+ *,
297
+ storage: Any = None,
298
+ config: TraceSeedConfig | None = None,
299
+ on_captured: Callable[[CaptureResult], Any] | None = None,
300
+ strict: bool = False,
301
+ ) -> Generator[None, None, None]:
302
+ cfg = config or get_config()
303
+ try:
304
+ yield
305
+ except _SKIP_TYPES:
306
+ raise
307
+ except BaseException as exc:
308
+ ser = _make_serializer(cfg)
309
+ stor = storage if storage is not None else _default_storage(cfg, ser)
310
+ ctx = CaptureContext(operation=operation, metadata={}, arguments={})
311
+ engine = CaptureEngine(cfg, _global_registry, stor, ser)
312
+ result = engine.capture(exc, ctx)
313
+
314
+ if result.capture_error:
315
+ if strict:
316
+ raise StorageError(result.capture_error) from exc
317
+ print(f"traceseed: {result.capture_error}", file=sys.stderr)
318
+ else:
319
+ global _last_capture
320
+ _last_capture = result
321
+ if on_captured:
322
+ _invoke_callback(on_captured, result, strict, exc)
323
+
324
+ if cfg.re_raise:
325
+ raise
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Hooks globais
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ def install(storage: Any = None, config: TraceSeedConfig | None = None) -> None:
334
+ """Instala hooks em sys.excepthook e threading.excepthook.
335
+
336
+ É idempotente: chamadas repetidas não instalam hooks duplos.
337
+ O hook anterior (incluindo handlers customizados) é sempre chamado.
338
+ """
339
+ global _installed, _old_excepthook, _old_thread_excepthook
340
+
341
+ if _installed:
342
+ return
343
+
344
+ cfg = config
345
+ stor = storage
346
+ # Captura os handlers atuais antes de sobrescrever
347
+ prev_sys = sys.excepthook
348
+ prev_thread = threading.excepthook
349
+
350
+ def _sys_excepthook(exc_type: type, exc_value: BaseException, exc_tb: Any) -> None:
351
+ with suppress(Exception):
352
+ capture_exception(exc_value, config=cfg, storage=stor)
353
+ # Chama o handler que estava instalado antes do TraceSeed
354
+ try:
355
+ prev_sys(exc_type, exc_value, exc_tb)
356
+ except Exception:
357
+ sys.__excepthook__(exc_type, exc_value, exc_tb)
358
+
359
+ def _thread_excepthook(hook_args: threading.ExceptHookArgs) -> None:
360
+ if hook_args.exc_value is not None:
361
+ with suppress(Exception):
362
+ capture_exception(hook_args.exc_value, config=cfg, storage=stor)
363
+ # Chama o handler anterior
364
+ with suppress(Exception):
365
+ prev_thread(hook_args)
366
+
367
+ _old_excepthook = prev_sys
368
+ _old_thread_excepthook = prev_thread
369
+ sys.excepthook = _sys_excepthook
370
+ threading.excepthook = _thread_excepthook
371
+ _installed = True
372
+
373
+
374
+ def uninstall() -> None:
375
+ """Restaura exatamente os hooks que estavam instalados antes de install()."""
376
+ global _installed, _old_excepthook, _old_thread_excepthook
377
+
378
+ if not _installed:
379
+ return
380
+
381
+ if _old_excepthook is not None:
382
+ sys.excepthook = _old_excepthook
383
+ if _old_thread_excepthook is not None:
384
+ threading.excepthook = _old_thread_excepthook
385
+
386
+ _old_excepthook = None
387
+ _old_thread_excepthook = None
388
+ _installed = False
389
+
390
+
391
+ def install_asyncio(
392
+ loop: asyncio.AbstractEventLoop | None = None,
393
+ storage: Any = None,
394
+ config: TraceSeedConfig | None = None,
395
+ ) -> asyncio.AbstractEventLoop:
396
+ """Instala handler de exceções no loop asyncio, preservando o handler anterior.
397
+
398
+ É idempotente por loop: instalar duas vezes não empilha wrappers.
399
+ Usa WeakKeyDictionary para evitar retenção do loop e colisão de id().
400
+
401
+ Retorna o loop configurado (útil para chamar uninstall_asyncio depois).
402
+ """
403
+ cfg = config
404
+ stor = storage
405
+
406
+ try:
407
+ lp = loop or asyncio.get_event_loop()
408
+ except RuntimeError:
409
+ lp = asyncio.new_event_loop()
410
+
411
+ # Idempotência: se já instalado neste loop, retorna sem re-instalar
412
+ if lp in _asyncio_handlers:
413
+ return lp
414
+
415
+ old_handler = lp.get_exception_handler()
416
+ _asyncio_handlers[lp] = old_handler
417
+
418
+ def _asyncio_handler(lp2: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
419
+ exc = context.get("exception")
420
+ if exc is not None:
421
+ with suppress(Exception):
422
+ capture_exception(exc, config=cfg, storage=stor)
423
+ if old_handler is not None:
424
+ try:
425
+ old_handler(lp2, context)
426
+ except Exception:
427
+ lp2.default_exception_handler(context)
428
+ else:
429
+ lp2.default_exception_handler(context)
430
+
431
+ lp.set_exception_handler(_asyncio_handler)
432
+ return lp
433
+
434
+
435
+ def uninstall_asyncio(loop: asyncio.AbstractEventLoop | None = None) -> None:
436
+ """Restaura o handler asyncio anterior ao install_asyncio().
437
+
438
+ É seguro chamar mais de uma vez (idempotente).
439
+ """
440
+ try:
441
+ lp = loop or asyncio.get_event_loop()
442
+ except RuntimeError:
443
+ return
444
+
445
+ if lp not in _asyncio_handlers:
446
+ return
447
+
448
+ lp.set_exception_handler(_asyncio_handlers.pop(lp))
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # Registro global
453
+ # ---------------------------------------------------------------------------
454
+
455
+
456
+ def get_last_capture() -> CaptureResult | None:
457
+ return _last_capture
458
+
459
+
460
+ def register_collector(collector: Any) -> None:
461
+ _global_registry.register(collector)
462
+
463
+
464
+ def unregister_collector(name: str) -> None:
465
+ _global_registry.unregister(name)
466
+
467
+
468
+ def register_codec(codec: Any) -> None:
469
+ _global_codecs[codec.type_name] = codec
470
+
471
+
472
+ def unregister_codec(name: str) -> None:
473
+ _global_codecs.pop(name, None)