bugsradar 3.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.
bugsradar/__init__.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ BugsRadar: errors of your Python application in Telegram, Discord or Pushover.
3
+
4
+ import bugsradar
5
+ bugsradar.init(api_key=os.environ["BUGSRADAR_KEY"])
6
+
7
+ Documentation: https://bugsradar.com/docs/python/
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import atexit
13
+ import logging
14
+ import threading
15
+ import time
16
+ from datetime import timedelta
17
+ from typing import Any, Mapping, Optional, Union
18
+
19
+ from . import _client, _hooks, _logging
20
+ from ._client import Client
21
+ from ._logging import LoggingHandler
22
+ from ._version import __version__
23
+
24
+ __all__ = ["init", "send", "send_exception", "flush", "LoggingHandler", "__version__"]
25
+
26
+ Seconds = Union[float, timedelta]
27
+
28
+ _init_lock = threading.Lock()
29
+ _exit_hook_registered = False
30
+ _last_not_initialized_warning = 0.0
31
+
32
+
33
+ def init(
34
+ api_key: str,
35
+ *,
36
+ environment: Optional[str] = None,
37
+ host: Optional[str] = None,
38
+ app_version: Optional[str] = None,
39
+ logging_level: Union[int, str, None] = logging.ERROR,
40
+ capture_uncaught: bool = True,
41
+ repeat_interval: Seconds = 5.0,
42
+ queue_capacity: int = 1000,
43
+ shutdown_timeout: Seconds = 5.0,
44
+ request_timeout: Seconds = 15.0,
45
+ api_url: Optional[str] = None,
46
+ ) -> None:
47
+ """
48
+ Connects BugsRadar to logging and to uncaught exceptions; the application's logging setup stays as it is.
49
+ Call it once, at the start of the program; a second call replaces the settings of the first.
50
+ An empty api_key or an unknown logging_level raises ValueError.
51
+ """
52
+ global _exit_hook_registered
53
+
54
+ level = None if logging_level is None else _logging.level_number(logging_level)
55
+ client = Client(
56
+ api_key,
57
+ environment=environment,
58
+ host=host,
59
+ app_version=app_version,
60
+ repeat_interval=repeat_interval,
61
+ queue_capacity=queue_capacity,
62
+ shutdown_timeout=shutdown_timeout,
63
+ request_timeout=request_timeout,
64
+ api_url=api_url,
65
+ )
66
+
67
+ with _init_lock:
68
+ previous = _client.set_default(client)
69
+ if previous is not None:
70
+ # Whatever the previous client still holds goes out from its own thread.
71
+ previous.close()
72
+
73
+ _logging.install(level)
74
+ _hooks.install(capture_uncaught)
75
+
76
+ if not _exit_hook_registered:
77
+ _exit_hook_registered = True
78
+ atexit.register(_flush_at_exit)
79
+
80
+
81
+ def send(
82
+ *,
83
+ exception: Any = None,
84
+ level: Union[str, int, None] = None,
85
+ message_template: Optional[str] = None,
86
+ message: Optional[str] = None,
87
+ properties: Optional[Mapping[str, Any]] = None,
88
+ category: Optional[str] = None,
89
+ module: Optional[str] = None,
90
+ fingerprint: Optional[str] = None,
91
+ environment: Optional[str] = None,
92
+ host: Optional[str] = None,
93
+ app_version: Optional[str] = None,
94
+ ) -> None:
95
+ """
96
+ Queues an event and returns at once. Never raises.
97
+
98
+ message_template is the text with placeholders, the same for every occurrence ("Order {order_id} failed"): it
99
+ groups repeats. fingerprint is your own grouping key. level: "error" by default, or "critical", "warning",
100
+ "information", "debug", "trace", or a logging level.
101
+ """
102
+ client = _client.get_default()
103
+ if client is None:
104
+ _warn_not_initialized()
105
+ return
106
+ client._send(
107
+ "Direct",
108
+ exception=exception,
109
+ level=level,
110
+ message_template=message_template,
111
+ message=message,
112
+ properties=properties,
113
+ category=category,
114
+ module=module,
115
+ fingerprint=fingerprint,
116
+ environment=environment,
117
+ host=host,
118
+ app_version=app_version,
119
+ )
120
+
121
+
122
+ def send_exception(
123
+ error: BaseException,
124
+ *,
125
+ level: Union[str, int, None] = None,
126
+ message_template: Optional[str] = None,
127
+ message: Optional[str] = None,
128
+ properties: Optional[Mapping[str, Any]] = None,
129
+ category: Optional[str] = None,
130
+ module: Optional[str] = None,
131
+ fingerprint: Optional[str] = None,
132
+ ) -> None:
133
+ """Queues the exception, with its traceback and chain, and returns at once. Never raises."""
134
+ send(
135
+ exception=error,
136
+ level=level,
137
+ message_template=message_template,
138
+ message=message,
139
+ properties=properties,
140
+ category=category,
141
+ module=module,
142
+ fingerprint=fingerprint,
143
+ )
144
+
145
+
146
+ def flush(timeout: Optional[Seconds] = None) -> bool:
147
+ """
148
+ Waits until everything queued so far has left, at most `timeout` seconds (shutdown_timeout by default).
149
+ True when the queue drained in time.
150
+ """
151
+ client = _client.get_default()
152
+ if client is None:
153
+ return True
154
+ return client.flush(timeout)
155
+
156
+
157
+ def _flush_at_exit() -> None:
158
+ client = _client.get_default()
159
+ if client is not None:
160
+ client.flush()
161
+
162
+
163
+ def _warn_not_initialized() -> None:
164
+ global _last_not_initialized_warning
165
+ now = time.monotonic()
166
+ if _last_not_initialized_warning and now - _last_not_initialized_warning < _client.WARNING_INTERVAL:
167
+ return
168
+ _last_not_initialized_warning = now
169
+ _client.log.warning("BugsRadar: call bugsradar.init() first; the event was not sent")
bugsradar/_client.py ADDED
@@ -0,0 +1,448 @@
1
+ """The client: a queue, a daemon thread and the rules of delivery shared with the .NET and Node.js packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import socket
8
+ import ssl
9
+ import sys
10
+ import threading
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ import weakref
15
+ from collections import deque
16
+ from datetime import timedelta
17
+ from typing import Any, Deque, Dict, Optional, Tuple
18
+
19
+ from ._duplicates import DuplicateFilter
20
+ from ._event import Event, capture, level_name, repeat_key, stringify_properties, text, utc
21
+ from ._payload import serialize
22
+ from ._version import __version__
23
+
24
+ DEFAULT_API_URL = "https://api.bugsradar.com/api/v3/python/SendException"
25
+ # The server learns the package version from User-Agent.
26
+ USER_AGENT = "BugsRadar.Python/" + __version__
27
+ MAX_ATTEMPTS = 3
28
+ RETRY_DELAYS = (1.0, 5.0, 30.0)
29
+ DEFAULT_RETRY_AFTER = 5.0
30
+ WARNING_INTERVAL = 600.0
31
+ # How often the sending thread looks at the repeat windows when the queue is empty.
32
+ WINDOW_CHECK = 1.0
33
+
34
+ # The package's own diagnostics. Records of this logger are never sent: a delivery problem never becomes an event.
35
+ log = logging.getLogger("bugsradar")
36
+
37
+ _DELIVERED, _RETRY, _RATE_LIMITED, _DROP = range(4)
38
+
39
+ _clients: "weakref.WeakSet[Client]" = weakref.WeakSet()
40
+ _start_lock = threading.Lock()
41
+
42
+
43
+ class _Item:
44
+ """An event to send, or the marker flush() puts behind everything queued so far."""
45
+
46
+ __slots__ = ("event", "count", "marker")
47
+
48
+ def __init__(self, event: Optional[Event], count: int, marker: Optional[threading.Event]) -> None:
49
+ self.event = event
50
+ self.count = count
51
+ self.marker = marker
52
+
53
+
54
+ class _Window:
55
+ __slots__ = ("opened_at", "count", "latest")
56
+
57
+ def __init__(self, opened_at: float, latest: Event) -> None:
58
+ self.opened_at = opened_at
59
+ self.count = 0
60
+ self.latest = latest
61
+
62
+
63
+ class Client:
64
+ """
65
+ The one place events leave the process. Sending never waits for the network: the event is checked for
66
+ duplicates, folded with repeats of the same error and queued; a daemon thread posts to the server, respects 429
67
+ and retries transient failures. flush() waits for the queue, at most shutdown_timeout.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ api_key: str,
73
+ *,
74
+ environment: Optional[str] = None,
75
+ host: Optional[str] = None,
76
+ app_version: Optional[str] = None,
77
+ repeat_interval: float = 5.0,
78
+ queue_capacity: int = 1000,
79
+ shutdown_timeout: float = 5.0,
80
+ request_timeout: float = 15.0,
81
+ api_url: Optional[str] = None,
82
+ ) -> None:
83
+ if not isinstance(api_key, str) or not api_key.strip():
84
+ raise ValueError("BugsRadar: api_key is required")
85
+
86
+ self.api_key = api_key.strip()
87
+ self.api_url = text(api_url) or DEFAULT_API_URL
88
+ self.environment = text(environment)
89
+ self.host = _hostname() if host is None else text(host)
90
+ self.app_version = text(app_version)
91
+ self.repeat_interval = _number(repeat_interval, 5.0)
92
+ self.queue_capacity = int(_number(queue_capacity, 1000))
93
+ self.shutdown_timeout = _number(shutdown_timeout, 5.0)
94
+ self.request_timeout = _number(request_timeout, 15.0)
95
+
96
+ self._paused_until = 0.0
97
+ self._gone = False
98
+ self._deprecation_warned = False
99
+ self._last_warning: Dict[str, float] = {}
100
+ # Built by the sending thread before its first request; None is urllib's default.
101
+ self._tls: Optional[ssl.SSLContext] = None
102
+ self._tls_ready = False
103
+ self._reset()
104
+ _clients.add(self)
105
+ self._start()
106
+
107
+ def _reset(self) -> None:
108
+ """Fresh queue, locks and windows: at creation, and in the child after a fork, where the thread is gone."""
109
+ self._condition = threading.Condition()
110
+ self._queue: Deque[_Item] = deque()
111
+ self._pending = 0
112
+ self._closing = False
113
+ self._windows: Dict[str, _Window] = {}
114
+ self._windows_lock = threading.Lock()
115
+ self._duplicates = DuplicateFilter()
116
+ self._thread: Optional[threading.Thread] = None
117
+ self._thread_pid: Optional[int] = None
118
+
119
+ def _send(
120
+ self,
121
+ source: str,
122
+ *,
123
+ exception: Any = None,
124
+ level: Any = None,
125
+ message_template: Any = None,
126
+ message: Any = None,
127
+ properties: Any = None,
128
+ category: Any = None,
129
+ environment: Any = None,
130
+ host: Any = None,
131
+ app_version: Any = None,
132
+ module: Any = None,
133
+ fingerprint: Any = None,
134
+ timestamp: Any = None,
135
+ ) -> None:
136
+ """Entry point of init's functions, the logging handler and the hooks. Never raises."""
137
+ try:
138
+ if self._gone or self._closing:
139
+ return
140
+
141
+ level = level_name(level)
142
+ message_template = text(message_template)
143
+ message = text(message)
144
+ # Nothing to show: no exception, no text, no template.
145
+ if exception is None and message is None and message_template is None:
146
+ return
147
+ if self._duplicates.is_duplicate(exception, level, message_template, message, time.monotonic()):
148
+ return
149
+
150
+ self._enqueue(
151
+ Event(
152
+ timestamp=utc(timestamp),
153
+ level=level,
154
+ message_template=message_template,
155
+ message=message,
156
+ properties=stringify_properties(properties),
157
+ exceptions=capture(exception),
158
+ category=text(category),
159
+ environment=text(environment) or self.environment,
160
+ host=text(host) or self.host,
161
+ app_version=text(app_version) or self.app_version,
162
+ module=text(module),
163
+ fingerprint=text(fingerprint),
164
+ source=source,
165
+ )
166
+ )
167
+ except Exception:
168
+ self._warn("queue", "BugsRadar: failed to queue an event", exc_info=True)
169
+
170
+ def flush(self, timeout: Optional[float] = None) -> bool:
171
+ """
172
+ Waits until everything queued so far has left, at most `timeout` seconds (shutdown_timeout by default).
173
+ True when the queue drained in time.
174
+ """
175
+ limit = self.shutdown_timeout if timeout is None else _number(timeout, self.shutdown_timeout)
176
+ if threading.current_thread() is self._thread:
177
+ return False
178
+ self._flush_windows(True)
179
+
180
+ marker = threading.Event()
181
+ with self._condition:
182
+ self._queue.append(_Item(None, 0, marker))
183
+ self._condition.notify()
184
+ try:
185
+ self._start()
186
+ except Exception:
187
+ return False
188
+ return marker.wait(limit)
189
+
190
+ def close(self) -> None:
191
+ """Takes no new events; the thread sends what is queued and ends. Does not wait."""
192
+ self._flush_windows(True)
193
+ with self._condition:
194
+ self._closing = True
195
+ self._condition.notify()
196
+
197
+ def _enqueue(self, event: Event) -> None:
198
+ key = repeat_key(event)
199
+ with self._windows_lock:
200
+ window = self._windows.get(key)
201
+ if window is not None:
202
+ # A repeat inside the interval only bumps the counter; it leaves with the next request of this window.
203
+ window.count += 1
204
+ window.latest = event
205
+ return
206
+ self._windows[key] = _Window(time.monotonic(), event)
207
+
208
+ self._post(event, 1)
209
+
210
+ def _flush_windows(self, force: bool) -> None:
211
+ """Windows whose interval passed: repeats go out as one request with a count, idle windows close."""
212
+ try:
213
+ now = time.monotonic()
214
+ due = []
215
+ with self._windows_lock:
216
+ for key, window in list(self._windows.items()):
217
+ if not force and now - window.opened_at < self.repeat_interval:
218
+ continue
219
+ if window.count > 0:
220
+ due.append((window.latest, window.count))
221
+ window.count = 0
222
+ window.opened_at = now
223
+ else:
224
+ del self._windows[key]
225
+
226
+ for event, count in due:
227
+ self._post(event, count)
228
+ except Exception:
229
+ self._warn("windows", "BugsRadar: failed to flush repeat windows", exc_info=True)
230
+
231
+ def _post(self, event: Event, count: int) -> None:
232
+ with self._condition:
233
+ if self._closing:
234
+ return
235
+ full = self._pending >= self.queue_capacity
236
+ if not full:
237
+ self._pending += 1
238
+ self._queue.append(_Item(event, count, None))
239
+ self._condition.notify()
240
+
241
+ if full:
242
+ self._warn("queue-full", "BugsRadar: the outgoing queue is full (%d), events are being dropped", self.queue_capacity)
243
+ return
244
+ self._start()
245
+
246
+ def _start(self) -> None:
247
+ """The sending thread of this process. After a fork the child has none: the first event starts it."""
248
+ pid = os.getpid()
249
+ if self._thread_pid == pid:
250
+ return
251
+ with _start_lock:
252
+ if self._thread_pid == pid:
253
+ return
254
+ thread = threading.Thread(target=self._run, name="bugsradar", daemon=True)
255
+ thread.start()
256
+ self._thread = thread
257
+ self._thread_pid = pid
258
+
259
+ def _run(self) -> None:
260
+ """One thread sends the queue in order; markers of flush() are released when the thread reaches them."""
261
+ while True:
262
+ with self._condition:
263
+ if not self._queue and not self._closing:
264
+ self._condition.wait(WINDOW_CHECK)
265
+ item = self._queue.popleft() if self._queue else None
266
+ if item is not None and item.marker is None:
267
+ self._pending -= 1
268
+ if item is None and self._closing:
269
+ return
270
+
271
+ self._flush_windows(False)
272
+ if item is None:
273
+ continue
274
+ if item.marker is not None:
275
+ item.marker.set()
276
+ continue
277
+
278
+ try:
279
+ self._deliver(item)
280
+ except Exception:
281
+ self._warn("send", "BugsRadar: failed to send an event", exc_info=True)
282
+
283
+ def _deliver(self, item: _Item) -> None:
284
+ if self._gone or item.event is None:
285
+ return
286
+
287
+ body = serialize(item.event, item.count)
288
+ for attempt in range(MAX_ATTEMPTS):
289
+ pause = self._paused_until - time.monotonic()
290
+ if pause > 0:
291
+ time.sleep(pause)
292
+
293
+ outcome, retry_after = self._post_once(body)
294
+ if outcome in (_DELIVERED, _DROP):
295
+ return
296
+ if outcome == _RATE_LIMITED:
297
+ self._paused_until = time.monotonic() + retry_after
298
+ elif attempt + 1 < MAX_ATTEMPTS:
299
+ time.sleep(RETRY_DELAYS[attempt])
300
+
301
+ self._warn("dropped", "BugsRadar: an event was dropped after %d failed attempts", MAX_ATTEMPTS)
302
+
303
+ def _post_once(self, body: bytes) -> Tuple[int, float]:
304
+ request = urllib.request.Request(
305
+ self.api_url,
306
+ data=body,
307
+ method="POST",
308
+ headers={"Content-Type": "application/json", "X-Api-Key": self.api_key, "User-Agent": USER_AGENT},
309
+ )
310
+ if not self._tls_ready:
311
+ self._tls = _tls_context()
312
+ self._tls_ready = True
313
+ try:
314
+ with urllib.request.urlopen(request, timeout=self.request_timeout, context=self._tls) as response:
315
+ # The body is not needed; reading it frees the connection.
316
+ response.read()
317
+ if response.headers.get("Deprecation") is not None and not self._deprecation_warned:
318
+ self._deprecation_warned = True
319
+ log.warning("BugsRadar: this version of the bugsradar package is deprecated. Update it: pip install --upgrade bugsradar")
320
+ return _DELIVERED, 0.0
321
+ except urllib.error.HTTPError as error:
322
+ try:
323
+ error.read()
324
+ except Exception:
325
+ pass
326
+ finally:
327
+ error.close()
328
+ return self._interpret(error.code, error.headers)
329
+ except Exception:
330
+ # Network failure or timeout: worth a retry, not worth a warning every time.
331
+ log.debug("BugsRadar: request failed, will retry", exc_info=True)
332
+ return _RETRY, 0.0
333
+
334
+ def _interpret(self, status: int, headers: Any) -> Tuple[int, float]:
335
+ if status == 410:
336
+ # Nothing this version sends will ever be accepted: stop trying, tell the developer once.
337
+ if not self._gone:
338
+ self._gone = True
339
+ log.error("BugsRadar: this version of the bugsradar package is no longer supported, events are not delivered. Update it: pip install --upgrade bugsradar")
340
+ return _DROP, 0.0
341
+ if status == 401:
342
+ self._warn("auth", "BugsRadar: the server rejected the API key (401). Check api_key in bugsradar.init()")
343
+ return _DROP, 0.0
344
+ if status == 429:
345
+ return _RATE_LIMITED, _retry_after(headers.get("Retry-After") if headers is not None else None)
346
+ if status >= 500:
347
+ return _RETRY, 0.0
348
+
349
+ self._warn("rejected", "BugsRadar: the event was not accepted, the server returned %d", status)
350
+ return _DROP, 0.0
351
+
352
+ def _warn(self, kind: str, message: str, *args: Any, exc_info: bool = False) -> None:
353
+ """Warnings of one kind at most every ten minutes: a broken network must not flood the application's log."""
354
+ try:
355
+ now = time.monotonic()
356
+ last = self._last_warning.get(kind)
357
+ if last is not None and now - last < WARNING_INTERVAL:
358
+ return
359
+ self._last_warning[kind] = now
360
+ log.warning(message, *args, exc_info=exc_info)
361
+ except Exception:
362
+ # Diagnostics must never break the application.
363
+ pass
364
+
365
+
366
+ def _tls_context() -> Optional[ssl.SSLContext]:
367
+ """
368
+ None keeps urllib's default: the certificates the system trusts. On Windows that default also trusts the "CA"
369
+ store, where Windows keeps every intermediate certificate it has met, expired ones included. An expired
370
+ cross-signed ISRG Root X2 left there since 2025 breaks the current Let's Encrypt chain of api.bugsradar.com with
371
+ "certificate has expired", while .NET and browsers build the chain themselves and never notice. The server sends
372
+ all its intermediates, so on Windows the trusted roots are enough.
373
+ """
374
+ if sys.platform != "win32":
375
+ return None
376
+ try:
377
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
378
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
379
+ context.set_alpn_protocols(["http/1.1"])
380
+ loaded = 0
381
+ for cert, encoding, trust in ssl.enum_certificates("ROOT"):
382
+ # trust is True for any purpose, or the set of purposes the root is trusted for.
383
+ if encoding != "x509_asn" or (trust is not True and ssl.Purpose.SERVER_AUTH.oid not in trust):
384
+ continue
385
+ try:
386
+ context.load_verify_locations(cadata=cert)
387
+ loaded += 1
388
+ except ssl.SSLError:
389
+ pass
390
+ # No roots at all is not a normal Windows: Python's default is the better guess then.
391
+ return context if loaded > 0 else None
392
+ except Exception:
393
+ return None
394
+
395
+
396
+ def _hostname() -> Optional[str]:
397
+ try:
398
+ return text(socket.gethostname())
399
+ except Exception:
400
+ return None
401
+
402
+
403
+ def _number(value: Any, fallback: float) -> float:
404
+ if isinstance(value, timedelta):
405
+ value = value.total_seconds()
406
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value or value < 0:
407
+ return fallback
408
+ return float(value)
409
+
410
+
411
+ def _retry_after(value: Any) -> float:
412
+ try:
413
+ seconds = float(value)
414
+ except (TypeError, ValueError):
415
+ return DEFAULT_RETRY_AFTER
416
+ if seconds != seconds or seconds <= 0:
417
+ return DEFAULT_RETRY_AFTER
418
+ return max(1.0, seconds)
419
+
420
+
421
+ _default: Optional[Client] = None
422
+ _default_lock = threading.Lock()
423
+
424
+
425
+ def get_default() -> Optional[Client]:
426
+ """The client created by bugsradar.init()."""
427
+ return _default
428
+
429
+
430
+ def set_default(client: Optional[Client]) -> Optional[Client]:
431
+ """Makes `client` the default one and returns the previous."""
432
+ global _default
433
+ with _default_lock:
434
+ previous, _default = _default, client
435
+ return previous
436
+
437
+
438
+ def _after_fork_in_child() -> None:
439
+ # A lock held by another thread at the moment of the fork would stay locked in the child forever.
440
+ global _start_lock, _default_lock
441
+ _start_lock = threading.Lock()
442
+ _default_lock = threading.Lock()
443
+ for client in list(_clients):
444
+ client._reset()
445
+
446
+
447
+ if hasattr(os, "register_at_fork"):
448
+ os.register_at_fork(after_in_child=_after_fork_in_child)
@@ -0,0 +1,58 @@
1
+ """The same failure reaching the package twice is sent once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Any, Dict, Optional
7
+
8
+ TEXT_WINDOW = 2.0
9
+ MAX_TEXT_ENTRIES = 1000
10
+ _MARK = "_bugsradar_seen"
11
+
12
+
13
+ class DuplicateFilter:
14
+ """
15
+ The same failure often reaches the package twice: logged with logger.exception and also reported by hand.
16
+ An exception object is marked when first seen, so the mark lives and dies with it and holds no memory;
17
+ events without an exception are remembered for two seconds by level, template and text.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ self._lock = threading.Lock()
22
+ self._texts: Dict[str, float] = {}
23
+
24
+ def is_duplicate(self, exception: Any, level: str, message_template: Optional[str], message: Optional[str], now: float) -> bool:
25
+ if isinstance(exception, BaseException):
26
+ if getattr(exception, _MARK, False) is True:
27
+ return True
28
+ try:
29
+ setattr(exception, _MARK, True)
30
+ except Exception:
31
+ # An exception that takes no attributes cannot be recognised again: it is never a duplicate.
32
+ pass
33
+ return False
34
+
35
+ thrown = "" if exception is None else _safe_str(exception)
36
+ key = f"{level}|{message_template or ''}|{message or ''}|{thrown}"
37
+ with self._lock:
38
+ seen_at = self._texts.get(key)
39
+ if seen_at is not None and now - seen_at < TEXT_WINDOW:
40
+ return True
41
+ if len(self._texts) >= MAX_TEXT_ENTRIES:
42
+ self._sweep(now)
43
+ self._texts[key] = now
44
+ return False
45
+
46
+ def _sweep(self, now: float) -> None:
47
+ for key in [key for key, seen_at in self._texts.items() if now - seen_at >= TEXT_WINDOW]:
48
+ del self._texts[key]
49
+ # A flood of distinct texts within two seconds: forget everything rather than grow without bound.
50
+ if len(self._texts) >= MAX_TEXT_ENTRIES:
51
+ self._texts.clear()
52
+
53
+
54
+ def _safe_str(value: Any) -> str:
55
+ try:
56
+ return str(value)
57
+ except Exception:
58
+ return type(value).__name__
bugsradar/_event.py ADDED
@@ -0,0 +1,198 @@
1
+ """
2
+ Events are built in the caller's thread. The exception becomes plain data at once, frames without source lines,
3
+ so the queue never keeps frames and their locals alive; the source lines are read later, in the sending thread.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import traceback
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ MAX_EXCEPTIONS = 10
16
+ MAX_FRAMES = 100
17
+ MAX_PROPERTIES = 50
18
+
19
+ # Level names the server knows. Anything else becomes Error there too.
20
+ _LEVEL_NAMES = {
21
+ "trace": "Trace",
22
+ "debug": "Debug",
23
+ "info": "Information",
24
+ "information": "Information",
25
+ "warn": "Warning",
26
+ "warning": "Warning",
27
+ "error": "Error",
28
+ "exception": "Error",
29
+ "fatal": "Critical",
30
+ "critical": "Critical",
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class CapturedException:
36
+ type: str
37
+ message: Optional[str]
38
+ frames: Optional[traceback.StackSummary]
39
+
40
+ def top_frame(self) -> str:
41
+ if not self.frames:
42
+ return ""
43
+ frame = self.frames[-1]
44
+ return f"{frame.filename}:{frame.lineno}:{frame.name}"
45
+
46
+
47
+ @dataclass
48
+ class Event:
49
+ timestamp: datetime
50
+ level: str
51
+ message_template: Optional[str]
52
+ message: Optional[str]
53
+ properties: Optional[Dict[str, str]]
54
+ exceptions: Optional[List[CapturedException]]
55
+ category: Optional[str]
56
+ environment: Optional[str]
57
+ host: Optional[str]
58
+ app_version: Optional[str]
59
+ module: Optional[str]
60
+ fingerprint: Optional[str]
61
+ source: str
62
+
63
+
64
+ def capture(error: Any) -> Optional[List[CapturedException]]:
65
+ """The exception and its chain (raise ... from, or an exception raised while handling another), outermost first."""
66
+ if error is None:
67
+ return None
68
+
69
+ result: List[CapturedException] = []
70
+ seen = set()
71
+ current = error
72
+ while current is not None and len(result) < MAX_EXCEPTIONS and id(current) not in seen:
73
+ seen.add(id(current))
74
+ result.append(_capture_one(current))
75
+ current = _next(current)
76
+ return result
77
+
78
+
79
+ def _capture_one(error: Any) -> CapturedException:
80
+ if not isinstance(error, BaseException):
81
+ # send(exception="text"): no class of its own and no traceback.
82
+ return CapturedException(type(error).__name__, _safe_str(error), None)
83
+
84
+ # The frames closest to the error; a RecursionError alone would bring a thousand.
85
+ frames = list(traceback.walk_tb(error.__traceback__))[-MAX_FRAMES:]
86
+ summary = traceback.StackSummary.extract(iter(frames), lookup_lines=False) if frames else None
87
+ message = _safe_str(error)
88
+ return CapturedException(type_name(error), message or None, summary)
89
+
90
+
91
+ def _next(error: Any) -> Any:
92
+ if not isinstance(error, BaseException):
93
+ return None
94
+ if error.__cause__ is not None:
95
+ return error.__cause__
96
+ # raise ... from None hides the exception that was being handled.
97
+ if error.__suppress_context__:
98
+ return None
99
+ return error.__context__
100
+
101
+
102
+ def repeat_key(event: Event) -> str:
103
+ """
104
+ Key for folding repeats inside the package: exception type, top frame, template or text. Coarser than the
105
+ server's fingerprint on purpose: it only decides which events travel together in one request.
106
+ """
107
+ if event.fingerprint:
108
+ return "f|" + event.fingerprint
109
+ if event.exceptions:
110
+ first = event.exceptions[0]
111
+ # Without a traceback, the text tells errors of one type apart.
112
+ where = first.top_frame() or (first.message or "")
113
+ return f"e|{first.type}|{where}|{event.message_template or ''}"
114
+ return f"m|{event.level}|{event.message_template or event.message or ''}"
115
+
116
+
117
+ def type_name(error: BaseException) -> str:
118
+ """ValueError for built-ins and __main__, myapp.errors.OrderError for the rest."""
119
+ cls = type(error)
120
+ name = getattr(cls, "__qualname__", None) or cls.__name__
121
+ module = getattr(cls, "__module__", None)
122
+ if not module or module in ("builtins", "__main__"):
123
+ return name
124
+ return f"{module}.{name}"
125
+
126
+
127
+ def level_name(level: Any) -> str:
128
+ """'warning' or logging.WARNING → 'Warning'; empty or unknown → 'Error'."""
129
+ if isinstance(level, bool):
130
+ return "Error"
131
+ if isinstance(level, int):
132
+ if level >= logging.CRITICAL:
133
+ return "Critical"
134
+ if level >= logging.ERROR:
135
+ return "Error"
136
+ if level >= logging.WARNING:
137
+ return "Warning"
138
+ if level >= logging.INFO:
139
+ return "Information"
140
+ if level >= logging.DEBUG:
141
+ return "Debug"
142
+ return "Trace"
143
+ if isinstance(level, str):
144
+ return _LEVEL_NAMES.get(level.strip().lower(), "Error")
145
+ return "Error"
146
+
147
+
148
+ def stringify_properties(values: Any) -> Optional[Dict[str, str]]:
149
+ """Values travel as strings: dicts and lists as JSON, exceptions as "Type: message"."""
150
+ if not values:
151
+ return None
152
+ try:
153
+ items = list(values.items())
154
+ except AttributeError:
155
+ return None
156
+
157
+ result: Dict[str, str] = {}
158
+ for key, value in items:
159
+ if len(result) == MAX_PROPERTIES:
160
+ break
161
+ result[_safe_str(key)] = _stringify(value)
162
+ return result or None
163
+
164
+
165
+ def text(value: Any) -> Optional[str]:
166
+ """None, an empty string and a string of spaces are the same as no value."""
167
+ if value is None:
168
+ return None
169
+ result = value if isinstance(value, str) else _safe_str(value)
170
+ result = result.strip()
171
+ return result or None
172
+
173
+
174
+ def utc(value: Any) -> datetime:
175
+ if isinstance(value, datetime):
176
+ # A naive datetime is taken as local time, as datetime itself does.
177
+ return value.astimezone(timezone.utc)
178
+ return datetime.now(timezone.utc)
179
+
180
+
181
+ def _stringify(value: Any) -> str:
182
+ if isinstance(value, str):
183
+ return value
184
+ if isinstance(value, BaseException):
185
+ return f"{type_name(value)}: {_safe_str(value)}"
186
+ if isinstance(value, (dict, list, tuple)):
187
+ try:
188
+ return json.dumps(value, ensure_ascii=False, default=str)
189
+ except Exception:
190
+ pass
191
+ return _safe_str(value)
192
+
193
+
194
+ def _safe_str(value: Any) -> str:
195
+ try:
196
+ return str(value)
197
+ except Exception:
198
+ return f"<{type(value).__name__}>"
bugsradar/_hooks.py ADDED
@@ -0,0 +1,61 @@
1
+ """Uncaught exceptions: sys.excepthook for the program, threading.excepthook for threads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ from typing import Any, Dict, Optional
8
+
9
+ from . import _client
10
+
11
+ _lock = threading.Lock()
12
+ _installed = False
13
+ _enabled = False
14
+
15
+
16
+ def install(enabled: bool) -> None:
17
+ """
18
+ The hooks are set once per process and report to whichever client init() created last; each calls the hook it
19
+ replaced, so the traceback is still printed and other tools keep working. init(capture_uncaught=False) turns
20
+ reporting off without taking the hooks down.
21
+ """
22
+ global _installed, _enabled
23
+ with _lock:
24
+ _enabled = enabled
25
+ if _installed or not enabled:
26
+ return
27
+ _installed = True
28
+
29
+ previous_excepthook = sys.excepthook
30
+ previous_threading_excepthook = threading.excepthook
31
+
32
+ def excepthook(exc_type: Any, exc_value: Any, exc_traceback: Any) -> None:
33
+ # Ctrl+C is the user's decision, not an error.
34
+ if not (isinstance(exc_type, type) and issubclass(exc_type, KeyboardInterrupt)):
35
+ _report(exc_value, "critical", None)
36
+ previous_excepthook(exc_type, exc_value, exc_traceback)
37
+
38
+ def threading_excepthook(args: Any) -> None:
39
+ # SystemExit ends a thread quietly; Python's own hook ignores it too.
40
+ if not (isinstance(args.exc_type, type) and issubclass(args.exc_type, SystemExit)):
41
+ thread = args.thread
42
+ _report(args.exc_value, "error", {"thread": thread.name} if thread is not None else None)
43
+ previous_threading_excepthook(args)
44
+
45
+ sys.excepthook = excepthook
46
+ threading.excepthook = threading_excepthook
47
+
48
+
49
+ def _report(error: Any, level: str, properties: Optional[Dict[str, Any]]) -> None:
50
+ """Reports and waits up to shutdown_timeout for the report to leave: the program or the thread is ending."""
51
+ if not _enabled:
52
+ return
53
+ try:
54
+ client = _client.get_default()
55
+ if client is None:
56
+ return
57
+ client._send("Uncaught", exception=error, level=level, properties=properties)
58
+ client.flush()
59
+ except Exception:
60
+ # A hook must never make the crash worse.
61
+ pass
bugsradar/_logging.py ADDED
@@ -0,0 +1,139 @@
1
+ """Records of the standard logging module → BugsRadar events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Dict, Optional, Union
9
+
10
+ from . import _client
11
+
12
+ # Attributes every LogRecord has; the rest came from extra={...}.
13
+ _RECORD_FIELDS = frozenset(vars(logging.LogRecord("", logging.ERROR, "", 0, "", (), None))) | {"message", "asctime"}
14
+ # Set on a record once it is sent: the hook and a LoggingHandler never send one record twice.
15
+ _SENT = "_bugsradar_sent"
16
+
17
+ _lock = threading.Lock()
18
+ _level: Optional[int] = None
19
+ _patched = False
20
+ _sending = threading.local()
21
+
22
+
23
+ class LoggingHandler(logging.Handler):
24
+ """
25
+ Sends log records to BugsRadar through the client of bugsradar.init(). Needed only with
26
+ init(logging_level=None): add it to the loggers you choose, in code or in a dictConfig.
27
+ """
28
+
29
+ def __init__(self, level: Union[int, str] = logging.ERROR) -> None:
30
+ super().__init__(level)
31
+
32
+ def emit(self, record: logging.LogRecord) -> None:
33
+ try:
34
+ _send(record)
35
+ except Exception:
36
+ self.handleError(record)
37
+
38
+
39
+ def install(level: Optional[int]) -> None:
40
+ """
41
+ Records of every logger at `level` and above go to BugsRadar, whatever handlers the application's logging setup
42
+ has. Logger.callHandlers is wrapped once per process, as Sentry does, and no handler is added anywhere, so
43
+ logging.basicConfig and Python's last-resort output to stderr work as before. None turns it off.
44
+ """
45
+ global _level, _patched
46
+ with _lock:
47
+ _level = level
48
+ if _patched or level is None:
49
+ return
50
+ _patched = True
51
+ original = logging.Logger.callHandlers
52
+
53
+ def call_handlers(self: logging.Logger, record: logging.LogRecord) -> Any:
54
+ try:
55
+ return original(self, record)
56
+ finally:
57
+ # After the application's own handlers: its log output comes first.
58
+ _hook(record)
59
+
60
+ logging.Logger.callHandlers = call_handlers # type: ignore[method-assign]
61
+
62
+
63
+ def level_number(level: Union[int, str]) -> int:
64
+ """logging.WARNING or "WARNING" → 30."""
65
+ if isinstance(level, int) and not isinstance(level, bool):
66
+ return level
67
+ if isinstance(level, str):
68
+ number = logging.getLevelName(level.strip().upper())
69
+ if isinstance(number, int):
70
+ return number
71
+ raise ValueError(f"BugsRadar: unknown logging_level {level!r}")
72
+
73
+
74
+ def _hook(record: logging.LogRecord) -> None:
75
+ level = _level
76
+ if level is None or record.levelno < level:
77
+ return
78
+ try:
79
+ _send(record)
80
+ except Exception:
81
+ # Reporting must never break logging.
82
+ pass
83
+
84
+
85
+ def _send(record: logging.LogRecord) -> None:
86
+ # The package's own warnings are never sent: a broken network must not feed on itself.
87
+ if record.name == "bugsradar" or record.name.startswith("bugsradar."):
88
+ return
89
+ # Logging done while a record is being sent (a __str__ of a value in extra, say) is not sent.
90
+ if getattr(record, _SENT, False) or getattr(_sending, "active", False):
91
+ return
92
+ client = _client.get_default()
93
+ if client is None:
94
+ return
95
+
96
+ try:
97
+ setattr(record, _SENT, True)
98
+ except Exception:
99
+ pass
100
+
101
+ _sending.active = True
102
+ try:
103
+ exception = _exception(record)
104
+ client._send(
105
+ "logging",
106
+ exception=exception,
107
+ level=record.levelno,
108
+ # "Order %s failed": failures of different orders are one error with a count.
109
+ message_template=str(record.msg) if record.args else None,
110
+ # logger.error(error) makes the exception itself the message: it is already in the exception.
111
+ message=None if exception is not None and exception is record.msg else _message(record),
112
+ properties=_extra(record),
113
+ category=record.name,
114
+ timestamp=datetime.fromtimestamp(record.created, timezone.utc),
115
+ )
116
+ finally:
117
+ _sending.active = False
118
+
119
+
120
+ def _exception(record: logging.LogRecord) -> Optional[BaseException]:
121
+ # logger.exception and exc_info=True bring the exception being handled.
122
+ if record.exc_info and isinstance(record.exc_info[1], BaseException):
123
+ return record.exc_info[1]
124
+ if isinstance(record.msg, BaseException):
125
+ return record.msg
126
+ return None
127
+
128
+
129
+ def _message(record: logging.LogRecord) -> str:
130
+ try:
131
+ return record.getMessage()
132
+ except Exception:
133
+ # Arguments that do not fit the format string: the format string itself is better than nothing.
134
+ return str(record.msg)
135
+
136
+
137
+ def _extra(record: logging.LogRecord) -> Optional[Dict[str, Any]]:
138
+ extra = {key: value for key, value in vars(record).items() if key not in _RECORD_FIELDS and not key.startswith("_")}
139
+ return extra or None
bugsradar/_payload.py ADDED
@@ -0,0 +1,47 @@
1
+ """Body of api/v3/python/SendException, built in the sending thread."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict
8
+
9
+ from ._event import CapturedException, Event
10
+
11
+
12
+ def serialize(event: Event, count: int) -> bytes:
13
+ """Fields without a value are left out."""
14
+ body = {
15
+ "timestamp": _iso(event.timestamp),
16
+ "level": event.level,
17
+ "messageTemplate": event.message_template,
18
+ "message": event.message,
19
+ "properties": event.properties,
20
+ "exceptions": [_exception(e) for e in event.exceptions] if event.exceptions else None,
21
+ "category": event.category,
22
+ "environment": event.environment,
23
+ "host": event.host,
24
+ "appVersion": event.app_version,
25
+ "module": event.module,
26
+ "source": event.source,
27
+ "fingerprint": event.fingerprint,
28
+ "count": count if count > 1 else 1,
29
+ }
30
+ text = json.dumps({key: value for key, value in body.items() if value is not None}, ensure_ascii=False, separators=(",", ":"))
31
+ # A lone surrogate (a file name decoded with surrogateescape, say) has no UTF-8 form: it becomes "?".
32
+ return text.encode("utf-8", errors="replace")
33
+
34
+
35
+ def _exception(captured: CapturedException) -> Dict[str, Any]:
36
+ result: Dict[str, Any] = {"type": captured.type}
37
+ if captured.message is not None:
38
+ result["message"] = captured.message
39
+ if captured.frames:
40
+ # traceback.format_tb lines, from the outer call to the error; source lines are read here.
41
+ result["traceback"] = "".join(captured.frames.format())
42
+ return result
43
+
44
+
45
+ def _iso(value: datetime) -> str:
46
+ moment = value.astimezone(timezone.utc)
47
+ return moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z"
bugsradar/_version.py ADDED
@@ -0,0 +1,9 @@
1
+ """The package version comes from its metadata, so pyproject.toml is the only place to change it."""
2
+
3
+ from importlib import metadata
4
+
5
+ try:
6
+ __version__ = metadata.version("bugsradar")
7
+ except metadata.PackageNotFoundError:
8
+ # Running from a source checkout that was never installed.
9
+ __version__ = "0.0.0"
bugsradar/py.typed ADDED
File without changes
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: bugsradar
3
+ Version: 3.0.0
4
+ Summary: Errors of your Python application in Telegram, Discord or Pushover: logging, uncaught exceptions and direct calls in one package.
5
+ Project-URL: Homepage, https://bugsradar.com/docs/python/
6
+ Author: Bistriy Sp. z o.o.
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: alerts,bugsradar,discord,error,exception,logging,pushover,telegram
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: System :: Logging
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # BugsRadar for Python
20
+
21
+ Errors of your Python application in Telegram, Discord or Pushover: the standard `logging` module, uncaught exceptions and direct calls in one package. Python 3.9 and later, no dependencies outside the standard library.
22
+
23
+ Full documentation: https://bugsradar.com/docs/python/
24
+
25
+ You need a BugsRadar project with a channel and the project's API key: create them at https://app.bugsradar.com/.
26
+
27
+ ## Install
28
+
29
+ ```
30
+ pip install bugsradar
31
+ ```
32
+
33
+ ## Set it up
34
+
35
+ Once, at the start of the program:
36
+
37
+ ```python
38
+ import os
39
+ import bugsradar
40
+
41
+ bugsradar.init(
42
+ api_key=os.environ["BUGSRADAR_KEY"],
43
+ environment="Production", # optional
44
+ )
45
+ ```
46
+
47
+ > **The API key is secret: use it only in code that runs on your servers.** Never put it in a desktop app you ship to others (PyInstaller, PySide or PyQt builds) or in a public repository: anyone can take the key out of them. For such apps, public keys are on the way: https://bugsradar.com/docs/#public-keys
48
+
49
+ From here on, records at `ERROR` and above from every logger and uncaught exceptions go to BugsRadar. `init` adds no handlers: your logging setup works as before. Django, Flask and Uvicorn log unhandled exceptions of requests, so `init` covers them too.
50
+
51
+ ## logging
52
+
53
+ ```python
54
+ logger.exception("Order %s failed", order_id)
55
+ ```
56
+
57
+ The format string becomes the message template, so failures of different orders are one error with a count.
58
+
59
+ ## Direct calls
60
+
61
+ ```python
62
+ try:
63
+ create_order(order_id)
64
+ except Exception as error:
65
+ bugsradar.send_exception(error, module="Orders")
66
+ ```
67
+
68
+ `send` and `send_exception` queue the event and return at once; nothing raises.
69
+
70
+ ## Before the program exits
71
+
72
+ `init` registers an `atexit` hook that waits up to `shutdown_timeout` (5 s) for queued reports. In a short script you can also wait yourself:
73
+
74
+ ```python
75
+ bugsradar.flush()
76
+ ```
77
+
78
+ Questions: support@bistriy.com
@@ -0,0 +1,13 @@
1
+ bugsradar/__init__.py,sha256=NFE8cR0heYa6BA4ua7aOlEAEtzFXutOcFNE1GE-YmAg,5080
2
+ bugsradar/_client.py,sha256=Be9UGEhNaLfF4RhDmIFrFCZXN0JsmA2cgpDFqW4geYc,17163
3
+ bugsradar/_duplicates.py,sha256=apKvOCbTPsOua8CyoLuqzxYH7qN9Llry3q7f2mbVhbE,2151
4
+ bugsradar/_event.py,sha256=_2cyT2ZiYSxXpdZ7f7f8oKE_A6ZhRIh_sV_jIwhViao,6238
5
+ bugsradar/_hooks.py,sha256=XfIcO5h5bs6mmA9EpaL9Tq_JmEA4xcoIR6YPBV8djFo,2268
6
+ bugsradar/_logging.py,sha256=MbOZX-shOIj5UKw3ul5f2hiM3frImQ3gCT607Q_ohpw,4893
7
+ bugsradar/_payload.py,sha256=8wjF0t3n7D1ma_15M3apENJLRC2nhn2q8Whtd2aZnE4,1805
8
+ bugsradar/_version.py,sha256=jMDiRdtqbg0Es8WA64pnNLvYhASMPk6yqRqktrEMo9o,314
9
+ bugsradar/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ bugsradar-3.0.0.dist-info/METADATA,sha256=4SDG-9TWpm4rLl7Ci9zkwuS6pTyhR27U1_cPV721YYs,2587
11
+ bugsradar-3.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ bugsradar-3.0.0.dist-info/licenses/LICENSE,sha256=i-sSF-C-tqqEgbASCdz2eMVgDg7fjlnTyoUZ9VSfsRM,1075
13
+ bugsradar-3.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bistriy Sp. z o.o.
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.