fetchgate 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.
@@ -0,0 +1,106 @@
1
+ """Transports return only a RawResponse, never a verdict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+ from urllib.parse import urlparse
7
+
8
+ from .types import RawResponse
9
+
10
+
11
+ class Transport(Protocol):
12
+ def fetch(self, url: str) -> RawResponse: ...
13
+
14
+
15
+ class MockTransport:
16
+ """Replays captured fixtures; raises on an unknown URL."""
17
+
18
+ def __init__(self, fixtures: dict[str, RawResponse], renderer_used: str = "static") -> None:
19
+ self._fixtures = fixtures
20
+ self._renderer_used = renderer_used
21
+
22
+ def fetch(self, url: str) -> RawResponse:
23
+ if url not in self._fixtures:
24
+ raise KeyError(f"MockTransport: no fixture for {url!r}")
25
+ raw = self._fixtures[url]
26
+ raw.renderer_used = self._renderer_used
27
+ return raw
28
+
29
+
30
+ class HttpTransport:
31
+ """Static-tier transport over stdlib urllib; production swaps in httpx."""
32
+
33
+ def __init__(self, timeout: float = 10.0, max_bytes: int = 5_000_000) -> None:
34
+ self._timeout = timeout
35
+ self._max_bytes = max_bytes
36
+
37
+ def fetch(self, url: str) -> RawResponse:
38
+ import urllib.error
39
+ import urllib.request
40
+
41
+ scheme = urlparse(url).scheme
42
+ if scheme not in ("http", "https"):
43
+ raise ValueError(f"unsupported scheme: {scheme!r}")
44
+
45
+ req = urllib.request.Request(url, headers={"User-Agent": "FetchGate/0.1 (+honest)"})
46
+ try:
47
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
48
+ body = resp.read(self._max_bytes + 1)
49
+ truncated = len(body) > self._max_bytes
50
+ if truncated:
51
+ body = body[: self._max_bytes]
52
+ final = resp.geturl()
53
+ return RawResponse(
54
+ url_requested=url,
55
+ url_final=final,
56
+ status=resp.status,
57
+ headers={k: v for k, v in resp.headers.items()},
58
+ body=body,
59
+ redirect_chain=[], # urllib does not expose the chain
60
+ transport_error=None,
61
+ renderer_used="static",
62
+ truncated=truncated,
63
+ )
64
+ except urllib.error.HTTPError as e:
65
+ body = e.read() if hasattr(e, "read") else b""
66
+ return RawResponse(url, e.geturl() if hasattr(e, "geturl") else url,
67
+ e.code, {k: v for k, v in (e.headers or {}).items()},
68
+ body, renderer_used="static")
69
+ except urllib.error.URLError as e:
70
+ reason = str(getattr(e, "reason", "")).lower()
71
+ err = "timeout" if "timed out" in reason else "dns" if "name" in reason else "reset"
72
+ return RawResponse(url, url, None, {}, b"", transport_error=err, renderer_used="static")
73
+
74
+
75
+ class BrowserTransport:
76
+ """Headless render tier (Playwright). Honest: default UA, no stealth."""
77
+
78
+ def __init__(self, timeout: float = 20.0, settle_ms: int = 2000, max_bytes: int = 5_000_000) -> None:
79
+ self._timeout = timeout
80
+ self._settle = settle_ms
81
+ self._max_bytes = max_bytes
82
+
83
+ def fetch(self, url: str) -> RawResponse:
84
+ if urlparse(url).scheme not in ("http", "https"):
85
+ raise ValueError(f"unsupported scheme: {urlparse(url).scheme!r}")
86
+ try:
87
+ from playwright.sync_api import sync_playwright
88
+ except ImportError:
89
+ return RawResponse(url, url, None, {}, b"", transport_error="browser_unavailable", renderer_used="headless")
90
+ try:
91
+ with sync_playwright() as p:
92
+ browser = p.chromium.launch(headless=True)
93
+ try:
94
+ page = browser.new_page()
95
+ resp = page.goto(url, wait_until="networkidle", timeout=int(self._timeout * 1000))
96
+ page.wait_for_timeout(self._settle)
97
+ html = page.content().encode("utf-8")[: self._max_bytes]
98
+ headers = dict(resp.headers) if resp else {}
99
+ if not any(k.lower() == "content-type" for k in headers):
100
+ headers["Content-Type"] = "text/html; charset=utf-8"
101
+ return RawResponse(url, page.url, resp.status if resp else None,
102
+ headers, html, renderer_used="headless")
103
+ finally:
104
+ browser.close()
105
+ except Exception:
106
+ return RawResponse(url, url, None, {}, b"", transport_error="render_error", renderer_used="headless")
fetchgate/types.py ADDED
@@ -0,0 +1,150 @@
1
+ """Enums and frozen dataclasses for envelopes, signals, and gate results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Optional
8
+
9
+
10
+ class Verdict(str, Enum):
11
+ RETRIEVED = "RETRIEVED"
12
+ FAILED = "FAILED"
13
+ UNKNOWN = "UNKNOWN"
14
+
15
+
16
+ class TransportClass(str, Enum):
17
+ OK = "OK"
18
+ FAIL = "FAIL"
19
+ AMBIGUOUS = "AMBIGUOUS"
20
+
21
+
22
+ class ExtractionClass(str, Enum):
23
+ EXTRACT_OK = "EXTRACT_OK"
24
+ EMPTY = "EMPTY"
25
+ THIN = "THIN"
26
+ UNSUPPORTED = "UNSUPPORTED"
27
+ STRUCTURED_EMPTY = "STRUCTURED_EMPTY"
28
+ STRUCTURED_PARSE_FAIL = "STRUCTURED_PARSE_FAIL"
29
+
30
+
31
+ class ValiditySeverity(str, Enum):
32
+ CLEAN = "clean"
33
+ CHALLENGE = "challenge"
34
+ SUSPECT = "suspect"
35
+
36
+
37
+ class FreshnessState(str, Enum):
38
+ FRESH = "FRESH"
39
+ STALE = "STALE"
40
+ UNKNOWN = "UNKNOWN"
41
+ NOT_REQUIRED = "NOT_REQUIRED"
42
+
43
+
44
+ class GateDisposition(str, Enum):
45
+ ALLOW = "ALLOW"
46
+ STOP = "STOP"
47
+
48
+
49
+ # Severity lattice: FAILED < UNKNOWN < RETRIEVED. decide() returns the minimum.
50
+ SEV = {Verdict.FAILED: 0, Verdict.UNKNOWN: 1, Verdict.RETRIEVED: 2}
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Redirect:
55
+ status: int
56
+ from_url: str
57
+ to_url: str
58
+
59
+
60
+ @dataclass
61
+ class RawResponse:
62
+ """What a transport returns. Never a verdict; the runtime rebuilds the envelope."""
63
+
64
+ url_requested: str
65
+ url_final: str
66
+ status: Optional[int]
67
+ headers: dict
68
+ body: bytes
69
+ redirect_chain: list = field(default_factory=list)
70
+ transport_error: Optional[str] = None # None | dns | timeout | reset | tls | too_many_redirects | redirect_loop | downgrade
71
+ elapsed_ms: int = 0
72
+ renderer_used: str = "static" # static | headless
73
+ truncated: bool = False
74
+
75
+ @property
76
+ def ok(self) -> bool:
77
+ return self.transport_error is None
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class TransportSignal:
82
+ cls: TransportClass
83
+ reason: str
84
+ cross_registrable_domain_redirect: bool = False
85
+ max_redirects_hit: bool = False
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ExtractionSignal:
90
+ cls: ExtractionClass
91
+ profile: str
92
+ extractor: str
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ValiditySignal:
97
+ severity: ValiditySeverity
98
+ fingerprint_hit: bool = False
99
+ fingerprint_id: Optional[str] = None
100
+ fingerprint_pack_version: str = "2026-07-01"
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Signals:
105
+ transport: TransportSignal
106
+ extraction: ExtractionSignal
107
+ content_validity: ValiditySignal
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Freshness:
112
+ required_max_age_seconds: Optional[int]
113
+ resource_timestamp: Optional[str]
114
+ resource_timestamp_source: Optional[str]
115
+ age_seconds: Optional[int]
116
+ age_vs_required_freshness: FreshnessState
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class ProvenanceEnvelope:
121
+ envelope_version: str
122
+ runtime_version: str
123
+ request_id: str
124
+ url_requested: str
125
+ url_final: str
126
+ redirect_chain: list
127
+ http_status: Optional[int]
128
+ fetched_at: str
129
+ content_type: Optional[str]
130
+ raw_bytes: int
131
+ extracted_bytes: int
132
+ renderer_used: str
133
+ truncated: bool
134
+ content_sha256: Optional[str]
135
+ hash_target: str
136
+ extracted_sha256: Optional[str]
137
+ policy_id: str
138
+ policy_sha256: str
139
+ freshness: Freshness
140
+ signals: Signals
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class GateResult:
145
+ verdict: Verdict
146
+ reason: str
147
+
148
+ @property
149
+ def disposition(self) -> GateDisposition:
150
+ return GateDisposition.ALLOW if self.verdict is Verdict.RETRIEVED else GateDisposition.STOP
fetchgate/verify.py ADDED
@@ -0,0 +1,75 @@
1
+ """Offline verifier: recompute the verdict from stored signals, bound to policy_sha256."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .config import FetchPolicy
8
+ from .detect import decide
9
+ from .types import (
10
+ ExtractionClass, ExtractionSignal, Freshness, FreshnessState, GateResult,
11
+ ProvenanceEnvelope, Redirect, Signals, TransportClass, TransportSignal,
12
+ ValiditySeverity, ValiditySignal,
13
+ )
14
+
15
+
16
+ def envelope_from_dict(d: dict) -> ProvenanceEnvelope:
17
+ s = d["signals"]
18
+ fr = d["freshness"]
19
+ return ProvenanceEnvelope(
20
+ envelope_version=d["envelope_version"],
21
+ runtime_version=d["runtime_version"],
22
+ request_id=d["request_id"],
23
+ url_requested=d["url_requested"],
24
+ url_final=d["url_final"],
25
+ redirect_chain=[Redirect(r["status"], r["from"], r["to"]) for r in d["redirect_chain"]],
26
+ http_status=d["http_status"],
27
+ fetched_at=d["fetched_at"],
28
+ content_type=d["content_type"],
29
+ raw_bytes=d["raw_bytes"],
30
+ extracted_bytes=d["extracted_bytes"],
31
+ renderer_used=d["renderer_used"],
32
+ truncated=d["truncated"],
33
+ content_sha256=d["content_sha256"],
34
+ hash_target=d["hash_target"],
35
+ extracted_sha256=d["extracted_sha256"],
36
+ policy_id=d["policy_id"],
37
+ policy_sha256=d["policy_sha256"],
38
+ freshness=Freshness(
39
+ fr["required_max_age_seconds"], fr["resource_timestamp"],
40
+ fr["resource_timestamp_source"], fr["age_seconds"],
41
+ FreshnessState(fr["age_vs_required_freshness"]),
42
+ ),
43
+ signals=Signals(
44
+ transport=TransportSignal(
45
+ TransportClass(s["transport"]["class"]), s["transport"]["reason"],
46
+ s["transport"]["cross_registrable_domain_redirect"],
47
+ s["transport"]["max_redirects_hit"],
48
+ ),
49
+ extraction=ExtractionSignal(
50
+ ExtractionClass(s["extraction"]["class"]),
51
+ s["extraction"]["profile"], s["extraction"]["extractor"],
52
+ ),
53
+ content_validity=ValiditySignal(
54
+ ValiditySeverity(s["content_validity"]["severity"]),
55
+ s["content_validity"]["fingerprint_hit"],
56
+ s["content_validity"]["fingerprint_id"],
57
+ s["content_validity"]["fingerprint_pack_version"],
58
+ ),
59
+ ),
60
+ )
61
+
62
+
63
+ class PolicyMismatch(ValueError):
64
+ """The supplied policy does not match the one recorded in the envelope."""
65
+
66
+
67
+ def recompute_verdict(envelope_dict: dict, policy: Optional[FetchPolicy] = None) -> GateResult:
68
+ """Recompute the verdict; raises PolicyMismatch if policy != the receipt's policy."""
69
+ policy = policy or FetchPolicy()
70
+ recorded = envelope_dict.get("policy_sha256")
71
+ if recorded is not None and policy.sha256() != recorded:
72
+ raise PolicyMismatch(
73
+ f"policy_sha256 mismatch: envelope={recorded} supplied={policy.sha256()}"
74
+ )
75
+ return decide(envelope_from_dict(envelope_dict), policy)
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: fetchgate
3
+ Version: 0.1.0
4
+ Summary: A deterministic gate at the fetch boundary: a 200 is transport success, not a read.
5
+ Project-URL: Homepage, https://github.com/MoAz06/FetchGate
6
+ Project-URL: Repository, https://github.com/MoAz06/FetchGate
7
+ Project-URL: Issues, https://github.com/MoAz06/FetchGate/issues
8
+ Author: Mohamed Azahrioui
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,guardrails,llm,mcp,provenance,retrieval
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.11
20
+ Provides-Extra: browser
21
+ Requires-Dist: playwright>=1.44; extra == 'browser'
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pytest>=8; extra == 'dev'
25
+ Requires-Dist: ruff>=0.5; extra == 'dev'
26
+ Provides-Extra: extract
27
+ Requires-Dist: trafilatura>=1.9; extra == 'extract'
28
+ Provides-Extra: mcp
29
+ Requires-Dist: mcp>=1.2; extra == 'mcp'
30
+ Provides-Extra: sign
31
+ Requires-Dist: cryptography>=42; extra == 'sign'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # FetchGate
35
+
36
+ **A 200 is not a read.**
37
+
38
+ When an AI agent fetches a page and it quietly comes back empty (a JavaScript app,
39
+ a 403, a Cloudflare wall), the agent often does not stop. It answers from memory
40
+ and never tells you it did not actually read the page. FetchGate is a small gate
41
+ at the fetch boundary that refuses to let that happen: the agent gets the content
42
+ only when the page was really read, and an honest refusal otherwise.
43
+
44
+ It is deterministic (no model, no API keys), stdlib-only at its core, and free.
45
+
46
+ ## Quickstart
47
+
48
+ ```bash
49
+ git clone https://github.com/MoAz06/FetchGate
50
+ cd FetchGate
51
+ python demo.py https://example.com https://httpbin.org/status/403
52
+ ```
53
+
54
+ ```
55
+ https://example.com
56
+ verdict : RETRIEVED (clean)
57
+ answerable : ALLOW
58
+ bytes : raw 559 / extracted 127
59
+ tiers : static
60
+ gate-confirmed content (127 chars): Example Domain This domain is for use ...
61
+
62
+ https://httpbin.org/status/403
63
+ verdict : FAILED (transport.http_403)
64
+ answerable : STOP
65
+ refused: the model gets no content and cannot answer as if it read this page.
66
+ ```
67
+
68
+ Run the tests (no network, no model):
69
+
70
+ ```bash
71
+ pip install -e .
72
+ python -m unittest discover -s tests
73
+ ```
74
+
75
+ ## Use it in Claude Desktop or Cursor
76
+
77
+ ```bash
78
+ pip install ".[mcp]" # the fetch tool
79
+ pip install ".[browser]" # optional: render JavaScript pages
80
+ python -m playwright install chromium
81
+ ```
82
+
83
+ Add to your MCP config (details in [docs/mcp.md](docs/mcp.md)):
84
+
85
+ ```json
86
+ { "mcpServers": { "fetchgate": { "command": "fetchgate-mcp" } } }
87
+ ```
88
+
89
+ Your agent now has a `fetch_url` tool that returns content only for a confirmed
90
+ read, and a refusal otherwise.
91
+
92
+ ## Use it in your own agent
93
+
94
+ ```python
95
+ from fetchgate import default_gate
96
+
97
+ gate = default_gate() # renders JS pages if the [browser] extra is installed
98
+
99
+ def read_url(url: str) -> str:
100
+ out = gate.guarded_fetch(url)
101
+ content = gate.content_for(out.handle)
102
+ if content is None:
103
+ return f"NOT READ: {url} ({out.result.reason}). Do not answer as if you read it."
104
+ return content
105
+ ```
106
+
107
+ ## How it decides
108
+
109
+ Every fetch gets one verdict:
110
+
111
+ - **RETRIEVED**: a real read. The agent may answer and cite it.
112
+ - **FAILED**: a definite non-read (a 4xx or 5xx, a timeout, an https to http
113
+ downgrade, an empty body). Stop.
114
+ - **UNKNOWN**: fetched, but not enough readable content to confirm a read (often a
115
+ JavaScript page). Stop, or render and try again.
116
+
117
+ Three layers feed the verdict, and the result is the strictest of them:
118
+
119
+ - **transport**: status and redirect chain.
120
+ - **extraction**: readable text kept separate from raw bytes. A 17 byte price JSON
121
+ is a read, a 400 KB article at 2 percent text is a read, a JS shell with no text
122
+ is not.
123
+ - **content-validity**: a best-effort fingerprint for Cloudflare, consent,
124
+ paywall, and soft-404 walls. It can only downgrade to UNKNOWN, never fake a read.
125
+
126
+ When a page looks thin or empty and the render tier is on, FetchGate escalates to
127
+ a real headless browser and re-checks. It renders, it does not evade: a page still
128
+ walled after an honest render stays a non-read.
129
+
130
+ ## Stated limits
131
+
132
+ - `content_sha256` is a receipt anchor, not tamper-evidence. A cloaked page hashes
133
+ cleanly.
134
+ - Provenance is not authenticity. The gate closes fail-silent-on-empty, not
135
+ fetch-succeeds-on-wrong-content.
136
+ - A read is not an answer. RETRIEVED means enough content arrived, not that it held
137
+ the fact you need. Claim support is a downstream job.
138
+ - Fail-closed has an availability cost. Anyone who can make your fetch fail can
139
+ force a refusal. That is the trade for correctness.
140
+
141
+ ## What is built
142
+
143
+ The deterministic core, the offline evaluation with a signed manifest, the
144
+ build-breaking cheat-test, the MCP server, and the Playwright render tier. Optional
145
+ extras: `[mcp]`, `[browser]`, `[extract]` (trafilatura), `[sign]` (Ed25519).
146
+
147
+ MIT licensed.
@@ -0,0 +1,22 @@
1
+ fetchgate/__init__.py,sha256=JVXm2Yreodh_dpAdModgMEoY0ck_Ud_WyEJdkJVwgSc,878
2
+ fetchgate/app.py,sha256=QkTJktb3a2JXnXe-ur0UTgi6_G12KrmlBtHvtiu1jKI,657
3
+ fetchgate/config.py,sha256=yKzfVcwmPhkuszxF705aY2rHWnEFY7NlDOPd9JkOG5U,3694
4
+ fetchgate/detect.py,sha256=wnjEYgxGi2lV1MIC1oHX_K1QWUoNsdAZ20TE86I3-ww,9051
5
+ fetchgate/envelope.py,sha256=zzjQfWSv5EH-DkIJSYnMfi_-zhXmGH2txDSfNhUg5m0,3018
6
+ fetchgate/extract.py,sha256=y_6jHmwHOcmofaa_5WqL7yBRtJsc-ObLKP_zKC4TkL4,3984
7
+ fetchgate/fetch.py,sha256=oZhtzd9e5N5TGjCHfxHJHImt-c4rZ7StOXrSS21-TyQ,3724
8
+ fetchgate/gate.py,sha256=MnbfK0nDTWj9pJkNuFtjK345-U3G_mzGDr9saGqjovw,4075
9
+ fetchgate/mcp_server.py,sha256=t_FtAmbUJUtuTCvebT2SI4adfwerWWVBER2wg1Hc6E4,1875
10
+ fetchgate/transports.py,sha256=YBJ5XFpYE0vPL2AI_rX1zzI4WFqNxXMtXDiL-QZBVs8,4527
11
+ fetchgate/types.py,sha256=ZLUoQV4SK4CWz0wz3dWhOhu3U8mpTmimsGu-NQjc3aU,3450
12
+ fetchgate/verify.py,sha256=fqHuYGTImBVxtpf2Jl_izeaK37vWuIkYxZ8vz8xSnLE,3017
13
+ fetchgate/eval/__init__.py,sha256=09j08Q6i9rs0kMIfPxhT8bPV96PAEzpbY3VpHTjDW54,91
14
+ fetchgate/eval/run_eval.py,sha256=kGMRxZ4rBMZ6eCH-1V3Uz2BiDqBraVoZj6lGRmtmWT4,4392
15
+ fetchgate/eval/spine.py,sha256=gSWLCNMNeKeBYeE-KIJNTk4s5FLjAlmqIdb_HREMK44,6626
16
+ fetchgate/eval/corpus/eval_manifest.json,sha256=hK-Ku3cCQWL3aUwbZqiOsVtk4vOyYvZ0pEivtsJyShU,20712
17
+ fetchgate/eval/corpus/sample_envelope.json,sha256=WqxSZJDE6uwXjznu-Ja7Xv_pp9Ehc8XcXOu04gVTiqo,1482
18
+ fetchgate-0.1.0.dist-info/METADATA,sha256=vxi39xcs2ukxa-ea4A4lv9G4qdDx8Yp9JC5GS8aM3h4,5132
19
+ fetchgate-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
20
+ fetchgate-0.1.0.dist-info/entry_points.txt,sha256=SlR-YZR_sOGbvespC0Ihk-vHC81XHmbSwe3QkvInGlY,60
21
+ fetchgate-0.1.0.dist-info/licenses/LICENSE,sha256=8pw6L2DaNOig8vyqHsnRamv51zKosOBeA9SZA9wzrZQ,1074
22
+ fetchgate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fetchgate-mcp = fetchgate.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohamed Azahrioui
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.