errorgap 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.
- errorgap/__init__.py +108 -0
- errorgap/backtrace.py +45 -0
- errorgap/client.py +161 -0
- errorgap/configuration.py +48 -0
- errorgap/django.py +83 -0
- errorgap/fastapi.py +67 -0
- errorgap/filter.py +27 -0
- errorgap/flask.py +83 -0
- errorgap/handlers.py +41 -0
- errorgap/notice.py +47 -0
- errorgap/version.py +1 -0
- errorgap-0.1.0.dist-info/METADATA +163 -0
- errorgap-0.1.0.dist-info/RECORD +15 -0
- errorgap-0.1.0.dist-info/WHEEL +4 -0
- errorgap-0.1.0.dist-info/licenses/LICENSE +21 -0
errorgap/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .client import Client, DeliveryResult
|
|
6
|
+
from .configuration import Configuration
|
|
7
|
+
from .handlers import install_excepthook, uninstall_excepthook
|
|
8
|
+
from .version import VERSION
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"init",
|
|
12
|
+
"notify",
|
|
13
|
+
"flush",
|
|
14
|
+
"shutdown",
|
|
15
|
+
"configuration",
|
|
16
|
+
"client",
|
|
17
|
+
"Configuration",
|
|
18
|
+
"Client",
|
|
19
|
+
"DeliveryResult",
|
|
20
|
+
"VERSION",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_configuration = Configuration()
|
|
24
|
+
_client = Client(_configuration)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init(
|
|
28
|
+
*,
|
|
29
|
+
endpoint: Optional[str] = None,
|
|
30
|
+
project_slug: Optional[str] = None,
|
|
31
|
+
project_id: Optional[str] = None,
|
|
32
|
+
api_key: Optional[str] = None,
|
|
33
|
+
environment: Optional[str] = None,
|
|
34
|
+
root_directory: Optional[str] = None,
|
|
35
|
+
async_: Optional[bool] = None,
|
|
36
|
+
filter_keys: Optional[tuple] = None,
|
|
37
|
+
logger: Optional[Any] = None,
|
|
38
|
+
capture_globals: bool = True,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Configure the SDK and install global error hooks.
|
|
41
|
+
|
|
42
|
+
Reads ``ERRORGAP_ENDPOINT``, ``ERRORGAP_PROJECT_SLUG``,
|
|
43
|
+
``ERRORGAP_PROJECT_ID``, and ``ERRORGAP_API_KEY`` from the environment when
|
|
44
|
+
the matching keyword argument is omitted.
|
|
45
|
+
"""
|
|
46
|
+
global _configuration, _client
|
|
47
|
+
|
|
48
|
+
overrides: Dict[str, Any] = {}
|
|
49
|
+
if endpoint is not None:
|
|
50
|
+
overrides["endpoint"] = endpoint
|
|
51
|
+
if project_slug is not None:
|
|
52
|
+
overrides["project_slug"] = project_slug
|
|
53
|
+
if project_id is not None:
|
|
54
|
+
overrides["project_id"] = project_id
|
|
55
|
+
if api_key is not None:
|
|
56
|
+
overrides["api_key"] = api_key
|
|
57
|
+
if environment is not None:
|
|
58
|
+
overrides["environment"] = environment
|
|
59
|
+
if root_directory is not None:
|
|
60
|
+
overrides["root_directory"] = root_directory
|
|
61
|
+
if async_ is not None:
|
|
62
|
+
overrides["async_"] = async_
|
|
63
|
+
if filter_keys is not None:
|
|
64
|
+
overrides["filter_keys"] = filter_keys
|
|
65
|
+
if logger is not None:
|
|
66
|
+
overrides["logger"] = logger
|
|
67
|
+
|
|
68
|
+
_configuration = Configuration(**overrides)
|
|
69
|
+
_client.configure(_configuration)
|
|
70
|
+
|
|
71
|
+
if capture_globals:
|
|
72
|
+
install_excepthook(_client)
|
|
73
|
+
else:
|
|
74
|
+
uninstall_excepthook()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def notify(
|
|
78
|
+
exc: BaseException,
|
|
79
|
+
context: Optional[Dict[str, Any]] = None,
|
|
80
|
+
environment: Optional[Dict[str, Any]] = None,
|
|
81
|
+
session: Optional[Dict[str, Any]] = None,
|
|
82
|
+
params: Optional[Dict[str, Any]] = None,
|
|
83
|
+
sync: bool = False,
|
|
84
|
+
) -> DeliveryResult:
|
|
85
|
+
return _client.notify(
|
|
86
|
+
exc,
|
|
87
|
+
context=context,
|
|
88
|
+
environment=environment,
|
|
89
|
+
session=session,
|
|
90
|
+
params=params,
|
|
91
|
+
sync=sync,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def flush(timeout: Optional[float] = None) -> None:
|
|
96
|
+
_client.flush(timeout)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def shutdown(timeout: Optional[float] = None) -> None:
|
|
100
|
+
_client.shutdown(timeout)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def configuration() -> Configuration:
|
|
104
|
+
return _configuration
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def client() -> Client:
|
|
108
|
+
return _client
|
errorgap/backtrace.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_backtrace(exc: BaseException, root_directory: str) -> List[Dict[str, Any]]:
|
|
9
|
+
frames: List[Dict[str, Any]] = []
|
|
10
|
+
tb_frames = traceback.extract_tb(exc.__traceback__)
|
|
11
|
+
|
|
12
|
+
for index, frame in enumerate(tb_frames):
|
|
13
|
+
file = frame.filename
|
|
14
|
+
frames.append(
|
|
15
|
+
{
|
|
16
|
+
"file": _relative_file(file, root_directory),
|
|
17
|
+
"line": frame.lineno,
|
|
18
|
+
"function": frame.name,
|
|
19
|
+
"in_app": _is_in_app(file, root_directory),
|
|
20
|
+
"index": index,
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return frames
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _relative_file(file: Optional[str], root: str) -> Optional[str]:
|
|
28
|
+
if not file:
|
|
29
|
+
return file
|
|
30
|
+
if not root:
|
|
31
|
+
return file
|
|
32
|
+
normalized = root if root.endswith(os.sep) else root + os.sep
|
|
33
|
+
if file.startswith(normalized):
|
|
34
|
+
return file[len(normalized):]
|
|
35
|
+
return file
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_in_app(file: Optional[str], root: str) -> bool:
|
|
39
|
+
if not file or not root:
|
|
40
|
+
return False
|
|
41
|
+
if "site-packages" in file:
|
|
42
|
+
return False
|
|
43
|
+
if file.startswith("<"):
|
|
44
|
+
return False
|
|
45
|
+
return file.startswith(root)
|
errorgap/client.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
from urllib import error as urlerror
|
|
10
|
+
from urllib import request as urlrequest
|
|
11
|
+
|
|
12
|
+
from .configuration import Configuration
|
|
13
|
+
from .notice import build_notice
|
|
14
|
+
from .version import VERSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DeliveryResult:
|
|
19
|
+
status: Optional[int] = None
|
|
20
|
+
body: Optional[str] = None
|
|
21
|
+
error: Optional[BaseException] = None
|
|
22
|
+
queued: bool = False
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def success(self) -> bool:
|
|
26
|
+
return self.error is None and self.status is not None and 200 <= self.status < 300
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Client:
|
|
30
|
+
def __init__(self, configuration: Configuration):
|
|
31
|
+
self._configuration = configuration
|
|
32
|
+
self._queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue()
|
|
33
|
+
self._worker: Optional[threading.Thread] = None
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def configure(self, configuration: Configuration) -> None:
|
|
37
|
+
self._configuration = configuration
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def configuration(self) -> Configuration:
|
|
41
|
+
return self._configuration
|
|
42
|
+
|
|
43
|
+
def notify(
|
|
44
|
+
self,
|
|
45
|
+
exc: BaseException,
|
|
46
|
+
context: Optional[Dict[str, Any]] = None,
|
|
47
|
+
environment: Optional[Dict[str, Any]] = None,
|
|
48
|
+
session: Optional[Dict[str, Any]] = None,
|
|
49
|
+
params: Optional[Dict[str, Any]] = None,
|
|
50
|
+
sync: bool = False,
|
|
51
|
+
) -> DeliveryResult:
|
|
52
|
+
try:
|
|
53
|
+
self._configuration.validate()
|
|
54
|
+
notice = build_notice(
|
|
55
|
+
exc,
|
|
56
|
+
self._configuration,
|
|
57
|
+
context=context,
|
|
58
|
+
environment=environment,
|
|
59
|
+
session=session,
|
|
60
|
+
params=params,
|
|
61
|
+
)
|
|
62
|
+
except Exception as caught: # noqa: BLE001 — SDK must not raise
|
|
63
|
+
self._log(caught)
|
|
64
|
+
return DeliveryResult(error=caught)
|
|
65
|
+
|
|
66
|
+
if sync or not self._configuration.async_:
|
|
67
|
+
return self.deliver(notice)
|
|
68
|
+
|
|
69
|
+
self._ensure_worker()
|
|
70
|
+
self._queue.put(notice)
|
|
71
|
+
return DeliveryResult(queued=True, status=202)
|
|
72
|
+
|
|
73
|
+
def deliver(self, notice: Dict[str, Any]) -> DeliveryResult:
|
|
74
|
+
url = _notices_url(self._configuration)
|
|
75
|
+
body = json.dumps(notice).encode("utf-8")
|
|
76
|
+
headers = {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"User-Agent": f"errorgap-python/{VERSION}",
|
|
79
|
+
}
|
|
80
|
+
if self._configuration.api_key:
|
|
81
|
+
headers["X-Errorgap-Project-Key"] = self._configuration.api_key
|
|
82
|
+
|
|
83
|
+
req = urlrequest.Request(url, data=body, method="POST", headers=headers)
|
|
84
|
+
try:
|
|
85
|
+
with urlrequest.urlopen(req, timeout=10) as response:
|
|
86
|
+
response_body = response.read().decode("utf-8", errors="replace")
|
|
87
|
+
return DeliveryResult(status=response.status, body=response_body)
|
|
88
|
+
except urlerror.HTTPError as http_err:
|
|
89
|
+
response_body = ""
|
|
90
|
+
try:
|
|
91
|
+
response_body = http_err.read().decode("utf-8", errors="replace")
|
|
92
|
+
except Exception: # noqa: BLE001
|
|
93
|
+
pass
|
|
94
|
+
return DeliveryResult(status=http_err.code, body=response_body, error=http_err)
|
|
95
|
+
except Exception as caught: # noqa: BLE001 — network/socket errors
|
|
96
|
+
self._log(caught)
|
|
97
|
+
return DeliveryResult(error=caught)
|
|
98
|
+
|
|
99
|
+
def flush(self, timeout: Optional[float] = None) -> None:
|
|
100
|
+
"""Block until all queued notices have been delivered."""
|
|
101
|
+
with self._lock:
|
|
102
|
+
worker = self._worker
|
|
103
|
+
if worker is None:
|
|
104
|
+
return
|
|
105
|
+
self._queue.join() if timeout is None else _wait_join(self._queue, timeout)
|
|
106
|
+
|
|
107
|
+
def shutdown(self, timeout: Optional[float] = None) -> None:
|
|
108
|
+
"""Flush and tear down the worker thread. Safe to call multiple times."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
worker = self._worker
|
|
111
|
+
self._worker = None
|
|
112
|
+
if worker is None:
|
|
113
|
+
return
|
|
114
|
+
self.flush(timeout)
|
|
115
|
+
self._queue.put(None)
|
|
116
|
+
worker.join(timeout)
|
|
117
|
+
|
|
118
|
+
def _ensure_worker(self) -> None:
|
|
119
|
+
with self._lock:
|
|
120
|
+
if self._worker is not None and self._worker.is_alive():
|
|
121
|
+
return
|
|
122
|
+
self._worker = threading.Thread(
|
|
123
|
+
target=self._worker_loop, name="errorgap-delivery", daemon=True
|
|
124
|
+
)
|
|
125
|
+
self._worker.start()
|
|
126
|
+
|
|
127
|
+
def _worker_loop(self) -> None:
|
|
128
|
+
while True:
|
|
129
|
+
item = self._queue.get()
|
|
130
|
+
try:
|
|
131
|
+
if item is None:
|
|
132
|
+
return
|
|
133
|
+
self.deliver(item)
|
|
134
|
+
finally:
|
|
135
|
+
self._queue.task_done()
|
|
136
|
+
|
|
137
|
+
def _log(self, exc: BaseException) -> None:
|
|
138
|
+
logger = self._configuration.get_logger()
|
|
139
|
+
if logger is None:
|
|
140
|
+
return
|
|
141
|
+
if isinstance(logger, logging.Logger):
|
|
142
|
+
logger.warning("[errorgap] %s: %s", exc.__class__.__name__, exc)
|
|
143
|
+
else:
|
|
144
|
+
try:
|
|
145
|
+
logger.warning(f"[errorgap] {exc.__class__.__name__}: {exc}")
|
|
146
|
+
except Exception: # noqa: BLE001
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _notices_url(configuration: Configuration) -> str:
|
|
151
|
+
base = configuration.endpoint.rstrip("/")
|
|
152
|
+
return f"{base}/api/projects/{configuration.project_slug}/notices"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _wait_join(q: "queue.Queue[Any]", timeout: float) -> None:
|
|
156
|
+
"""queue.Queue has no join-with-timeout. Poll for emptiness."""
|
|
157
|
+
import time
|
|
158
|
+
|
|
159
|
+
deadline = time.monotonic() + timeout
|
|
160
|
+
while q.unfinished_tasks > 0 and time.monotonic() < deadline:
|
|
161
|
+
time.sleep(0.01)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
DEFAULT_FILTER_KEYS = (
|
|
9
|
+
"password",
|
|
10
|
+
"password_confirmation",
|
|
11
|
+
"token",
|
|
12
|
+
"secret",
|
|
13
|
+
"api_key",
|
|
14
|
+
"authorization",
|
|
15
|
+
"cookie",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _default_environment() -> str:
|
|
20
|
+
return os.environ.get("ERRORGAP_ENVIRONMENT") or os.environ.get("ENV") or "development"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Configuration:
|
|
25
|
+
endpoint: str = field(
|
|
26
|
+
default_factory=lambda: os.environ.get("ERRORGAP_ENDPOINT", "http://127.0.0.1:3030")
|
|
27
|
+
)
|
|
28
|
+
project_slug: Optional[str] = field(
|
|
29
|
+
default_factory=lambda: os.environ.get("ERRORGAP_PROJECT_SLUG")
|
|
30
|
+
)
|
|
31
|
+
project_id: Optional[str] = field(
|
|
32
|
+
default_factory=lambda: os.environ.get("ERRORGAP_PROJECT_ID")
|
|
33
|
+
)
|
|
34
|
+
api_key: Optional[str] = field(default_factory=lambda: os.environ.get("ERRORGAP_API_KEY"))
|
|
35
|
+
environment: str = field(default_factory=_default_environment)
|
|
36
|
+
root_directory: str = field(default_factory=os.getcwd)
|
|
37
|
+
async_: bool = True
|
|
38
|
+
logger: Optional[logging.Logger] = None
|
|
39
|
+
filter_keys: tuple = DEFAULT_FILTER_KEYS
|
|
40
|
+
apm_enabled: bool = False
|
|
41
|
+
apm_sample_rate: float = 1.0
|
|
42
|
+
|
|
43
|
+
def validate(self) -> None:
|
|
44
|
+
if not self.project_slug or not str(self.project_slug).strip():
|
|
45
|
+
raise ValueError("Errorgap project_slug is required")
|
|
46
|
+
|
|
47
|
+
def get_logger(self) -> logging.Logger:
|
|
48
|
+
return self.logger or logging.getLogger("errorgap")
|
errorgap/django.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from . import notify
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorgapMiddleware:
|
|
9
|
+
"""Django middleware that reports unhandled exceptions to Errorgap.
|
|
10
|
+
|
|
11
|
+
Add ``"errorgap.django.ErrorgapMiddleware"`` to ``MIDDLEWARE``. Place it
|
|
12
|
+
early so it sees exceptions raised by inner middleware too.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, get_response: Callable[..., Any]):
|
|
16
|
+
self.get_response = get_response
|
|
17
|
+
|
|
18
|
+
def __call__(self, request: Any) -> Any:
|
|
19
|
+
return self.get_response(request)
|
|
20
|
+
|
|
21
|
+
def process_exception(self, request: Any, exception: BaseException) -> Optional[Any]:
|
|
22
|
+
notify(
|
|
23
|
+
exception,
|
|
24
|
+
sync=True,
|
|
25
|
+
context=_context(request),
|
|
26
|
+
environment=_environment(request),
|
|
27
|
+
session=_session(request),
|
|
28
|
+
params=_params(request),
|
|
29
|
+
)
|
|
30
|
+
# Returning None lets Django continue its exception handling chain.
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _context(request: Any) -> Dict[str, Any]:
|
|
35
|
+
try:
|
|
36
|
+
resolver = getattr(request, "resolver_match", None)
|
|
37
|
+
view_name = resolver.view_name if resolver else None
|
|
38
|
+
except Exception: # noqa: BLE001
|
|
39
|
+
view_name = None
|
|
40
|
+
try:
|
|
41
|
+
url = request.build_absolute_uri()
|
|
42
|
+
except Exception: # noqa: BLE001
|
|
43
|
+
url = getattr(request, "path", None)
|
|
44
|
+
return {
|
|
45
|
+
"url": url,
|
|
46
|
+
"component": view_name or getattr(request, "path", None),
|
|
47
|
+
"action": getattr(request, "method", None),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _environment(request: Any) -> Dict[str, Any]:
|
|
52
|
+
meta = getattr(request, "META", {}) or {}
|
|
53
|
+
return {
|
|
54
|
+
"method": getattr(request, "method", None),
|
|
55
|
+
"path": getattr(request, "path", None),
|
|
56
|
+
"query_string": meta.get("QUERY_STRING"),
|
|
57
|
+
"user_agent": meta.get("HTTP_USER_AGENT"),
|
|
58
|
+
"remote_addr": meta.get("REMOTE_ADDR"),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _session(request: Any) -> Dict[str, Any]:
|
|
63
|
+
try:
|
|
64
|
+
session = getattr(request, "session", None)
|
|
65
|
+
if session is None:
|
|
66
|
+
return {}
|
|
67
|
+
return dict(session.items())
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _params(request: Any) -> Dict[str, Any]:
|
|
73
|
+
out: Dict[str, Any] = {}
|
|
74
|
+
try:
|
|
75
|
+
for key, value in (getattr(request, "GET", {}) or {}).items():
|
|
76
|
+
out[key] = value
|
|
77
|
+
post = getattr(request, "POST", None)
|
|
78
|
+
if post:
|
|
79
|
+
for key, value in post.items():
|
|
80
|
+
out[key] = value
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
return out
|
|
83
|
+
return out
|
errorgap/fastapi.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict
|
|
4
|
+
|
|
5
|
+
from . import notify
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorgapMiddleware:
|
|
9
|
+
"""Pure ASGI middleware that reports unhandled exceptions to Errorgap.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI
|
|
14
|
+
from errorgap.fastapi import ErrorgapMiddleware
|
|
15
|
+
|
|
16
|
+
app = FastAPI()
|
|
17
|
+
app.add_middleware(ErrorgapMiddleware)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, app: Callable[..., Any]) -> None:
|
|
21
|
+
self.app = app
|
|
22
|
+
|
|
23
|
+
async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None:
|
|
24
|
+
if scope.get("type") != "http":
|
|
25
|
+
await self.app(scope, receive, send)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
await self.app(scope, receive, send)
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
notify(
|
|
32
|
+
exc,
|
|
33
|
+
sync=True,
|
|
34
|
+
context=_context(scope),
|
|
35
|
+
environment=_environment(scope),
|
|
36
|
+
)
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _context(scope: Dict[str, Any]) -> Dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"url": _build_url(scope),
|
|
43
|
+
"component": scope.get("path"),
|
|
44
|
+
"action": scope.get("method"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _environment(scope: Dict[str, Any]) -> Dict[str, Any]:
|
|
49
|
+
headers = {k.decode("latin1"): v.decode("latin1") for k, v in scope.get("headers", [])}
|
|
50
|
+
client = scope.get("client") or (None, None)
|
|
51
|
+
return {
|
|
52
|
+
"method": scope.get("method"),
|
|
53
|
+
"path": scope.get("path"),
|
|
54
|
+
"query_string": scope.get("query_string", b"").decode("utf-8", errors="replace"),
|
|
55
|
+
"user_agent": headers.get("user-agent"),
|
|
56
|
+
"remote_addr": client[0],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_url(scope: Dict[str, Any]) -> str:
|
|
61
|
+
scheme = scope.get("scheme", "http")
|
|
62
|
+
headers = dict(scope.get("headers", []))
|
|
63
|
+
host = headers.get(b"host", b"").decode("latin1") or "localhost"
|
|
64
|
+
path = scope.get("path", "/")
|
|
65
|
+
query = scope.get("query_string", b"").decode("utf-8", errors="replace")
|
|
66
|
+
suffix = f"?{query}" if query else ""
|
|
67
|
+
return f"{scheme}://{host}{path}{suffix}"
|
errorgap/filter.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Iterable
|
|
4
|
+
|
|
5
|
+
FILTERED = "[FILTERED]"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def filter_params(params: Dict[str, Any], filter_keys: Iterable[str]) -> Dict[str, Any]:
|
|
9
|
+
lowered = tuple(k.lower() for k in filter_keys)
|
|
10
|
+
return _walk(params, lowered)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _walk(value: Dict[str, Any], lowered: tuple) -> Dict[str, Any]:
|
|
14
|
+
out: Dict[str, Any] = {}
|
|
15
|
+
for key, val in value.items():
|
|
16
|
+
if _is_sensitive(str(key), lowered):
|
|
17
|
+
out[key] = FILTERED
|
|
18
|
+
elif isinstance(val, dict):
|
|
19
|
+
out[key] = _walk(val, lowered)
|
|
20
|
+
else:
|
|
21
|
+
out[key] = val
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_sensitive(key: str, lowered: tuple) -> bool:
|
|
26
|
+
lk = key.lower()
|
|
27
|
+
return any(needle in lk for needle in lowered)
|
errorgap/flask.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from . import notify
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def init_app(app: Any) -> None:
|
|
9
|
+
"""Register Errorgap with a Flask application.
|
|
10
|
+
|
|
11
|
+
Subscribes to Flask's ``got_request_exception`` signal so all unhandled
|
|
12
|
+
exceptions raised inside a request are reported.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
from flask import got_request_exception
|
|
16
|
+
except ImportError as exc: # pragma: no cover
|
|
17
|
+
raise RuntimeError("Flask is required to use errorgap.flask.init_app") from exc
|
|
18
|
+
|
|
19
|
+
def _on_exception(sender: Any, exception: BaseException, **_: Any) -> None:
|
|
20
|
+
try:
|
|
21
|
+
from flask import request
|
|
22
|
+
|
|
23
|
+
ctx = _context(request)
|
|
24
|
+
env = _environment(request)
|
|
25
|
+
params = _params(request)
|
|
26
|
+
except Exception: # noqa: BLE001
|
|
27
|
+
ctx, env, params = {}, {}, {}
|
|
28
|
+
|
|
29
|
+
notify(
|
|
30
|
+
exception,
|
|
31
|
+
sync=True,
|
|
32
|
+
context=ctx,
|
|
33
|
+
environment=env,
|
|
34
|
+
params=params,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# weak=False: keep a strong reference so the local closure isn't GC'd.
|
|
38
|
+
got_request_exception.connect(_on_exception, app, weak=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _context(request: Any) -> Dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"url": getattr(request, "url", None),
|
|
44
|
+
"component": getattr(request, "endpoint", None) or getattr(request, "path", None),
|
|
45
|
+
"action": getattr(request, "method", None),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _environment(request: Any) -> Dict[str, Any]:
|
|
50
|
+
return {
|
|
51
|
+
"method": getattr(request, "method", None),
|
|
52
|
+
"path": getattr(request, "path", None),
|
|
53
|
+
"query_string": getattr(request, "query_string", b"").decode("utf-8", errors="replace")
|
|
54
|
+
if isinstance(getattr(request, "query_string", None), (bytes, bytearray))
|
|
55
|
+
else getattr(request, "query_string", None),
|
|
56
|
+
"user_agent": str(getattr(request, "user_agent", "") or ""),
|
|
57
|
+
"remote_addr": getattr(request, "remote_addr", None),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _params(request: Any) -> Dict[str, Any]:
|
|
62
|
+
out: Dict[str, Any] = {}
|
|
63
|
+
try:
|
|
64
|
+
for key, value in (getattr(request, "args", {}) or {}).items():
|
|
65
|
+
out[key] = value
|
|
66
|
+
except Exception: # noqa: BLE001
|
|
67
|
+
pass
|
|
68
|
+
try:
|
|
69
|
+
form = getattr(request, "form", None)
|
|
70
|
+
if form:
|
|
71
|
+
for key, value in form.items():
|
|
72
|
+
out[key] = value
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
pass
|
|
75
|
+
try:
|
|
76
|
+
if request.is_json:
|
|
77
|
+
json_body = request.get_json(silent=True)
|
|
78
|
+
if isinstance(json_body, dict):
|
|
79
|
+
for key, value in json_body.items():
|
|
80
|
+
out[key] = value
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
pass
|
|
83
|
+
return out
|
errorgap/handlers.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Optional, Type
|
|
6
|
+
|
|
7
|
+
from .client import Client
|
|
8
|
+
|
|
9
|
+
_installed = False
|
|
10
|
+
_previous_excepthook = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def install_excepthook(client: Client) -> None:
|
|
14
|
+
global _installed, _previous_excepthook
|
|
15
|
+
if _installed:
|
|
16
|
+
return
|
|
17
|
+
_previous_excepthook = sys.excepthook
|
|
18
|
+
_installed = True
|
|
19
|
+
|
|
20
|
+
def _hook(
|
|
21
|
+
exc_type: Type[BaseException],
|
|
22
|
+
exc: BaseException,
|
|
23
|
+
tb: Optional[TracebackType],
|
|
24
|
+
) -> None:
|
|
25
|
+
try:
|
|
26
|
+
exc.__traceback__ = tb
|
|
27
|
+
client.notify(exc, sync=True, context={"source": "sys.excepthook"})
|
|
28
|
+
finally:
|
|
29
|
+
if _previous_excepthook is not None:
|
|
30
|
+
_previous_excepthook(exc_type, exc, tb)
|
|
31
|
+
|
|
32
|
+
sys.excepthook = _hook
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def uninstall_excepthook() -> None:
|
|
36
|
+
global _installed, _previous_excepthook
|
|
37
|
+
if not _installed:
|
|
38
|
+
return
|
|
39
|
+
sys.excepthook = _previous_excepthook or sys.__excepthook__
|
|
40
|
+
_previous_excepthook = None
|
|
41
|
+
_installed = False
|
errorgap/notice.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .backtrace import parse_backtrace
|
|
7
|
+
from .configuration import Configuration
|
|
8
|
+
from .filter import filter_params
|
|
9
|
+
from .version import VERSION
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_notice(
|
|
13
|
+
exc: BaseException,
|
|
14
|
+
configuration: Configuration,
|
|
15
|
+
context: Optional[Dict[str, Any]] = None,
|
|
16
|
+
environment: Optional[Dict[str, Any]] = None,
|
|
17
|
+
session: Optional[Dict[str, Any]] = None,
|
|
18
|
+
params: Optional[Dict[str, Any]] = None,
|
|
19
|
+
) -> Dict[str, Any]:
|
|
20
|
+
default_context: Dict[str, Any] = {
|
|
21
|
+
"notifier": "errorgap-python",
|
|
22
|
+
"notifier_version": VERSION,
|
|
23
|
+
"environment": configuration.environment,
|
|
24
|
+
"root_directory": configuration.root_directory,
|
|
25
|
+
}
|
|
26
|
+
if context:
|
|
27
|
+
default_context.update(context)
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
"project_id": configuration.project_id,
|
|
31
|
+
"received_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
32
|
+
"errors": [
|
|
33
|
+
{
|
|
34
|
+
"type": _error_type(exc),
|
|
35
|
+
"message": str(exc),
|
|
36
|
+
"backtrace": parse_backtrace(exc, configuration.root_directory),
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"context": default_context,
|
|
40
|
+
"environment": environment or {},
|
|
41
|
+
"session": session or {},
|
|
42
|
+
"params": filter_params(params or {}, configuration.filter_keys),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _error_type(exc: BaseException) -> str:
|
|
47
|
+
return exc.__class__.__name__
|
errorgap/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.1.0"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: errorgap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python notifier for Errorgap error tracking.
|
|
5
|
+
Project-URL: Homepage, https://github.com/errorgaphq/errorgap-python
|
|
6
|
+
Project-URL: Source, https://github.com/errorgaphq/errorgap-python
|
|
7
|
+
Author: Errorgap
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: error-tracking,errorgap,exceptions,monitoring
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Bug Tracking
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: django>=4.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
24
|
+
Requires-Dist: flask>=2.3; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: starlette>=0.27; extra == 'dev'
|
|
28
|
+
Provides-Extra: django
|
|
29
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
30
|
+
Provides-Extra: fastapi
|
|
31
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
32
|
+
Requires-Dist: starlette>=0.27; extra == 'fastapi'
|
|
33
|
+
Provides-Extra: flask
|
|
34
|
+
Requires-Dist: flask>=2.3; extra == 'flask'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# errorgap
|
|
38
|
+
|
|
39
|
+
Python notifier for [Errorgap](https://errorgap.com). Captures exceptions,
|
|
40
|
+
normalizes tracebacks, and ships notices to an Errorgap server. Single
|
|
41
|
+
package covers plain Python, Django, Flask, and FastAPI.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
pip install errorgap
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For framework integrations:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
pip install "errorgap[django]" # or [flask], [fastapi]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires Python 3.9+.
|
|
56
|
+
|
|
57
|
+
## Configure
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import errorgap
|
|
61
|
+
|
|
62
|
+
errorgap.init(
|
|
63
|
+
endpoint=...,
|
|
64
|
+
project_slug=...,
|
|
65
|
+
api_key=...,
|
|
66
|
+
environment="production",
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`init` reads the same values from `ERRORGAP_ENDPOINT`,
|
|
71
|
+
`ERRORGAP_PROJECT_SLUG`, `ERRORGAP_PROJECT_ID`, and `ERRORGAP_API_KEY` if you
|
|
72
|
+
don't pass them. By default it installs a `sys.excepthook` to catch
|
|
73
|
+
uncaught exceptions in scripts; pass `capture_globals=False` to skip.
|
|
74
|
+
|
|
75
|
+
## Manual notification
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
try:
|
|
79
|
+
risky()
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
errorgap.notify(exc, context={"component": "billing"})
|
|
82
|
+
raise
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`notify` returns a `DeliveryResult` (`status`, `body`, `error`, `queued`).
|
|
86
|
+
The SDK never raises.
|
|
87
|
+
|
|
88
|
+
## Django
|
|
89
|
+
|
|
90
|
+
Add to `MIDDLEWARE`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
MIDDLEWARE = [
|
|
94
|
+
"errorgap.django.ErrorgapMiddleware",
|
|
95
|
+
# ... your existing middleware ...
|
|
96
|
+
]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Flask
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from flask import Flask
|
|
103
|
+
from errorgap.flask import init_app
|
|
104
|
+
|
|
105
|
+
app = Flask(__name__)
|
|
106
|
+
init_app(app)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## FastAPI
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from fastapi import FastAPI
|
|
113
|
+
from errorgap.fastapi import ErrorgapMiddleware
|
|
114
|
+
|
|
115
|
+
app = FastAPI()
|
|
116
|
+
app.add_middleware(ErrorgapMiddleware)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Configuration reference
|
|
120
|
+
|
|
121
|
+
| Argument | Default | Notes |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `endpoint` | `ERRORGAP_ENDPOINT` or `http://127.0.0.1:3030` | Base URL, no trailing slash |
|
|
124
|
+
| `project_slug` | `ERRORGAP_PROJECT_SLUG` | **Required** |
|
|
125
|
+
| `project_id` | `ERRORGAP_PROJECT_ID` | Optional, embedded in payload |
|
|
126
|
+
| `api_key` | `ERRORGAP_API_KEY` | Sent as `x-errorgap-project-key` |
|
|
127
|
+
| `environment` | `ERRORGAP_ENVIRONMENT`, `ENV`, or `development` | |
|
|
128
|
+
| `root_directory` | `os.getcwd()` | Used to mark frames as `in_app` |
|
|
129
|
+
| `async_` | `True` | Background-thread delivery |
|
|
130
|
+
| `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
|
|
131
|
+
| `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
|
|
132
|
+
| `capture_globals` | `True` | Install `sys.excepthook` |
|
|
133
|
+
|
|
134
|
+
## Graceful shutdown
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
errorgap.flush(timeout=5) # wait for queued deliveries
|
|
138
|
+
errorgap.shutdown(timeout=5) # flush + tear down background thread
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Verify
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
curl -sS -X POST "$ERRORGAP_ENDPOINT/api/projects/$ERRORGAP_PROJECT_SLUG/notices" \
|
|
145
|
+
-H "content-type: application/json" \
|
|
146
|
+
-H "x-errorgap-project-key: $ERRORGAP_API_KEY" \
|
|
147
|
+
-d '{"errors":[{"type":"ErrorgapInstallTest","message":"Errorgap install verification"}],"context":{"environment":"development"}}'
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Then trigger a real error and confirm it appears in the Errorgap UI.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
python -m venv .venv
|
|
156
|
+
source .venv/bin/activate
|
|
157
|
+
pip install -e ".[dev]"
|
|
158
|
+
pytest
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
errorgap/__init__.py,sha256=sPT-p-7L5tH9Js5JD4s3hDQPQWWbPIGt0jM8tI2VSIk,2801
|
|
2
|
+
errorgap/backtrace.py,sha256=6728DCVjvxOBYcp8v_zIq2D7OUnNnxOaJyzsx1X4N84,1224
|
|
3
|
+
errorgap/client.py,sha256=gn9KJUpGv_66DxZrEv-ABxGKh6yL0P7SUE5uSPq5_Gw,5537
|
|
4
|
+
errorgap/configuration.py,sha256=kEvybDZMdQ-Eq5EJ9QcMnsy9hMkT7R1qP-OsGIZpshc,1475
|
|
5
|
+
errorgap/django.py,sha256=xs6HLVsxzcsXQNiTpxy_NjaApGXCSQfgtMbXzYWSLzU,2514
|
|
6
|
+
errorgap/fastapi.py,sha256=W_sHvMLb9vNRK74sk8XyA85UWsOO9FciNLfPJAXVrJI,2017
|
|
7
|
+
errorgap/filter.py,sha256=kAvjCocwAJHI0u0DVSbQtBInbBGqebzz4fd2jbrHhb8,750
|
|
8
|
+
errorgap/flask.py,sha256=CoYEXdGZy8aGkhN-AIOtJPVKuco3624uX0m_t6BVDHQ,2650
|
|
9
|
+
errorgap/handlers.py,sha256=WOkKxxy2LguF1BU-VfVAecGSdLERgjRWCFj4Kz1p8M4,1033
|
|
10
|
+
errorgap/notice.py,sha256=uRj1aQKu9GWvOlkAw-6zuQGQV3i3xKIqbxR2LRT2VbY,1468
|
|
11
|
+
errorgap/version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
|
|
12
|
+
errorgap-0.1.0.dist-info/METADATA,sha256=nQJx_mVjp8vfE36JnjvuvC7HuEtZ3sX7KvmzIHF4cDc,4364
|
|
13
|
+
errorgap-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
errorgap-0.1.0.dist-info/licenses/LICENSE,sha256=_skACZib1dUUS61LlXEDHa5ZgW6SBrScsIIJjbgTC0c,1065
|
|
15
|
+
errorgap-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Errorgap
|
|
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.
|