wasat 0.0.1__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.
- wasat/__init__.py +51 -0
- wasat/__main__.py +78 -0
- wasat/client.py +436 -0
- wasat/exceptions.py +28 -0
- wasat/py.typed +0 -0
- wasat/response.py +171 -0
- wasat/status.py +134 -0
- wasat/trust.py +179 -0
- wasat/uri.py +172 -0
- wasat-0.0.1.dist-info/METADATA +146 -0
- wasat-0.0.1.dist-info/RECORD +13 -0
- wasat-0.0.1.dist-info/WHEEL +4 -0
- wasat-0.0.1.dist-info/entry_points.txt +3 -0
wasat/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Wasat: An asynchronous, type-hinted client library for the Gemini Protocol."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
######################################################################
|
|
8
|
+
# Main library information.
|
|
9
|
+
__author__ = "Dave Pearson"
|
|
10
|
+
__copyright__ = "Copyright 2026, Dave Pearson"
|
|
11
|
+
__credits__ = ["Dave Pearson"]
|
|
12
|
+
__maintainer__ = "Dave Pearson"
|
|
13
|
+
__email__ = "davep@davep.org"
|
|
14
|
+
__version__: str = version("wasat")
|
|
15
|
+
__licence__ = "MIT"
|
|
16
|
+
|
|
17
|
+
##############################################################################
|
|
18
|
+
# Local imports.
|
|
19
|
+
from .client import Client
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
ConnectionError,
|
|
22
|
+
ProtocolError,
|
|
23
|
+
RedirectError,
|
|
24
|
+
SecurityError,
|
|
25
|
+
URIError,
|
|
26
|
+
WasatError,
|
|
27
|
+
)
|
|
28
|
+
from .response import Response
|
|
29
|
+
from .status import StatusCode
|
|
30
|
+
from .trust import FileTrustStore, TrustStore
|
|
31
|
+
from .uri import GEMINI_DEFAULT_PORT, GeminiURI
|
|
32
|
+
|
|
33
|
+
##############################################################################
|
|
34
|
+
# Exports.
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Client",
|
|
37
|
+
"Response",
|
|
38
|
+
"StatusCode",
|
|
39
|
+
"GeminiURI",
|
|
40
|
+
"GEMINI_DEFAULT_PORT",
|
|
41
|
+
"TrustStore",
|
|
42
|
+
"FileTrustStore",
|
|
43
|
+
"WasatError",
|
|
44
|
+
"URIError",
|
|
45
|
+
"ProtocolError",
|
|
46
|
+
"ConnectionError",
|
|
47
|
+
"SecurityError",
|
|
48
|
+
"RedirectError",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
### __init__.py ends here
|
wasat/__main__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Entry point for executing the wasat package directly."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from argparse import ArgumentParser, Namespace
|
|
6
|
+
from asyncio import run
|
|
7
|
+
from sys import exit, stderr
|
|
8
|
+
|
|
9
|
+
##############################################################################
|
|
10
|
+
# Local imports.
|
|
11
|
+
from . import Client, WasatError, __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
##############################################################################
|
|
15
|
+
def get_args() -> Namespace:
|
|
16
|
+
"""Parse command-line arguments.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Namespace: Parsed command-line arguments.
|
|
20
|
+
"""
|
|
21
|
+
parser = ArgumentParser(
|
|
22
|
+
prog="wasat",
|
|
23
|
+
description="An asynchronous client library and CLI for the Gemini protocol.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"url",
|
|
27
|
+
help="The Gemini URL to request.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version",
|
|
31
|
+
action="version",
|
|
32
|
+
version=f"%(prog)s {__version__}",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-v",
|
|
36
|
+
"--verbose",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Enable verbose output.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return parser.parse_args()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
##############################################################################
|
|
45
|
+
async def run_cli() -> None:
|
|
46
|
+
"""Run the Wasat CLI asynchronously."""
|
|
47
|
+
args = get_args()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
async with await Client(verify_mode="tofu").request(args.url) as response:
|
|
51
|
+
if args.verbose or not response.status.is_success:
|
|
52
|
+
print("--- Gemini Response ---")
|
|
53
|
+
print(f"Status: {response.status.value} ({response.status.name})")
|
|
54
|
+
print(f"Meta: {response.meta}")
|
|
55
|
+
print("-----------------------")
|
|
56
|
+
if response.status.is_success:
|
|
57
|
+
print(await response.text())
|
|
58
|
+
else:
|
|
59
|
+
exit(1)
|
|
60
|
+
except WasatError as e:
|
|
61
|
+
print(f"Error: {e}", file=stderr)
|
|
62
|
+
exit(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
##############################################################################
|
|
66
|
+
def main() -> None:
|
|
67
|
+
"""CLI entry point."""
|
|
68
|
+
try:
|
|
69
|
+
run(run_cli())
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
exit(130)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
##############################################################################
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
|
77
|
+
|
|
78
|
+
### __main__.py ends here
|
wasat/client.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Gemini Protocol async client implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import ssl
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Coroutine
|
|
10
|
+
from typing import Final, Literal
|
|
11
|
+
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
ConnectionError,
|
|
14
|
+
ProtocolError,
|
|
15
|
+
RedirectError,
|
|
16
|
+
SecurityError,
|
|
17
|
+
URIError,
|
|
18
|
+
)
|
|
19
|
+
from .response import Response
|
|
20
|
+
from .status import StatusCode
|
|
21
|
+
from .trust import FileTrustStore, TrustStore, get_cert_fingerprint
|
|
22
|
+
from .uri import GeminiURI
|
|
23
|
+
|
|
24
|
+
type NewCertCallback = Callable[[str, int, str], Coroutine[None, None, bool]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_DEFAULT_STORE_DIR: Final[str] = "wasat"
|
|
28
|
+
_DEFAULT_STORE_FILE: Final[str] = "known_hosts"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_default_trust_store_path() -> pathlib.Path:
|
|
32
|
+
"""Get the default trust store filepath based on the operating system's behaviour.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The default pathlib.Path to the known hosts store.
|
|
36
|
+
"""
|
|
37
|
+
if sys.platform == "win32":
|
|
38
|
+
appdata = os.environ.get("APPDATA")
|
|
39
|
+
base_dir = (
|
|
40
|
+
pathlib.Path(appdata)
|
|
41
|
+
if appdata
|
|
42
|
+
else pathlib.Path.home() / "AppData" / "Roaming"
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
base_dir = pathlib.Path.home() / ".config"
|
|
46
|
+
|
|
47
|
+
return base_dir / _DEFAULT_STORE_DIR / _DEFAULT_STORE_FILE
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WrappedStreamReader:
|
|
51
|
+
"""Wraps StreamReader to ensure the StreamWriter is closed upon reaching EOF or on error."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Initialise the wrapper.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
reader: The stream reader to wrap.
|
|
60
|
+
writer: The stream writer to close on EOF or error.
|
|
61
|
+
"""
|
|
62
|
+
self._reader = reader
|
|
63
|
+
self._writer = writer
|
|
64
|
+
self._closed = False
|
|
65
|
+
|
|
66
|
+
async def read(self, n: int = -1) -> bytes:
|
|
67
|
+
"""Read data from the stream, closing the connection at EOF.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
n: Number of bytes to read, or -1 to read until EOF.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The read bytes.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
Exception: Any exception raised by the underlying reader.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
chunk = await self._reader.read(n)
|
|
80
|
+
if not chunk or n == -1:
|
|
81
|
+
await self.close()
|
|
82
|
+
return chunk
|
|
83
|
+
except Exception:
|
|
84
|
+
await self.close()
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
"""Close the writer transport."""
|
|
89
|
+
if not self._closed:
|
|
90
|
+
self._closed = True
|
|
91
|
+
self._writer.close()
|
|
92
|
+
with contextlib.suppress(Exception):
|
|
93
|
+
await self._writer.wait_closed()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Client:
|
|
97
|
+
"""Asynchronous Gemini Protocol Client."""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
verify_mode: Literal["ca", "tofu", "off"] = "ca",
|
|
103
|
+
trust_store: TrustStore | None = None,
|
|
104
|
+
trust_store_path: str | pathlib.Path | None = None,
|
|
105
|
+
client_cert: str | pathlib.Path | None = None,
|
|
106
|
+
client_key: str | pathlib.Path | None = None,
|
|
107
|
+
on_new_certificate: NewCertCallback | None = None,
|
|
108
|
+
follow_redirects: bool = True,
|
|
109
|
+
max_redirects: int = 5,
|
|
110
|
+
connect_timeout: float = 10.0,
|
|
111
|
+
read_timeout: float = 30.0,
|
|
112
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Initialise the Gemini Client.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
verify_mode: The certificate verification mode:
|
|
118
|
+
- 'ca': Trust certificates signed by system CAs.
|
|
119
|
+
- 'tofu': Trust-On-First-Use validation.
|
|
120
|
+
- 'off': Disable certificate verification (insecure).
|
|
121
|
+
trust_store: Custom TrustStore instance for TOFU mode.
|
|
122
|
+
trust_store_path: Filepath for the default FileTrustStore in TOFU mode.
|
|
123
|
+
client_cert: Path to client TLS certificate (for client auth).
|
|
124
|
+
client_key: Path to client TLS private key (optional if in cert file).
|
|
125
|
+
on_new_certificate: Async callback called when a new certificate is
|
|
126
|
+
encountered in TOFU mode. Must return True to accept, False to reject.
|
|
127
|
+
follow_redirects: If True, automatically follow redirects.
|
|
128
|
+
max_redirects: Maximum number of redirects to follow.
|
|
129
|
+
connect_timeout: Timeout in seconds for establishing a connection.
|
|
130
|
+
read_timeout: Timeout in seconds for reading the response line.
|
|
131
|
+
ssl_context: Pre-configured ssl.SSLContext. Overrides verify_mode/cert config.
|
|
132
|
+
"""
|
|
133
|
+
self._verify_mode = verify_mode
|
|
134
|
+
self._trust_store = trust_store
|
|
135
|
+
self._client_cert = (
|
|
136
|
+
pathlib.Path(client_cert) if client_cert is not None else None
|
|
137
|
+
)
|
|
138
|
+
self._client_key = pathlib.Path(client_key) if client_key is not None else None
|
|
139
|
+
self._on_new_certificate = on_new_certificate
|
|
140
|
+
self._follow_redirects = follow_redirects
|
|
141
|
+
self._max_redirects = max_redirects
|
|
142
|
+
self._connect_timeout = connect_timeout
|
|
143
|
+
self._read_timeout = read_timeout
|
|
144
|
+
self._ssl_context = ssl_context
|
|
145
|
+
|
|
146
|
+
# Set up default trust store for TOFU if none is specified
|
|
147
|
+
if self._verify_mode == "tofu" and self._trust_store is None:
|
|
148
|
+
self._trust_store = FileTrustStore(
|
|
149
|
+
trust_store_path or _get_default_trust_store_path()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Cache for permanent redirects (status 31)
|
|
153
|
+
self._permanent_redirects: dict[GeminiURI, GeminiURI] = {}
|
|
154
|
+
|
|
155
|
+
def _create_ssl_context(self) -> ssl.SSLContext:
|
|
156
|
+
"""Create and configure the SSLContext based on verification settings.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A configured ssl.SSLContext instance.
|
|
160
|
+
"""
|
|
161
|
+
# TLS 1.3/1.2 recommended by Gemini Protocol
|
|
162
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
163
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
164
|
+
|
|
165
|
+
if self._verify_mode == "ca":
|
|
166
|
+
context.check_hostname = True
|
|
167
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
168
|
+
context.load_default_certs()
|
|
169
|
+
elif self._verify_mode in ("tofu", "off"):
|
|
170
|
+
context.check_hostname = False
|
|
171
|
+
context.verify_mode = ssl.CERT_NONE
|
|
172
|
+
|
|
173
|
+
if self._client_cert:
|
|
174
|
+
context.load_cert_chain(
|
|
175
|
+
certfile=self._client_cert,
|
|
176
|
+
keyfile=self._client_key,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return context
|
|
180
|
+
|
|
181
|
+
async def _send_request_line(
|
|
182
|
+
self, uri: GeminiURI, writer: asyncio.StreamWriter
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Send the Gemini request line to the server.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
uri: The target GeminiURI.
|
|
188
|
+
writer: The StreamWriter representing the established connection.
|
|
189
|
+
"""
|
|
190
|
+
writer.write(f"{uri}\r\n".encode())
|
|
191
|
+
await writer.drain()
|
|
192
|
+
|
|
193
|
+
async def _read_response_line(
|
|
194
|
+
self, reader: asyncio.StreamReader
|
|
195
|
+
) -> tuple[StatusCode, str]:
|
|
196
|
+
"""Read and parse the response line from the server.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
reader: The StreamReader representing the established connection.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
A tuple of (StatusCode, meta string).
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ConnectionError: If the connection is closed before reading the response.
|
|
206
|
+
ProtocolError: If the response line format is invalid.
|
|
207
|
+
"""
|
|
208
|
+
async with asyncio.timeout(self._read_timeout):
|
|
209
|
+
try:
|
|
210
|
+
response_line_bytes = await reader.readuntil(b"\r\n")
|
|
211
|
+
except asyncio.LimitOverrunError as e:
|
|
212
|
+
raise ProtocolError(
|
|
213
|
+
"Response line exceeds maximum allowed limit"
|
|
214
|
+
) from e
|
|
215
|
+
except (asyncio.IncompleteReadError, ConnectionResetError) as e:
|
|
216
|
+
raise ConnectionError(
|
|
217
|
+
"Connection closed by server before sending response"
|
|
218
|
+
) from e
|
|
219
|
+
|
|
220
|
+
response_line = response_line_bytes.decode("utf-8").rstrip("\r\n")
|
|
221
|
+
if not response_line:
|
|
222
|
+
raise ProtocolError("Received empty response line")
|
|
223
|
+
|
|
224
|
+
parts = response_line.split(" ", 1)
|
|
225
|
+
status_str = parts[0]
|
|
226
|
+
if len(status_str) != 2 or not status_str.isdigit():
|
|
227
|
+
raise ProtocolError(f"Invalid status code format: '{status_str}'")
|
|
228
|
+
|
|
229
|
+
status_value = int(status_str)
|
|
230
|
+
try:
|
|
231
|
+
status_code = StatusCode.from_int(status_value)
|
|
232
|
+
except ValueError as e:
|
|
233
|
+
raise ProtocolError(f"Invalid status code: '{status_str}': {e}") from e
|
|
234
|
+
meta = parts[1] if len(parts) > 1 else ""
|
|
235
|
+
|
|
236
|
+
return status_code, meta
|
|
237
|
+
|
|
238
|
+
async def _connect(
|
|
239
|
+
self, uri: GeminiURI, ssl_context: ssl.SSLContext
|
|
240
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
241
|
+
"""Establish connection to the Gemini server.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
uri: The target GeminiURI.
|
|
245
|
+
ssl_context: The SSLContext to use for the TLS handshake.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
A tuple of (StreamReader, StreamWriter).
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ConnectionError: If the connection attempt times out or fails.
|
|
252
|
+
SecurityError: If the TLS handshake fails.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
async with asyncio.timeout(self._connect_timeout):
|
|
256
|
+
return await asyncio.open_connection(
|
|
257
|
+
host=uri.host,
|
|
258
|
+
port=uri.port,
|
|
259
|
+
ssl=ssl_context,
|
|
260
|
+
server_hostname=uri.host if ssl_context.check_hostname else None,
|
|
261
|
+
)
|
|
262
|
+
except TimeoutError as e:
|
|
263
|
+
raise ConnectionError(
|
|
264
|
+
f"Connection to {uri.host}:{uri.port} timed out"
|
|
265
|
+
) from e
|
|
266
|
+
except ssl.SSLError as e:
|
|
267
|
+
raise SecurityError(f"TLS handshake failed: {e}") from e
|
|
268
|
+
except Exception as e:
|
|
269
|
+
raise ConnectionError(
|
|
270
|
+
f"Failed to connect to {uri.host}:{uri.port}: {e}"
|
|
271
|
+
) from e
|
|
272
|
+
|
|
273
|
+
async def _verify_tofu(self, uri: GeminiURI, writer: asyncio.StreamWriter) -> None:
|
|
274
|
+
"""Verify the peer certificate using Trust-On-First-Use (TOFU).
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
uri: The target GeminiURI.
|
|
278
|
+
writer: The StreamWriter representing the established connection.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
ConnectionError: If the TLS handshake was not completed.
|
|
282
|
+
SecurityError: If the certificate is missing, mismatched, or rejected.
|
|
283
|
+
"""
|
|
284
|
+
transport = writer.transport
|
|
285
|
+
ssl_object = transport.get_extra_info("ssl_object")
|
|
286
|
+
if ssl_object is None:
|
|
287
|
+
raise ConnectionError("TLS handshake not completed")
|
|
288
|
+
|
|
289
|
+
cert_der = ssl_object.getpeercert(binary_form=True)
|
|
290
|
+
if not cert_der:
|
|
291
|
+
raise SecurityError("Server did not present a TLS certificate")
|
|
292
|
+
|
|
293
|
+
assert self._trust_store is not None
|
|
294
|
+
is_trusted = await self._trust_store.verify(uri.host, uri.port, cert_der)
|
|
295
|
+
if not is_trusted:
|
|
296
|
+
stored_fingerprint = await self._trust_store.get_fingerprint(
|
|
297
|
+
uri.host, uri.port
|
|
298
|
+
)
|
|
299
|
+
current_fingerprint = get_cert_fingerprint(cert_der)
|
|
300
|
+
if stored_fingerprint is not None:
|
|
301
|
+
raise SecurityError(
|
|
302
|
+
f"Verification failed: certificate fingerprint mismatch for {uri.host}:{uri.port}. "
|
|
303
|
+
f"Expected: sha256:{stored_fingerprint}, Received: sha256:{current_fingerprint}."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
accept = True
|
|
307
|
+
if self._on_new_certificate:
|
|
308
|
+
accept = await self._on_new_certificate(
|
|
309
|
+
uri.host, uri.port, current_fingerprint
|
|
310
|
+
)
|
|
311
|
+
if accept:
|
|
312
|
+
await self._trust_store.save(uri.host, uri.port, cert_der)
|
|
313
|
+
else:
|
|
314
|
+
raise SecurityError(
|
|
315
|
+
f"Certificate rejected for {uri.host}:{uri.port} by callback."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
async def _do_request(self, uri: GeminiURI) -> Response:
|
|
319
|
+
"""Execute a single Gemini request.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
uri: The target GeminiURI.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
The Gemini Response object.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
ConnectionError: On connection/network failure.
|
|
329
|
+
SecurityError: On certificate validation failure.
|
|
330
|
+
ProtocolError: On protocol format violations.
|
|
331
|
+
"""
|
|
332
|
+
ssl_context = (
|
|
333
|
+
self._ssl_context
|
|
334
|
+
if self._ssl_context is not None
|
|
335
|
+
else self._create_ssl_context()
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
reader, writer = await self._connect(uri, ssl_context)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
if self._verify_mode == "tofu":
|
|
342
|
+
await self._verify_tofu(uri, writer)
|
|
343
|
+
|
|
344
|
+
await self._send_request_line(uri, writer)
|
|
345
|
+
status_code, meta = await self._read_response_line(reader)
|
|
346
|
+
|
|
347
|
+
if status_code.is_success:
|
|
348
|
+
wrapped_reader = WrappedStreamReader(reader, writer)
|
|
349
|
+
return Response(status_code, meta, wrapped_reader)
|
|
350
|
+
else:
|
|
351
|
+
writer.close()
|
|
352
|
+
with contextlib.suppress(Exception):
|
|
353
|
+
await writer.wait_closed()
|
|
354
|
+
return Response(status_code, meta, None)
|
|
355
|
+
|
|
356
|
+
except Exception:
|
|
357
|
+
writer.close()
|
|
358
|
+
with contextlib.suppress(Exception):
|
|
359
|
+
await writer.wait_closed()
|
|
360
|
+
raise
|
|
361
|
+
|
|
362
|
+
async def request(self, uri: str | GeminiURI) -> Response:
|
|
363
|
+
"""Perform a Gemini request and return the response.
|
|
364
|
+
|
|
365
|
+
Automatically handles redirection if configured.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
uri: The target URI as a string or GeminiURI object.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
The final Gemini Response object.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
URIError: If the URI is invalid.
|
|
375
|
+
ConnectionError: If network connection fails or times out.
|
|
376
|
+
SecurityError: If TLS/certificate check fails.
|
|
377
|
+
ProtocolError: If the server response violates the Gemini protocol.
|
|
378
|
+
RedirectError: If redirect limits are exceeded or loops are detected.
|
|
379
|
+
"""
|
|
380
|
+
uri = GeminiURI(uri)
|
|
381
|
+
|
|
382
|
+
# Resolve permanent redirects cache first
|
|
383
|
+
seen_redirects = {uri}
|
|
384
|
+
while uri in self._permanent_redirects:
|
|
385
|
+
uri = self._permanent_redirects[uri]
|
|
386
|
+
if uri in seen_redirects:
|
|
387
|
+
raise RedirectError(
|
|
388
|
+
f"Circular permanent redirect cache loop detected for {uri}"
|
|
389
|
+
)
|
|
390
|
+
seen_redirects.add(uri)
|
|
391
|
+
|
|
392
|
+
visited = {uri}
|
|
393
|
+
response = await self._do_request(uri)
|
|
394
|
+
|
|
395
|
+
while response.status.is_redirect and self._follow_redirects:
|
|
396
|
+
if len(visited) > self._max_redirects:
|
|
397
|
+
# Ensure we close the current response's connection
|
|
398
|
+
await response.close()
|
|
399
|
+
raise RedirectError(
|
|
400
|
+
f"Maximum redirect limit of {self._max_redirects} exceeded"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
redirect_str = response.meta.strip()
|
|
404
|
+
if not redirect_str:
|
|
405
|
+
await response.close()
|
|
406
|
+
raise ProtocolError(
|
|
407
|
+
"Redirect status received, but redirect URI is empty"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
new_uri = uri.resolve(redirect_str)
|
|
412
|
+
except URIError as e:
|
|
413
|
+
await response.close()
|
|
414
|
+
raise RedirectError(
|
|
415
|
+
f"Failed to resolve redirect URI '{redirect_str}': {e}"
|
|
416
|
+
) from e
|
|
417
|
+
|
|
418
|
+
if new_uri in visited:
|
|
419
|
+
await response.close()
|
|
420
|
+
raise RedirectError(f"Circular redirect detected: {new_uri}")
|
|
421
|
+
|
|
422
|
+
# If it's a permanent redirect, cache it
|
|
423
|
+
if response.status == StatusCode.PERMANENT_REDIRECT:
|
|
424
|
+
self._permanent_redirects[uri] = new_uri
|
|
425
|
+
|
|
426
|
+
visited.add(new_uri)
|
|
427
|
+
uri = new_uri
|
|
428
|
+
|
|
429
|
+
# Close previous response before making the next request
|
|
430
|
+
await response.close()
|
|
431
|
+
response = await self._do_request(uri)
|
|
432
|
+
|
|
433
|
+
return response
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
### client.py ends here
|
wasat/exceptions.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Exceptions for the Wasat Gemini client library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WasatError(Exception):
|
|
5
|
+
"""Base exception for all errors raised by the Wasat library."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class URIError(WasatError):
|
|
9
|
+
"""Raised when a Gemini URI is invalid or malformed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProtocolError(WasatError):
|
|
13
|
+
"""Raised when the Gemini server violates the protocol."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnectionError(WasatError):
|
|
17
|
+
"""Raised when a connection to the Gemini server fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SecurityError(WasatError):
|
|
21
|
+
"""Raised when TLS or certificate validation (TOFU) fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RedirectError(WasatError):
|
|
25
|
+
"""Raised when redirect limits are exceeded or redirects are invalid."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### exceptions.py ends here
|
wasat/py.typed
ADDED
|
File without changes
|