tailcove 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.
tailcove/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .handler import TailcoveHandler
2
+ from .transport import DeliveryError
3
+
4
+ __all__ = ["TailcoveHandler", "DeliveryError"]
5
+ __version__ = "0.1.0"
tailcove/context.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Dict, FrozenSet
5
+
6
+ # The stdlib `logging` module doesn't nest `extra={...}` under a key — each
7
+ # entry lands as a top-level attribute directly on the LogRecord. To recover
8
+ # "the extra dict" we diff a record's __dict__ against the attributes a
9
+ # vanilla LogRecord always carries; whatever's left over is caller-supplied
10
+ # context. Computed once from a throwaway record rather than hardcoded, so
11
+ # it stays correct if a future Python version adds/removes a built-in field.
12
+ _DUMMY_RECORD = logging.LogRecord(
13
+ name="tailcove",
14
+ level=logging.INFO,
15
+ pathname=__file__,
16
+ lineno=0,
17
+ msg="",
18
+ args=(),
19
+ exc_info=None,
20
+ )
21
+ RESERVED_ATTRS: FrozenSet[str] = frozenset(vars(_DUMMY_RECORD).keys()) | {"message", "asctime"}
22
+
23
+
24
+ def extra_context(record: logging.LogRecord) -> Dict[str, Any]:
25
+ return {key: value for key, value in vars(record).items() if key not in RESERVED_ATTRS}
tailcove/exceptions.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+ from types import TracebackType
5
+ from typing import Any, Dict, List, Optional, Tuple, Type
6
+
7
+ ExcInfo = Tuple[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]]
8
+
9
+
10
+ def format_exception(exc_info: ExcInfo, trace_limit: int = 10) -> Dict[str, Any]:
11
+ """
12
+ Renders a caught exception as structured data instead of a flat string,
13
+ matching the tailcove/laravel package's `{class, message, location,
14
+ trace}` shape so fingerprinting (Milestone 11) works identically
15
+ regardless of which client sent the line. `location` is the file:line
16
+ of the frame where the exception was actually raised (the last frame in
17
+ the traceback); `trace` is trimmed to `trace_limit` frames the same way
18
+ the Laravel package trims its stringified trace.
19
+ """
20
+ exc_type, exc_value, exc_tb = exc_info
21
+
22
+ frames = traceback.extract_tb(exc_tb)
23
+ location = f"{frames[-1].filename}:{frames[-1].lineno}" if frames else ""
24
+
25
+ trace: List[str] = [
26
+ f"{frame.filename}:{frame.lineno} in {frame.name}" for frame in frames
27
+ ][:trace_limit]
28
+
29
+ class_name = "UnknownException"
30
+ if exc_type is not None:
31
+ class_name = exc_type.__qualname__
32
+ if exc_type.__module__ not in ("builtins", "__main__"):
33
+ class_name = f"{exc_type.__module__}.{class_name}"
34
+
35
+ return {
36
+ "class": class_name,
37
+ "message": str(exc_value) if exc_value is not None else "",
38
+ "location": location,
39
+ "trace": trace,
40
+ }
tailcove/handler.py ADDED
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import queue
5
+ import socket
6
+ import sys
7
+ import threading
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from .context import extra_context
13
+ from .exceptions import format_exception
14
+ from .levels import normalize_level
15
+ from .transport import send_batch
16
+
17
+ DEFAULT_ENDPOINT = "https://ingest.tailcove.dev/api/v1/logs"
18
+ DEFAULT_BATCH_SIZE = 50
19
+ DEFAULT_BATCH_SECONDS = 2.0
20
+ DEFAULT_TIMEOUT = 2.0
21
+ DEFAULT_TRACE_LIMIT = 10
22
+
23
+ # Queue item kinds. A plain string tag plus payload, rather than subclasses,
24
+ # keeps the background loop's dispatch a single if/elif chain.
25
+ _LOG = "log"
26
+ _FLUSH = "flush"
27
+ _CLOSE = "close"
28
+
29
+
30
+ class TailcoveHandler(logging.Handler):
31
+ """
32
+ Batches records and ships them to a Tailcove source's ingest endpoint on
33
+ a background thread — up to `batch_size` lines or `batch_seconds`,
34
+ whichever comes first, matching the wire contract and thresholds of the
35
+ tailcove/laravel and @tailcove/node clients (see the log-ingestion
36
+ skill). Never raises into the host app: `emit()` catches its own
37
+ failures and reports them via `self.handleError()`, the stdlib
38
+ `Handler` convention; delivery failures on the background thread are
39
+ caught and printed to stderr rather than propagated anywhere.
40
+
41
+ Flushing at interpreter exit is handled by the stdlib itself: every
42
+ `logging.Handler` is registered in `logging._handlerList`, and the
43
+ `logging` module registers its own `atexit` hook (`logging.shutdown`)
44
+ that calls `flush()` then `close()` on every handler. Subclassing
45
+ `Handler` and implementing `close()` to drain the queue is therefore
46
+ sufficient — no separate `atexit.register()` call is needed here, and
47
+ adding one would risk a double flush racing the stdlib's own.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ token: str,
53
+ endpoint: str = DEFAULT_ENDPOINT,
54
+ batch_size: int = DEFAULT_BATCH_SIZE,
55
+ batch_seconds: float = DEFAULT_BATCH_SECONDS,
56
+ timeout: float = DEFAULT_TIMEOUT,
57
+ trace_limit: int = DEFAULT_TRACE_LIMIT,
58
+ level: int = logging.NOTSET,
59
+ ) -> None:
60
+ super().__init__(level=level)
61
+ self.token = token
62
+ self.endpoint = endpoint
63
+ self.batch_size = batch_size
64
+ self.batch_seconds = batch_seconds
65
+ self.timeout = timeout
66
+ self.trace_limit = trace_limit
67
+
68
+ self._hostname = socket.gethostname()
69
+ self._queue: "queue.Queue[Any]" = queue.Queue()
70
+ self._closed = False
71
+ self._thread = threading.Thread(
72
+ target=self._run, name="tailcove-sender", daemon=True
73
+ )
74
+ self._thread.start()
75
+
76
+ def emit(self, record: logging.LogRecord) -> None:
77
+ try:
78
+ self._queue.put_nowait((_LOG, self._normalize(record)))
79
+ except Exception:
80
+ self.handleError(record)
81
+
82
+ def flush(self) -> None:
83
+ """Sends whatever is buffered right now, regardless of size or age. Safe to call directly; blocks until the background thread confirms the flush."""
84
+ self._signal(_FLUSH)
85
+
86
+ def close(self) -> None:
87
+ if self._closed:
88
+ super().close()
89
+ return
90
+
91
+ self._closed = True
92
+ self._signal(_CLOSE)
93
+ self._thread.join(timeout=self.timeout + 1)
94
+ super().close()
95
+
96
+ def _signal(self, kind: str) -> None:
97
+ if not self._thread.is_alive():
98
+ return
99
+
100
+ done = threading.Event()
101
+ self._queue.put((kind, done))
102
+ done.wait(timeout=self.timeout + 1)
103
+
104
+ def _normalize(self, record: logging.LogRecord) -> Dict[str, Any]:
105
+ context = extra_context(record)
106
+
107
+ if record.exc_info:
108
+ context["exception"] = format_exception(record.exc_info, self.trace_limit)
109
+
110
+ return {
111
+ "message": record.getMessage(),
112
+ "level": normalize_level(record.levelname),
113
+ "context": context,
114
+ "host": self._hostname,
115
+ "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
116
+ timespec="milliseconds"
117
+ ),
118
+ }
119
+
120
+ def _run(self) -> None:
121
+ batch: List[Dict[str, Any]] = []
122
+ deadline: Optional[float] = None
123
+
124
+ while True:
125
+ wait = None if deadline is None else max(0.0, deadline - time.monotonic())
126
+
127
+ try:
128
+ kind, payload = self._queue.get(timeout=wait)
129
+ except queue.Empty:
130
+ kind, payload = None, None
131
+
132
+ if kind == _LOG:
133
+ batch.append(payload)
134
+ if deadline is None:
135
+ deadline = time.monotonic() + self.batch_seconds
136
+ elif kind == _FLUSH:
137
+ self._send(batch)
138
+ batch, deadline = [], None
139
+ payload.set()
140
+ continue
141
+ elif kind == _CLOSE:
142
+ self._send(batch)
143
+ payload.set()
144
+ return
145
+
146
+ if batch and (
147
+ len(batch) >= self.batch_size
148
+ or (deadline is not None and time.monotonic() >= deadline)
149
+ ):
150
+ self._send(batch)
151
+ batch, deadline = [], None
152
+
153
+ def _send(self, batch: List[Dict[str, Any]]) -> None:
154
+ if not batch:
155
+ return
156
+
157
+ try:
158
+ send_batch(self.endpoint, self.token, batch, self.timeout)
159
+ except Exception as exc:
160
+ print(f"[tailcove] failed to send log batch: {exc}", file=sys.stderr)
tailcove/levels.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ #: Syslog-style levels, matching the set the ingest endpoint normalizes to
4
+ #: (see the log-ingestion skill). Standard library level names map onto
5
+ #: these directly; anything unrecognized (including a custom level name the
6
+ #: server has never heard of) falls back to "info", same as the server.
7
+ _LEVEL_MAP = {
8
+ "DEBUG": "debug",
9
+ "INFO": "info",
10
+ "NOTICE": "notice",
11
+ "WARNING": "warning",
12
+ "WARN": "warning",
13
+ "ERROR": "error",
14
+ "CRITICAL": "critical",
15
+ "FATAL": "critical",
16
+ "ALERT": "alert",
17
+ "EMERGENCY": "emergency",
18
+ }
19
+
20
+
21
+ def normalize_level(levelname: str) -> str:
22
+ return _LEVEL_MAP.get(levelname.upper(), "info")
tailcove/py.typed ADDED
File without changes
tailcove/transport.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, Dict, List
7
+
8
+
9
+ class DeliveryError(Exception):
10
+ """Raised when a batch fails to reach the ingest endpoint (network error or non-2xx response)."""
11
+
12
+
13
+ def send_batch(
14
+ endpoint: str,
15
+ token: str,
16
+ batch: List[Dict[str, Any]],
17
+ timeout: float,
18
+ ) -> None:
19
+ """
20
+ Ships a batch as NDJSON — one JSON object per line, per the ingest
21
+ endpoint's payload sniffing (see the log-ingestion skill). Uses only
22
+ `urllib` from the standard library so the package has zero required
23
+ dependencies. Raises DeliveryError on a network failure or non-2xx
24
+ response; callers decide whether that's swallowed or surfaced.
25
+ """
26
+ body = "\n".join(json.dumps(line, default=str) for line in batch).encode("utf-8")
27
+
28
+ request = urllib.request.Request(
29
+ endpoint,
30
+ data=body,
31
+ method="POST",
32
+ headers={
33
+ "Content-Type": "application/x-ndjson",
34
+ "Authorization": f"Bearer {token}",
35
+ },
36
+ )
37
+
38
+ try:
39
+ with urllib.request.urlopen(request, timeout=timeout) as response:
40
+ status = response.status
41
+ except urllib.error.HTTPError as exc:
42
+ raise DeliveryError(
43
+ f"tailcove: ingest endpoint responded with status {exc.code}"
44
+ ) from exc
45
+ except urllib.error.URLError as exc:
46
+ raise DeliveryError(
47
+ f"tailcove: request to ingest endpoint failed ({exc.reason})"
48
+ ) from exc
49
+
50
+ if status >= 300:
51
+ raise DeliveryError(f"tailcove: ingest endpoint responded with status {status}")
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.5
2
+ Name: tailcove
3
+ Version: 0.1.0
4
+ Summary: Python client for shipping application logs to Tailcove — a stdlib logging.Handler that batches and ships NDJSON.
5
+ Project-URL: Homepage, https://tailcove.dev
6
+ Project-URL: Documentation, https://tailcove.dev/docs/sending-logs/python
7
+ Project-URL: Source, https://github.com/tailcove/python
8
+ Project-URL: Issues, https://github.com/tailcove/python/issues
9
+ Author-email: Akcybex <developers@akcybex.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: logging,monolog,ndjson,tailcove
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: System :: Logging
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ <h1 align="center">tailcove</h1>
28
+
29
+ <p align="center">
30
+ Send Python logs to <a href="https://tailcove.dev">Tailcove</a> with the standard <code>logging</code> module.<br>
31
+ One handler, no dependencies, and it never slows down your code.
32
+ </p>
33
+
34
+ <p align="center">
35
+ <a href="https://pypi.org/project/tailcove/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
36
+ <a href="https://pypi.org/project/tailcove/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
37
+ <a href="https://pypi.org/project/tailcove/"><img alt="PyPI downloads" src="https://img.shields.io/pypi/dm/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
38
+ <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-2DD4BF?labelColor=0A0A0A"></a>
39
+ </p>
40
+
41
+ <p align="center">
42
+ <a href="https://tailcove.dev/docs/sending-logs/python">Documentation</a> ·
43
+ <a href="https://github.com/tailcove/python/issues">Report a bug</a> ·
44
+ <a href="https://app.tailcove.dev/register">Get started free</a>
45
+ </p>
46
+
47
+ ---
48
+
49
+ ## Why use it
50
+
51
+ - **Plain `logging`.** `TailcoveHandler` is a normal `logging.Handler`. Keep the loggers you already have.
52
+ - **Never blocks.** Records are batched and sent from a background thread, so a request or a hot loop never waits on the network.
53
+ - **No dependencies.** Delivery uses `urllib` from the standard library.
54
+ - **Safe.** A failed delivery is caught and never raised into your code.
55
+ - **Structured exceptions.** Log an exception and it arrives as data (class, message, location, trace), so identical errors group together.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install tailcove
61
+ ```
62
+
63
+ Requires Python 3.9 or newer.
64
+
65
+ ## Quick start
66
+
67
+ **1. Get a token.** Create a source in your [Tailcove dashboard](https://app.tailcove.dev) and copy its ingest token. It is shown only once, so save it somewhere safe (an environment variable is ideal).
68
+
69
+ **2. Add the handler.**
70
+
71
+ ```python
72
+ import logging
73
+ import os
74
+
75
+ from tailcove import TailcoveHandler
76
+
77
+ logger = logging.getLogger("myapp")
78
+ logger.addHandler(TailcoveHandler(token=os.environ["TAILCOVE_TOKEN"]))
79
+ logger.setLevel(logging.INFO)
80
+
81
+ logger.info("user signed up", extra={"user_id": 42})
82
+ logger.error("payment failed", extra={"order_id": 99, "reason": "card_declined"})
83
+ ```
84
+
85
+ **3. Open Tailcove.** Your records appear in search and live tail within a couple of seconds.
86
+
87
+ Buffered records are flushed when your program exits normally, because Python's own `logging.shutdown()` closes every handler.
88
+
89
+ ## Options
90
+
91
+ | Argument | Default | What it does |
92
+ |---|---|---|
93
+ | `token` | — | Your source's ingest token. **Required.** |
94
+ | `endpoint` | `https://ingest.tailcove.dev/api/v1/logs` | The ingest URL. Only change this if you self-host. |
95
+ | `batch_size` | `50` | Send once this many records are buffered. |
96
+ | `batch_seconds` | `2.0` | Send once this many seconds have passed since the first buffered record. |
97
+ | `timeout` | `2.0` | Seconds a delivery attempt may take before giving up. |
98
+ | `trace_limit` | `10` | Most stack frames kept when a logged exception is rendered. |
99
+ | `level` | `logging.NOTSET` | Lowest level this handler processes, on top of the logger's own level. |
100
+
101
+ Records are sent once `batch_size` is reached or `batch_seconds` has passed, whichever comes first. They are never sent one HTTP request per log call. To send whatever is buffered right now, call `handler.flush()`. It blocks until the background thread confirms.
102
+
103
+ ## Structured context
104
+
105
+ Anything you pass in `extra={...}` is shipped as the line's context, with the same keys:
106
+
107
+ ```python
108
+ logger.info("order placed", extra={"order_id": 99, "total_cents": 4200})
109
+ ```
110
+
111
+ ## Exceptions
112
+
113
+ Log with `exc_info=True`, or use `logger.exception(...)` inside an `except` block, and the exception is sent as structured data and not as a flattened string:
114
+
115
+ ```python
116
+ try:
117
+ charge_card(order)
118
+ except CardDeclined:
119
+ logger.error("payment failed", exc_info=True)
120
+ ```
121
+
122
+ ```json
123
+ "exception": {
124
+ "class": "billing.errors.CardDeclined",
125
+ "message": "insufficient funds",
126
+ "location": "/app/billing/charge.py:42",
127
+ "trace": [
128
+ "/app/billing/charge.py:42 in charge_card",
129
+ "/app/orders/service.py:17 in place_order"
130
+ ]
131
+ }
132
+ ```
133
+
134
+ `location` is where the exception was raised, and `trace` is limited to `trace_limit` frames. It's the same shape the Laravel, Node and browser libraries use, so the same error groups into one issue whichever app reported it.
135
+
136
+ ## Frameworks
137
+
138
+ ### Django
139
+
140
+ Add the handler to `LOGGING` in `settings.py`:
141
+
142
+ ```python
143
+ LOGGING = {
144
+ "version": 1,
145
+ "disable_existing_loggers": False,
146
+ "handlers": {
147
+ "tailcove": {
148
+ "class": "tailcove.TailcoveHandler",
149
+ "token": env("TAILCOVE_TOKEN"),
150
+ },
151
+ },
152
+ "root": {
153
+ "handlers": ["console", "tailcove"],
154
+ "level": "INFO",
155
+ },
156
+ "loggers": {
157
+ "django.request": {
158
+ "handlers": ["tailcove"],
159
+ "level": "ERROR",
160
+ "propagate": True,
161
+ },
162
+ },
163
+ }
164
+ ```
165
+
166
+ ### Flask
167
+
168
+ Attach the handler to the app logger, or to the root logger if you want every `logging.getLogger(__name__)` in the app to ship too:
169
+
170
+ ```python
171
+ from flask import Flask
172
+ from tailcove import TailcoveHandler
173
+
174
+ app = Flask(__name__)
175
+ app.logger.addHandler(TailcoveHandler(token=app.config["TAILCOVE_TOKEN"]))
176
+ app.logger.setLevel("INFO")
177
+
178
+
179
+ @app.errorhandler(Exception)
180
+ def handle_exception(exc):
181
+ app.logger.error("unhandled exception", exc_info=exc)
182
+ raise
183
+ ```
184
+
185
+ ## When delivery fails
186
+
187
+ Network errors and non-2xx responses never raise into your code. `emit()` follows the standard `Handler` convention and routes its own failures through `handleError()`. Errors on the background thread are caught and printed to stderr. Logging is best-effort by design.
188
+
189
+ ## Help and feedback
190
+
191
+ - **Docs:** [tailcove.dev/docs/sending-logs/python](https://tailcove.dev/docs/sending-logs/python)
192
+ - **Bugs and feature requests:** [open an issue](https://github.com/tailcove/python/issues/new/choose)
193
+ - **Security problems:** please email **security@tailcove.dev** instead of opening a public issue.
194
+
195
+ ## Other Tailcove libraries
196
+
197
+ [Laravel](https://github.com/tailcove/laravel) · [Node.js](https://github.com/tailcove/node) · [Browser](https://github.com/tailcove/browser)
198
+
199
+ ## License
200
+
201
+ [MIT](LICENSE) © Akcybex
@@ -0,0 +1,11 @@
1
+ tailcove/__init__.py,sha256=M6jEyWQipvkW18ELB11Q4S_3DMJadjwqy5nD92aF_-w,144
2
+ tailcove/context.py,sha256=ePdDScnCQeY7ngXjDJM-rcC0W-vvEUSyVSropf73DcA,964
3
+ tailcove/exceptions.py,sha256=kjpZL4YUyoXpuD0WAH9LDFlAM6Q6kthVMXa61iyP8cc,1533
4
+ tailcove/handler.py,sha256=669QGPvOvEl2mbqHbdNkLXCBH0vDUwpIGjbe5uFppqs,5503
5
+ tailcove/levels.py,sha256=-y7b2vvz2klWQ7VnAkqWK1fcIhiQE6LMp8Sm5e0ZcVg,689
6
+ tailcove/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ tailcove/transport.py,sha256=zySSxWSgNsnL4mVSCBNzPMIbcJlfZzZb45xWlY3kKdU,1631
8
+ tailcove-0.1.0.dist-info/METADATA,sha256=1_BWAco6_iX7lkpcPIN2LqquIGESuWThMjUWW3SSxEc,7431
9
+ tailcove-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ tailcove-0.1.0.dist-info/licenses/LICENSE,sha256=mHEELvGtWbKsucOMiKAMnUeQMOZY3R4-IGGc1IEGUWE,1064
11
+ tailcove-0.1.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 Akcybex
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.