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 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