hawkapi-sentry 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.
- hawkapi_sentry/__init__.py +14 -0
- hawkapi_sentry/_context.py +61 -0
- hawkapi_sentry/_middleware.py +61 -0
- hawkapi_sentry/_plugin.py +127 -0
- hawkapi_sentry-0.1.0.dist-info/METADATA +123 -0
- hawkapi_sentry-0.1.0.dist-info/RECORD +8 -0
- hawkapi_sentry-0.1.0.dist-info/WHEEL +4 -0
- hawkapi_sentry-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""hawkapi-sentry — Sentry integration for HawkAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hawkapi_sentry._middleware import SentryMiddleware
|
|
6
|
+
from hawkapi_sentry._plugin import SentryPlugin
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SentryMiddleware",
|
|
12
|
+
"SentryPlugin",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Request context helpers for Sentry event enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_REDACT_HEADER_NAMES = frozenset(
|
|
8
|
+
[
|
|
9
|
+
"authorization",
|
|
10
|
+
"cookie",
|
|
11
|
+
"x-api-key",
|
|
12
|
+
"x-auth-token",
|
|
13
|
+
"proxy-authorization",
|
|
14
|
+
]
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_FILTERED = "[Filtered]"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def redact_headers(headers: Any) -> dict[str, str]:
|
|
21
|
+
"""Return a dict of headers with sensitive values masked.
|
|
22
|
+
|
|
23
|
+
Accepts any iterable of (key, value) pairs or an object with an .items()
|
|
24
|
+
method — covers both HawkAPI Headers (__iter__ yields tuples) and plain dicts.
|
|
25
|
+
"""
|
|
26
|
+
result: dict[str, str] = {}
|
|
27
|
+
items: Any = headers.items() if hasattr(headers, "items") else headers
|
|
28
|
+
for key, value in items:
|
|
29
|
+
if key.lower() in _REDACT_HEADER_NAMES:
|
|
30
|
+
result[key] = _FILTERED
|
|
31
|
+
else:
|
|
32
|
+
result[key] = value
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def status_class(status_code: int) -> str:
|
|
37
|
+
"""Map an HTTP status code to an OTel/Sentry transaction status string."""
|
|
38
|
+
if status_code < 400:
|
|
39
|
+
return "ok"
|
|
40
|
+
if status_code < 500:
|
|
41
|
+
return "invalid_argument"
|
|
42
|
+
return "internal_error"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def request_context(request: Any) -> dict[str, Any]:
|
|
46
|
+
"""Build a Sentry request context dict from a HawkAPI Request."""
|
|
47
|
+
url: str = request.url
|
|
48
|
+
qs_raw: Any = request.query_string
|
|
49
|
+
qs: str = qs_raw.decode("latin-1") if hasattr(qs_raw, "decode") else str(qs_raw)
|
|
50
|
+
return {
|
|
51
|
+
"method": request.method,
|
|
52
|
+
"url": url,
|
|
53
|
+
"headers": redact_headers(request.headers),
|
|
54
|
+
"query_string": qs,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Keep underscore aliases so tests that import _redact_headers etc. still work
|
|
59
|
+
_redact_headers = redact_headers
|
|
60
|
+
_status_class = status_class
|
|
61
|
+
_request_context = request_context
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""SentryMiddleware — per-request Sentry transaction and breadcrumb."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import sentry_sdk
|
|
8
|
+
from hawkapi.middleware.base import Middleware
|
|
9
|
+
from hawkapi.requests.request import Request
|
|
10
|
+
from hawkapi.responses.json_response import JSONResponse
|
|
11
|
+
from hawkapi.responses.response import Response
|
|
12
|
+
|
|
13
|
+
from hawkapi_sentry._context import status_class
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SentryMiddleware(Middleware):
|
|
17
|
+
"""Starts a Sentry performance transaction for every HTTP request."""
|
|
18
|
+
|
|
19
|
+
async def before_request(self, request: Request) -> Request | Response | JSONResponse | None:
|
|
20
|
+
"""Start a Sentry transaction and add a breadcrumb."""
|
|
21
|
+
method: str = request.method
|
|
22
|
+
path: str = request.path
|
|
23
|
+
|
|
24
|
+
# Extract traceparent for distributed tracing
|
|
25
|
+
traceparent = request.headers.get("sentry-trace") or request.headers.get("traceparent")
|
|
26
|
+
|
|
27
|
+
transaction = sentry_sdk.start_transaction(
|
|
28
|
+
op="http.server",
|
|
29
|
+
name=f"{method} {path}",
|
|
30
|
+
source="url",
|
|
31
|
+
)
|
|
32
|
+
if traceparent:
|
|
33
|
+
transaction.continuing_trace({"sentry-trace": traceparent}) # type: ignore[attr-defined]
|
|
34
|
+
|
|
35
|
+
request.state._sentry_tx = transaction # type: ignore[attr-defined]
|
|
36
|
+
transaction.__enter__() # type: ignore[attr-defined]
|
|
37
|
+
|
|
38
|
+
sentry_sdk.add_breadcrumb(
|
|
39
|
+
category="request",
|
|
40
|
+
message=f"{method} {path}",
|
|
41
|
+
level="info",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
async def after_response(
|
|
47
|
+
self, request: Request, response: Response | JSONResponse
|
|
48
|
+
) -> Response | JSONResponse | None:
|
|
49
|
+
"""Finish the Sentry transaction with HTTP metadata."""
|
|
50
|
+
tx: Any = getattr(getattr(request, "state", None), "_sentry_tx", None)
|
|
51
|
+
if tx is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
status_code: int = response.status_code
|
|
55
|
+
tx.set_tag("http.method", request.method)
|
|
56
|
+
tx.set_tag("http.status_code", str(status_code))
|
|
57
|
+
tx.set_tag("http.target", request.path)
|
|
58
|
+
tx.set_status(status_class(status_code))
|
|
59
|
+
tx.__exit__(None, None, None) # type: ignore[attr-defined]
|
|
60
|
+
|
|
61
|
+
return None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""SentryPlugin — HawkAPI Plugin that initialises and drives the Sentry SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import sentry_sdk
|
|
10
|
+
from hawkapi.plugins import Plugin
|
|
11
|
+
|
|
12
|
+
from hawkapi_sentry._context import redact_headers, request_context
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("hawkapi_sentry")
|
|
15
|
+
|
|
16
|
+
_DEFAULT_IGNORE: tuple[int, ...] = (404, 401, 403)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SentryPlugin(Plugin):
|
|
20
|
+
"""HawkAPI plugin that wires Sentry error and performance monitoring."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
dsn: str | None = None,
|
|
26
|
+
environment: str = "production",
|
|
27
|
+
release: str | None = None,
|
|
28
|
+
traces_sample_rate: float = 0.0,
|
|
29
|
+
profiles_sample_rate: float = 0.0,
|
|
30
|
+
include_user_data: bool = False,
|
|
31
|
+
user_getter: Callable[[Any], dict[str, Any]] | None = None,
|
|
32
|
+
before_send: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None]
|
|
33
|
+
| None = None,
|
|
34
|
+
tags: dict[str, str] | None = None,
|
|
35
|
+
ignore_status_codes: tuple[int, ...] = _DEFAULT_IGNORE,
|
|
36
|
+
transport: Any = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._dsn = dsn
|
|
39
|
+
self._environment = environment
|
|
40
|
+
self._release = release
|
|
41
|
+
self._traces_sample_rate = traces_sample_rate
|
|
42
|
+
self._profiles_sample_rate = profiles_sample_rate
|
|
43
|
+
self._include_user_data = include_user_data
|
|
44
|
+
self._user_getter = user_getter
|
|
45
|
+
self._before_send = before_send
|
|
46
|
+
self._tags: dict[str, str] = tags or {}
|
|
47
|
+
self._ignore_status_codes = ignore_status_codes
|
|
48
|
+
self._transport = transport
|
|
49
|
+
self._initialised = False
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Plugin hooks
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def on_startup(self) -> None:
|
|
56
|
+
"""Initialise the Sentry SDK. No-op when dsn is empty or None."""
|
|
57
|
+
if not self._dsn:
|
|
58
|
+
logger.info("hawkapi_sentry: no DSN provided — Sentry disabled (no-op mode)")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
init_kwargs: dict[str, Any] = {
|
|
62
|
+
"dsn": self._dsn,
|
|
63
|
+
"environment": self._environment,
|
|
64
|
+
"traces_sample_rate": self._traces_sample_rate,
|
|
65
|
+
"profiles_sample_rate": self._profiles_sample_rate,
|
|
66
|
+
}
|
|
67
|
+
if self._release is not None:
|
|
68
|
+
init_kwargs["release"] = self._release
|
|
69
|
+
if self._before_send is not None:
|
|
70
|
+
init_kwargs["before_send"] = self._before_send
|
|
71
|
+
if self._transport is not None:
|
|
72
|
+
init_kwargs["transport"] = self._transport
|
|
73
|
+
|
|
74
|
+
sentry_sdk.init(**init_kwargs)
|
|
75
|
+
self._initialised = True
|
|
76
|
+
|
|
77
|
+
logger.info(
|
|
78
|
+
"hawkapi_sentry: Sentry initialised (environment=%s, traces_sample_rate=%s)",
|
|
79
|
+
self._environment,
|
|
80
|
+
self._traces_sample_rate,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def on_shutdown(self) -> None:
|
|
84
|
+
"""Flush pending Sentry events before the process exits."""
|
|
85
|
+
sentry_sdk.flush(timeout=2.0)
|
|
86
|
+
|
|
87
|
+
def on_exception(self, request: Any, exc: Exception) -> None:
|
|
88
|
+
"""Capture an unhandled exception in Sentry, enriched with request context."""
|
|
89
|
+
status_code = getattr(exc, "status_code", 500)
|
|
90
|
+
if status_code in self._ignore_status_codes:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
with sentry_sdk.new_scope() as scope:
|
|
94
|
+
scope.set_context("request", request_context(request))
|
|
95
|
+
|
|
96
|
+
user_data = self._resolve_user(request)
|
|
97
|
+
if user_data:
|
|
98
|
+
scope.set_user(user_data)
|
|
99
|
+
|
|
100
|
+
for tag_key, tag_val in self._tags.items():
|
|
101
|
+
scope.set_tag(tag_key, tag_val)
|
|
102
|
+
|
|
103
|
+
sentry_sdk.capture_exception(exc)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Internal helpers
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _resolve_user(self, request: Any) -> dict[str, Any] | None:
|
|
110
|
+
"""Return user dict from user_getter or request.state.user, or None."""
|
|
111
|
+
if self._user_getter is not None:
|
|
112
|
+
try:
|
|
113
|
+
return self._user_getter(request)
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.debug("hawkapi_sentry: user_getter raised — skipping user context")
|
|
116
|
+
return None
|
|
117
|
+
if self._include_user_data:
|
|
118
|
+
state = getattr(request, "state", None)
|
|
119
|
+
if state is not None:
|
|
120
|
+
user = getattr(state, "user", None)
|
|
121
|
+
if isinstance(user, dict):
|
|
122
|
+
return user # type: ignore[return-value]
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def _redact_headers_helper(self, headers: Any) -> dict[str, str]:
|
|
126
|
+
"""Delegate to module-level helper."""
|
|
127
|
+
return redact_headers(headers)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hawkapi-sentry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sentry integration for HawkAPI — plugin + middleware
|
|
5
|
+
Project-URL: Homepage, https://github.com/ashimov/hawkapi-sentry
|
|
6
|
+
Project-URL: Repository, https://github.com/ashimov/hawkapi-sentry
|
|
7
|
+
Project-URL: Issues, https://github.com/ashimov/hawkapi-sentry/issues
|
|
8
|
+
Author-email: HawkAPI Contributors <hawkapi@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: error-tracking,hawkapi,monitoring,observability,sentry
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: hawkapi>=0.1.3
|
|
23
|
+
Requires-Dist: sentry-sdk>=2.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
26
|
+
Requires-Dist: pyright>=1.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# hawkapi-sentry
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/hawkapi-sentry/)
|
|
35
|
+
[](https://pypi.org/project/hawkapi-sentry/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://github.com/ashimov/hawkapi-sentry/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/hawkapi-sentry/)
|
|
39
|
+
|
|
40
|
+
Sentry integration for [HawkAPI](https://github.com/ashimov/HawkAPI) — a plugin that initialises the Sentry SDK on startup and captures unhandled exceptions, plus a middleware that creates a performance transaction per request.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install hawkapi-sentry
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from hawkapi import HawkAPI
|
|
52
|
+
from hawkapi_sentry import SentryPlugin, SentryMiddleware
|
|
53
|
+
|
|
54
|
+
app = HawkAPI()
|
|
55
|
+
|
|
56
|
+
app.add_plugin(
|
|
57
|
+
SentryPlugin(
|
|
58
|
+
dsn="https://key@sentry.io/123",
|
|
59
|
+
environment="production",
|
|
60
|
+
traces_sample_rate=0.1,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
app.add_middleware(SentryMiddleware)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That's it. Every unhandled exception is captured with full request context; every request gets a Sentry performance transaction.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## `SentryPlugin` parameter reference
|
|
71
|
+
|
|
72
|
+
| Parameter | Type | Default | Description |
|
|
73
|
+
|---|---|---|---|
|
|
74
|
+
| `dsn` | `str \| None` | `None` | Sentry DSN. Empty/`None` = no-op mode (safe for local dev). |
|
|
75
|
+
| `environment` | `str` | `"production"` | Sentry environment tag. |
|
|
76
|
+
| `release` | `str \| None` | `None` | Release string. `None` = not set. |
|
|
77
|
+
| `traces_sample_rate` | `float` | `0.0` | Fraction of transactions to sample for performance monitoring. |
|
|
78
|
+
| `profiles_sample_rate` | `float` | `0.0` | Fraction of sampled transactions to profile. |
|
|
79
|
+
| `include_user_data` | `bool` | `False` | When `True`, attach `request.state.user` dict to Sentry events. |
|
|
80
|
+
| `user_getter` | `Callable[[Request], dict] \| None` | `None` | Custom callable to extract user data from the request. Takes priority over `include_user_data`. |
|
|
81
|
+
| `before_send` | `Callable \| None` | `None` | Passed directly to `sentry_sdk.init`. Return `None` to drop an event. |
|
|
82
|
+
| `tags` | `dict[str, str] \| None` | `None` | Global tags applied to every captured event. |
|
|
83
|
+
| `ignore_status_codes` | `tuple[int, ...]` | `(404, 401, 403)` | HTTP status codes for which exceptions are **not** sent to Sentry. |
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Migration from `sentry-sdk[fastapi]`
|
|
88
|
+
|
|
89
|
+
| FastAPI / starlette-sentry | hawkapi-sentry |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `sentry_sdk.init(integrations=[StarletteIntegration(), FastApiIntegration()])` | `app.add_plugin(SentryPlugin(dsn=...))` |
|
|
92
|
+
| `SentryAsgiMiddleware(app)` | `app.add_middleware(SentryMiddleware)` |
|
|
93
|
+
| `before_send` kwarg to `sentry_sdk.init` | `SentryPlugin(before_send=...)` |
|
|
94
|
+
| Manual `with sentry_sdk.push_scope() as scope: scope.set_user(...)` | `SentryPlugin(user_getter=lambda req: {...})` |
|
|
95
|
+
|
|
96
|
+
The main difference: HawkAPI plugins own the SDK lifecycle, so you never call `sentry_sdk.init()` yourself — `SentryPlugin.on_startup()` does it.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Clone and install in editable mode with dev extras
|
|
104
|
+
git clone https://github.com/ashimov/hawkapi-sentry.git
|
|
105
|
+
cd hawkapi-sentry
|
|
106
|
+
uv sync --extra dev
|
|
107
|
+
|
|
108
|
+
# Run tests
|
|
109
|
+
uv run pytest tests/ -q
|
|
110
|
+
|
|
111
|
+
# Lint
|
|
112
|
+
uv run ruff check .
|
|
113
|
+
uv run ruff format .
|
|
114
|
+
|
|
115
|
+
# Type-check
|
|
116
|
+
uv run pyright src/
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
[MIT](LICENSE) — Copyright (c) 2026 HawkAPI Contributors.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hawkapi_sentry/__init__.py,sha256=60d3Nn5rfxisa4bUfuSfaWaw8jTGWAltMiKi37IoYHM,299
|
|
2
|
+
hawkapi_sentry/_context.py,sha256=fqp4Vqc0HBrIbaSeNpIpASaYZFAY_r5s8-dpiMs-XmM,1749
|
|
3
|
+
hawkapi_sentry/_middleware.py,sha256=p-EESG5_ZfE5e8ihMyGNHmdAdorxkkOJGLo23s-AO64,2152
|
|
4
|
+
hawkapi_sentry/_plugin.py,sha256=UUKcAb45_Wv0Gbueiu1XY2zfTM_qzQaPILYz9bTl_BA,4703
|
|
5
|
+
hawkapi_sentry-0.1.0.dist-info/METADATA,sha256=H_ystdiw6gQst9H08ZZkNCnoYevGLSgm9hdE-z-sBEc,4742
|
|
6
|
+
hawkapi_sentry-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
hawkapi_sentry-0.1.0.dist-info/licenses/LICENSE,sha256=_RpjhvsfLqqeG_gv2cRatjIxCTGXTpXhKU9jqLZXYa4,1077
|
|
8
|
+
hawkapi_sentry-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HawkAPI Contributors
|
|
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.
|