rnhttp 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.
rnhttp/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """rnhttp - HTTP/1.1 over Reticulum Network Stack."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("rnhttp")
6
+
7
+ from .client import HttpClient
8
+ from .server import HttpServer
9
+ from .types import (
10
+ HttpRequest,
11
+ HttpResponse,
12
+ )
13
+
14
+ __all__ = [
15
+ "HttpRequest",
16
+ "HttpResponse",
17
+ "HttpServer",
18
+ "HttpClient",
19
+ ]
rnhttp/__whitelist.py ADDED
@@ -0,0 +1,21 @@
1
+ _._read_timeout # unused attribute (rnhttp/client.py:44)
2
+ _.post # unused method (rnhttp/client.py:189)
3
+ _.put # unused method (rnhttp/client.py:198)
4
+ _.delete # unused method (rnhttp/client.py:207)
5
+ _.is_connected # unused property (rnhttp/client.py:221)
6
+ _._read_timeout # unused attribute (rnhttp/server.py:52)
7
+ _.route # unused method (rnhttp/server.py:78)
8
+ _.add_handler # unused method (rnhttp/server.py:99)
9
+ HttpSerializerError # unused class (rnhttp/types.py:26)
10
+ _.on_message_begin # unused method (rnhttp/types.py:42)
11
+ _.on_method # unused method (rnhttp/types.py:45)
12
+ _.on_url # unused method (rnhttp/types.py:48)
13
+ _.on_version # unused method (rnhttp/types.py:51)
14
+ _.on_header # unused method (rnhttp/types.py:54)
15
+ _.on_body # unused method (rnhttp/types.py:57)
16
+ _.on_message_begin # unused method (rnhttp/types.py:71)
17
+ _.on_version # unused method (rnhttp/types.py:74)
18
+ _.on_status_code # unused method (rnhttp/types.py:77)
19
+ _.on_reason_phrase # unused method (rnhttp/types.py:80)
20
+ _.on_header # unused method (rnhttp/types.py:83)
21
+ _.on_body # unused method (rnhttp/types.py:86)
rnhttp/_compat.py ADDED
@@ -0,0 +1,20 @@
1
+ # pyright: reportUnnecessaryTypeIgnoreComment=none
2
+ from collections.abc import Callable
3
+ from typing import (
4
+ Any,
5
+ cast,
6
+ )
7
+
8
+ try:
9
+ # Added in python 3.12
10
+ from typing import (
11
+ override, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue,reportUnknownType,reportUnnecessaryTypeIgnoreComment]
12
+ )
13
+
14
+ except ImportError:
15
+ from overrides import ( # pyright: ignore[reportMissingImports,reportUnnecessaryTypeIgnoreComment]
16
+ override, # pyright: ignore[reportUnknownVariableType,reportUnnecessaryTypeIgnoreComment]
17
+ )
18
+
19
+ override = cast(Callable[[Callable[..., Any]], Callable[..., Any]], override) # pyright: ignore[reportExplicitAny]
20
+ __all__ = ["override"]
rnhttp/client.py ADDED
@@ -0,0 +1,330 @@
1
+ """HTTP/1.1 client over RNS."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import sys
7
+ import threading
8
+ from typing import (
9
+ Any,
10
+ cast,
11
+ )
12
+
13
+ import RNS
14
+
15
+ from .types import (
16
+ HttpRequest,
17
+ HttpResponse,
18
+ )
19
+
20
+
21
+ class TransportError(Exception):
22
+ """Exception raised for transport errors."""
23
+
24
+ pass
25
+
26
+
27
+ class HttpClient:
28
+ """HTTP/1.1 client using RNS for transport."""
29
+
30
+ def __init__(
31
+ self,
32
+ destination_hash: bytes | str,
33
+ port: int,
34
+ identity_path: str | None = None,
35
+ connect_timeout: float = 30.0,
36
+ request_timeout: float = 60.0,
37
+ read_timeout: float = 30.0,
38
+ ) -> None:
39
+ if isinstance(destination_hash, str):
40
+ destination_hash = bytes.fromhex(destination_hash)
41
+
42
+ self._destination_hash: bytes = destination_hash
43
+ self._port: int = port
44
+ self._identity_path: str = identity_path or self._default_identity_path()
45
+ self._connect_timeout: float = connect_timeout
46
+ self._request_timeout: float = request_timeout
47
+ self._read_timeout: float = read_timeout
48
+ self._identity: RNS.Identity | None = None
49
+ self._link: RNS.Link | None = None
50
+
51
+ @staticmethod
52
+ def _default_identity_path() -> str:
53
+ """Get default identity path."""
54
+ home = os.path.expanduser("~")
55
+ return os.path.join(home, ".rnhttp", "identity")
56
+
57
+ def _load_or_create_identity(self) -> RNS.Identity:
58
+ """Load existing identity or create new one."""
59
+ if self._identity is not None:
60
+ return self._identity
61
+
62
+ if os.path.exists(self._identity_path):
63
+ self._identity = RNS.Identity.from_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
64
+ if self._identity is not None:
65
+ return self._identity
66
+
67
+ self._identity = RNS.Identity()
68
+ os.makedirs(os.path.dirname(self._identity_path), exist_ok=True)
69
+ _ = self._identity.to_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
70
+
71
+ return self._identity
72
+
73
+ async def connect(self) -> None:
74
+ """Connect to the server."""
75
+ _ = self._load_or_create_identity()
76
+ RNS.Transport.request_path(self._destination_hash) # pyright: ignore[reportUnknownMemberType]
77
+ if not RNS.Transport.has_path(self._destination_hash): # pyright: ignore[reportUnknownMemberType]
78
+ if not RNS.Transport.await_path( # pyright: ignore[reportUnknownMemberType]
79
+ self._destination_hash, self._connect_timeout
80
+ ):
81
+ raise TransportError("Timeout waiting for path to server")
82
+
83
+ server_identity = RNS.Identity.recall(self._destination_hash) # pyright: ignore[reportUnknownMemberType]
84
+ if server_identity is None:
85
+ raise TransportError("Could not recall server identity")
86
+
87
+ dest = RNS.Destination(
88
+ server_identity,
89
+ RNS.Destination.OUT,
90
+ RNS.Destination.SINGLE,
91
+ "HTTP",
92
+ str(self._port),
93
+ )
94
+
95
+ connected = threading.Event()
96
+
97
+ def on_established(_link: RNS.Link) -> None:
98
+ nonlocal connected
99
+ connected.set()
100
+
101
+ self._link = RNS.Link(dest, on_established)
102
+ if not connected.wait(self._connect_timeout):
103
+ if self._link is not None: # pyright: ignore[reportUnnecessaryComparison]
104
+ self._link.teardown()
105
+ self._link = None
106
+
107
+ raise TransportError("Connection timeout")
108
+
109
+ async def request(
110
+ self,
111
+ path: str,
112
+ method: str = "GET",
113
+ headers: dict[str, str] | None = None,
114
+ body: bytes | None = None,
115
+ ) -> HttpResponse:
116
+ """Send an HTTP request.
117
+
118
+ Args:
119
+ path: Request path
120
+ method: HTTP method (GET, POST, etc.)
121
+ headers: Additional headers
122
+ body: Request body
123
+
124
+ Returns:
125
+ HttpResponse object
126
+
127
+ Raises:
128
+ TransportError: If request fails
129
+ """
130
+ if self._link is None:
131
+ await self.connect()
132
+
133
+ if self._link is None:
134
+ raise TransportError("Not connected")
135
+
136
+ request = HttpRequest(
137
+ method=method,
138
+ path=path,
139
+ headers=headers or {},
140
+ body=body,
141
+ )
142
+
143
+ response_data = await self._send_request(bytes(request))
144
+ return HttpResponse.parse(response_data)
145
+
146
+ async def _send_request(self, data: bytes) -> bytes:
147
+ """Send request data and wait for response."""
148
+ if self._link is None:
149
+ raise TransportError("Not connected")
150
+
151
+ channel = self._link.get_channel()
152
+
153
+ response_event = threading.Event()
154
+ response_data: bytes | None = None
155
+ response_error: Exception | None = None
156
+
157
+ def on_reader_ready(ready: int) -> None:
158
+ nonlocal response_data, response_error, response_event, buffer
159
+ try:
160
+ response_data = buffer.read(ready)
161
+ response_event.set()
162
+
163
+ except Exception as e:
164
+ response_error = e
165
+ response_event.set()
166
+
167
+ buffer = RNS.Buffer.create_bidirectional_buffer(1, 0, channel, on_reader_ready)
168
+ _ = buffer.write(data)
169
+ buffer.flush()
170
+
171
+ if not response_event.wait(self._request_timeout):
172
+ raise TransportError("Request timeout")
173
+
174
+ if response_error is not None:
175
+ raise TransportError( # pyright: ignore[reportUnreachable]
176
+ f"Request failed: {response_error}"
177
+ ) from response_error
178
+
179
+ if response_data is None:
180
+ raise TransportError("No response received")
181
+
182
+ return response_data # pyright: ignore[reportUnreachable]
183
+
184
+ async def get(
185
+ self,
186
+ path: str,
187
+ headers: dict[str, str] | None = None,
188
+ ) -> HttpResponse:
189
+ """Send GET request."""
190
+ return await self.request(path, "GET", headers)
191
+
192
+ async def post(
193
+ self,
194
+ path: str,
195
+ body: bytes | None = None,
196
+ headers: dict[str, str] | None = None,
197
+ ) -> HttpResponse:
198
+ """Send POST request."""
199
+ return await self.request(path, "POST", headers, body)
200
+
201
+ async def put(
202
+ self,
203
+ path: str,
204
+ body: bytes | None = None,
205
+ headers: dict[str, str] | None = None,
206
+ ) -> HttpResponse:
207
+ """Send PUT request."""
208
+ return await self.request(path, "PUT", headers, body)
209
+
210
+ async def delete(
211
+ self,
212
+ path: str,
213
+ headers: dict[str, str] | None = None,
214
+ ) -> HttpResponse:
215
+ """Send DELETE request."""
216
+ return await self.request(path, "DELETE", headers)
217
+
218
+ async def close(self) -> None:
219
+ """Close the connection."""
220
+ if self._link is not None:
221
+ self._link.teardown()
222
+ self._link = None
223
+
224
+ @property
225
+ def is_connected(self) -> bool:
226
+ """Check if client is connected."""
227
+ return self._link is not None
228
+
229
+ async def __aenter__(self) -> "HttpClient":
230
+ """Async context manager entry."""
231
+ await self.connect()
232
+ return self
233
+
234
+ async def __aexit__(self, _exc_type: Any, _exc_val: Any, _exc_tb: Any) -> None: # pyright: ignore[reportAny, reportExplicitAny] # noqa: ANN401
235
+ """Async context manager exit."""
236
+ await self.close()
237
+
238
+
239
+ async def main():
240
+ parser = argparse.ArgumentParser(description="HTTP/1.1 client over Reticulum")
241
+ _ = parser.add_argument("destination", type=str, help="Server destination hash")
242
+ _ = parser.add_argument("port", type=int, help="Server port")
243
+ _ = parser.add_argument("method", type=str, default="GET", help="HTTP method")
244
+ _ = parser.add_argument("path", type=str, default="/", help="Request path")
245
+ _ = parser.add_argument("--config", type=str, help="RNS config directory")
246
+ _ = parser.add_argument("--identity", type=str, help="Identity file path")
247
+ _ = parser.add_argument(
248
+ "-v",
249
+ "--verbose",
250
+ action="store_true",
251
+ help="Enable verbose logging",
252
+ dest="verbose",
253
+ )
254
+ _ = parser.add_argument(
255
+ "-H", "--header", action="append", help="Add header (Format: Name: Value)"
256
+ )
257
+ _ = parser.add_argument("--body", type=str, help="Request body")
258
+ _ = parser.add_argument(
259
+ "-r",
260
+ "--response-code",
261
+ action="store_true",
262
+ help="Print the response code and exit",
263
+ dest="response_code",
264
+ )
265
+ args = parser.parse_args()
266
+
267
+ assert isinstance(args.config, str | None) # pyright: ignore[reportAny]
268
+ config_path = args.config
269
+ if config_path is None:
270
+ config_path = os.environ.get("RNS_CONFIG_PATH", None)
271
+
272
+ assert isinstance(args.verbose, bool) # pyright: ignore[reportAny]
273
+ _ = RNS.Reticulum(config_path, RNS.LOG_VERBOSE if args.verbose else RNS.LOG_WARNING)
274
+
275
+ headers: dict[str, str] = {}
276
+ assert isinstance(args.header, list | None) # pyright: ignore[reportAny]
277
+ if args.header is not None: # pyright: ignore[reportUnknownMemberType]
278
+ for header in cast(list[str], args.header):
279
+ if "=" in header:
280
+ name, value = header.split("=", 1)
281
+ headers[name] = value
282
+
283
+ assert isinstance(args.body, str | None) # pyright: ignore[reportAny]
284
+ body = args.body.encode("utf-8") if args.body else None
285
+
286
+ assert isinstance(args.destination, str) # pyright: ignore[reportAny]
287
+ assert isinstance(args.port, int) # pyright: ignore[reportAny]
288
+ assert isinstance(args.identity, str | None) # pyright: ignore[reportAny]
289
+ client = HttpClient(
290
+ destination_hash=args.destination,
291
+ port=args.port,
292
+ identity_path=args.identity,
293
+ )
294
+
295
+ assert isinstance(args.method, str) # pyright: ignore[reportAny]
296
+ assert isinstance(args.path, str) # pyright: ignore[reportAny]
297
+ try:
298
+ async with client:
299
+ response = await client.request(
300
+ path=args.path,
301
+ method=args.method.upper(),
302
+ headers=headers,
303
+ body=body,
304
+ )
305
+ assert isinstance(args.response_code, bool) # pyright: ignore[reportAny]
306
+ if args.response_code:
307
+ print(response.status)
308
+
309
+ else:
310
+ _ = sys.stdout.write(
311
+ f"{response.version} {response.status} {response.reason}\n"
312
+ )
313
+ for name, value in response.headers.items():
314
+ _ = sys.stdout.write(f"{name}: {value}\n")
315
+
316
+ _ = sys.stdout.write("\n")
317
+ if response.body:
318
+ _ = sys.stdout.buffer.write(response.body)
319
+
320
+ _ = sys.stdout.buffer.flush()
321
+
322
+ sys.exit(0 if response.status < 400 else 1)
323
+
324
+ except TransportError as e:
325
+ print(f"Error: {e}", file=sys.stderr)
326
+ sys.exit(1)
327
+
328
+
329
+ if __name__ == "__main__":
330
+ asyncio.run(main())
rnhttp/server.py ADDED
@@ -0,0 +1,312 @@
1
+ """HTTP/1.1 server over RNS."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import io
6
+ import os
7
+ import sys
8
+ from collections.abc import (
9
+ Awaitable,
10
+ Callable,
11
+ )
12
+ from typing import TypeVar
13
+
14
+ import RNS
15
+
16
+ from .types import (
17
+ HttpRequest,
18
+ HttpResponse,
19
+ )
20
+
21
+ HandlerType = (
22
+ Callable[[HttpRequest], HttpResponse]
23
+ | Callable[[HttpRequest], Awaitable[HttpResponse]]
24
+ )
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ def await_in_sync(awaitable: Awaitable[T]) -> T:
30
+ """Run any awaitable from synchronous code safely."""
31
+ try:
32
+ loop = asyncio.get_running_loop()
33
+ return loop.run_until_complete(awaitable)
34
+
35
+ except RuntimeError:
36
+ return asyncio.run(awaitable) # pyright: ignore[reportUnknownVariableType, reportArgumentType]
37
+
38
+
39
+ class HttpServer:
40
+ """HTTP/1.1 server using RNS for transport."""
41
+
42
+ def __init__(
43
+ self,
44
+ port: int,
45
+ identity_path: str | None = None,
46
+ request_timeout: float = 60.0,
47
+ read_timeout: float = 30.0,
48
+ ) -> None:
49
+ self._port: int = port
50
+ self._identity_path: str = identity_path or self._default_identity_path()
51
+ self._request_timeout: float = request_timeout
52
+ self._read_timeout: float = read_timeout
53
+ self._destination: RNS.Destination | None = None
54
+ self._handlers: dict[tuple[str, str], HandlerType] = {}
55
+ self._default_handler: HandlerType | None = None
56
+ self._running: bool = False
57
+ self._links: dict[int, tuple[RNS.Link, io.BufferedRWPair]] = {}
58
+
59
+ @staticmethod
60
+ def _default_identity_path() -> str:
61
+ """Get default identity path."""
62
+ home = os.path.expanduser("~")
63
+ return os.path.join(home, ".rnhttp", "identity")
64
+
65
+ def _load_or_create_identity(self) -> RNS.Identity:
66
+ """Load existing identity or create new one."""
67
+ if os.path.exists(self._identity_path):
68
+ identity = RNS.Identity.from_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
69
+ if identity is not None:
70
+ return identity
71
+
72
+ identity = RNS.Identity()
73
+ os.makedirs(os.path.dirname(self._identity_path), exist_ok=True)
74
+ _ = identity.to_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
75
+
76
+ return identity
77
+
78
+ def route(
79
+ self, path: str, method: str = "GET"
80
+ ) -> Callable[[HandlerType], HandlerType]:
81
+ """Decorator to register a handler for a path and method.
82
+
83
+ Usage:
84
+ @server.route("/api/data")
85
+ def handle_request(request: HttpRequest) -> HttpResponse:
86
+ return HttpResponse(status=200, body=b"OK")
87
+
88
+ @server.route("/api/data", method="POST")
89
+ def handle_post(request: HttpRequest) -> HttpResponse:
90
+ return HttpResponse(status=200, body=b"OK")
91
+ """
92
+
93
+ def decorator(handler: HandlerType) -> HandlerType:
94
+ self._handlers[(method.upper(), path)] = handler
95
+ return handler
96
+
97
+ return decorator
98
+
99
+ def add_handler(self, path: str, handler: HandlerType, method: str = "GET") -> None:
100
+ """Add a handler for a path and method."""
101
+ self._handlers[(method.upper(), path)] = handler
102
+
103
+ def set_default_handler(self, handler: HandlerType) -> None:
104
+ """Set a default handler for all requests."""
105
+ self._default_handler = handler
106
+
107
+ async def start(self) -> None:
108
+ """Start the HTTP server."""
109
+ identity = self._load_or_create_identity()
110
+
111
+ self._destination = RNS.Destination(
112
+ identity,
113
+ RNS.Destination.IN,
114
+ RNS.Destination.SINGLE,
115
+ "HTTP",
116
+ str(self._port),
117
+ )
118
+
119
+ self._running = True
120
+ _ = self._destination.accepts_links(True) # pyright: ignore[reportUnknownMemberType]
121
+ self._destination.set_link_established_callback(self._on_link) # pyright: ignore[reportUnknownMemberType]
122
+ _ = self._destination.announce() # pyright: ignore[reportUnknownMemberType]
123
+
124
+ async def stop(self) -> None:
125
+ """Stop the HTTP server."""
126
+ self._running = False
127
+ for link, _ in list(self._links.values()):
128
+ link.teardown()
129
+
130
+ self._links.clear()
131
+ if self._destination is not None:
132
+ _ = self._destination.accepts_links(False) # pyright: ignore[reportUnknownMemberType]
133
+
134
+ def _on_link(self, link: RNS.Link) -> None:
135
+ """Handle incoming link."""
136
+ if not self._running:
137
+ link.teardown()
138
+ return
139
+
140
+ print(f"Connected: {link}")
141
+
142
+ def _on_reader_ready(ready: int) -> None:
143
+ """Handle incoming data on reader."""
144
+ nonlocal link
145
+ print(f"Reader ready {link}: {ready}")
146
+ _, buffer = self._links.get(id(link), (None, None))
147
+ if buffer is None:
148
+ return
149
+
150
+ request: HttpRequest | Awaitable[HttpRequest] | None = None
151
+ response: HttpResponse | Awaitable[HttpResponse] | None = None
152
+ try:
153
+ request = HttpRequest.parse(buffer.read(ready))
154
+
155
+ except Exception as e:
156
+ response = HttpResponse(
157
+ status=400,
158
+ body=str(e).encode("utf-8"),
159
+ )
160
+
161
+ if response is None:
162
+ assert request is not None
163
+ try:
164
+ response = self._handle_request(link, request)
165
+
166
+ except Exception as e:
167
+ response = HttpResponse(
168
+ status=500,
169
+ body=str(e).encode("utf-8"),
170
+ )
171
+
172
+ await_in_sync(self._send_response(buffer, response))
173
+
174
+ link.set_link_closed_callback(self._on_close) # pyright: ignore[reportUnknownMemberType]
175
+ channel = link.get_channel()
176
+ buffer = RNS.Buffer.create_bidirectional_buffer(0, 1, channel, _on_reader_ready)
177
+ self._links[id(link)] = (link, buffer)
178
+
179
+ async def _send_response(
180
+ self,
181
+ writer: io.BufferedRWPair,
182
+ response: HttpResponse | Awaitable[HttpResponse],
183
+ ) -> None:
184
+ """Send response back on the link."""
185
+ if isinstance(response, Awaitable):
186
+ response = await response
187
+
188
+ try:
189
+ _ = writer.write(bytes(response))
190
+ writer.flush()
191
+
192
+ except Exception: # TODO narrow this to actual exception
193
+ link_id = None
194
+ for lid, (_, buffer) in self._links.items():
195
+ if buffer is writer:
196
+ link_id = lid
197
+ break
198
+
199
+ if link_id is not None:
200
+ link, _ = self._links.pop(link_id, (None, None))
201
+ if link is not None:
202
+ link.teardown()
203
+
204
+ def _on_close(self, link: RNS.Link) -> None:
205
+ """Handle link close."""
206
+ _ = self._links.pop(id(link), None)
207
+
208
+ def _handle_request(
209
+ self, link: RNS.Link, request: HttpRequest
210
+ ) -> HttpResponse | Awaitable[HttpResponse]:
211
+ """Handle incoming HTTP request."""
212
+ path = request.path
213
+ method = request.method.upper()
214
+ print(f"{link} {method} {path}")
215
+
216
+ key = (method, path)
217
+ if key in self._handlers:
218
+ return self._handlers[key](request)
219
+
220
+ for (_, p), handler in self._handlers.items():
221
+ if self._match_pattern(p, path):
222
+ return handler(request)
223
+
224
+ if self._default_handler:
225
+ return self._default_handler(request)
226
+
227
+ return HttpResponse(
228
+ status=404,
229
+ reason="Not Found",
230
+ body=b"Not Found",
231
+ )
232
+
233
+ def _match_pattern(self, pattern: str, path: str) -> bool:
234
+ """Simple pattern matching for routes."""
235
+ if pattern.endswith("*"):
236
+ return path.startswith(pattern[:-1])
237
+ return path == pattern
238
+
239
+ @property
240
+ def port(self) -> int:
241
+ """Get the server port."""
242
+ return self._port
243
+
244
+ @property
245
+ def destination_hash(self) -> str | None:
246
+ """Get the server destination hash as hex string."""
247
+ if self._destination is None:
248
+ return None
249
+
250
+ assert isinstance(self._destination.hexhash, str | None) # pyright: ignore[reportAny]
251
+ return self._destination.hexhash
252
+
253
+ @property
254
+ def is_running(self) -> bool:
255
+ """Check if server is running."""
256
+ return self._running
257
+
258
+
259
+ async def main():
260
+ parser = argparse.ArgumentParser(description="HTTP/1.1 server over Reticulum")
261
+ _ = parser.add_argument("port", type=int, help="Port number")
262
+ _ = parser.add_argument("--config", type=str, help="RNS config directory")
263
+ _ = parser.add_argument("--identity", type=str, help="Identity file path")
264
+ _ = parser.add_argument(
265
+ "-v",
266
+ "--verbose",
267
+ action="store_true",
268
+ help="Enable verbose logging",
269
+ dest="verbose",
270
+ )
271
+ args = parser.parse_args()
272
+
273
+ assert isinstance(args.config, str | None) # pyright: ignore[reportAny]
274
+ config_path = args.config
275
+ if config_path is None:
276
+ config_path = os.environ.get("RNS_CONFIG_PATH", None)
277
+
278
+ assert isinstance(args.verbose, bool) # pyright: ignore[reportAny]
279
+ _ = RNS.Reticulum(config_path, RNS.LOG_VERBOSE if args.verbose else RNS.LOG_WARNING)
280
+
281
+ assert isinstance(args.identity, str | None) # pyright: ignore[reportAny]
282
+ assert isinstance(args.port, int) # pyright: ignore[reportAny]
283
+ server = HttpServer(
284
+ port=args.port,
285
+ identity_path=args.identity,
286
+ )
287
+
288
+ def default_handler(_request: HttpRequest) -> HttpResponse:
289
+ return HttpResponse(
290
+ status=200,
291
+ body=b"Hello world!",
292
+ )
293
+
294
+ server.set_default_handler(default_handler)
295
+
296
+ await server.start()
297
+ print(f"Server listening on HTTP.{server.port}", file=sys.stderr, flush=True)
298
+ print(f"Destination hash: {server.destination_hash}", file=sys.stderr, flush=True)
299
+
300
+ try:
301
+ while server.is_running:
302
+ await asyncio.sleep(1)
303
+
304
+ except KeyboardInterrupt:
305
+ pass
306
+
307
+ finally:
308
+ await server.stop()
309
+
310
+
311
+ if __name__ == "__main__":
312
+ asyncio.run(main())
rnhttp/types.py ADDED
@@ -0,0 +1,336 @@
1
+ """HTTP/1.1 protocol parser and serializer.
2
+
3
+ Based on RFC 9112 - HTTP/1.1 Message Syntax and Routing (June 2022)
4
+ Uses httptools for parsing."""
5
+
6
+ from typing import final
7
+
8
+ from httptools import (
9
+ HttpParserError as HttptoolsParserError,
10
+ )
11
+ from httptools import (
12
+ HttpRequestParser,
13
+ HttpResponseParser,
14
+ )
15
+
16
+ from ._compat import override
17
+
18
+
19
+ class HttpParserError(Exception):
20
+ """Exception raised for HTTP parsing errors."""
21
+
22
+ pass
23
+
24
+
25
+ class HttpSerializerError(Exception):
26
+ """Exception raised for HTTP serialization errors."""
27
+
28
+ pass
29
+
30
+
31
+ class RequestCallbacks:
32
+ """Callback handler for request parsing."""
33
+
34
+ def __init__(self) -> None:
35
+ self.method: bytes = b""
36
+ self.url: bytes = b""
37
+ self.version: bytes = b"HTTP/1.1"
38
+ self.headers: dict[bytes, bytes] = {}
39
+ self.body_chunks: list[bytes] = []
40
+
41
+ def on_message_begin(self) -> None:
42
+ pass
43
+
44
+ def on_method(self, method: bytes) -> None:
45
+ self.method = method
46
+
47
+ def on_url(self, url: bytes) -> None:
48
+ self.url = url
49
+
50
+ def on_version(self, version: bytes) -> None:
51
+ self.version = version
52
+
53
+ def on_header(self, name: bytes, value: bytes) -> None:
54
+ self.headers[name] = value
55
+
56
+ def on_body(self, body: bytes) -> None:
57
+ self.body_chunks.append(body)
58
+
59
+
60
+ class ResponseCallbacks:
61
+ """Callback handler for response parsing."""
62
+
63
+ def __init__(self) -> None:
64
+ self.version: bytes = b"HTTP/1.1"
65
+ self.status: int = 0
66
+ self.reason: bytes = b""
67
+ self.headers: dict[bytes, bytes] = {}
68
+ self.body_chunks: list[bytes] = []
69
+
70
+ def on_message_begin(self) -> None:
71
+ pass
72
+
73
+ def on_version(self, version: bytes) -> None:
74
+ self.version = version
75
+
76
+ def on_status_code(self, status_code: bytes) -> None:
77
+ self.status = int(status_code)
78
+
79
+ def on_reason_phrase(self, reason: bytes) -> None:
80
+ self.reason = reason
81
+
82
+ def on_header(self, name: bytes, value: bytes) -> None:
83
+ self.headers[name] = value
84
+
85
+ def on_body(self, body: bytes) -> None:
86
+ self.body_chunks.append(body)
87
+
88
+
89
+ def encode_chunked(body: bytes) -> bytes:
90
+ """Encode body using chunked transfer encoding.
91
+
92
+ Args:
93
+ body: Body bytes to encode
94
+
95
+ Returns:
96
+ Chunked encoded body bytes
97
+ """
98
+ offset = 0
99
+ data: bytes = b""
100
+ while offset < len(body):
101
+ chunk = body[offset : offset + 4096]
102
+ data += f"{len(chunk):x}".encode() + b"\r\n" + chunk + b"\r\n"
103
+ offset += len(chunk)
104
+
105
+ return data + b"0" + b"\r\n\r\n"
106
+
107
+
108
+ @final
109
+ class HttpRequest:
110
+ """Represents an HTTP/1.1 request."""
111
+
112
+ __slots__ = ("method", "path", "version", "headers", "body")
113
+
114
+ def __init__(
115
+ self,
116
+ method: str,
117
+ path: str,
118
+ version: str = "HTTP/1.1",
119
+ headers: dict[str, str] | None = None,
120
+ body: bytes | None = None,
121
+ ) -> None:
122
+ self.method = method
123
+ self.path = path
124
+ self.version = version
125
+ self.headers = headers if headers is not None else {}
126
+ self.body = body
127
+
128
+ @staticmethod
129
+ def parse(data: bytes) -> "HttpRequest":
130
+ if not data:
131
+ raise HttpParserError("Incomplete request")
132
+
133
+ callbacks = RequestCallbacks()
134
+ parser = HttpRequestParser(callbacks)
135
+
136
+ try:
137
+ parser.feed_data(data) # pyright: ignore[reportUnknownMemberType]
138
+
139
+ except HttptoolsParserError as e:
140
+ raise HttpParserError(str(e)) from e
141
+
142
+ method = parser.get_method()
143
+ if not method:
144
+ raise HttpParserError("Incomplete request")
145
+
146
+ version = parser.get_http_version()
147
+
148
+ return HttpRequest(
149
+ method=method.decode("utf-8"),
150
+ path=callbacks.url.decode("utf-8") if callbacks.url else "/",
151
+ version=f"HTTP/{version}" if version else "HTTP/1.1",
152
+ headers={
153
+ k.decode("utf-8").lower(): v.decode("utf-8")
154
+ for k, v in callbacks.headers.items()
155
+ },
156
+ body=b"".join(callbacks.body_chunks) if callbacks.body_chunks else None,
157
+ )
158
+
159
+ @override
160
+ def __repr__(self) -> str:
161
+ body_len = len(self.body) if self.body else 0
162
+ return (
163
+ f"HttpRequest(method={self.method!r}, path={self.path!r}, "
164
+ f"version={self.version!r}, body_len={body_len})"
165
+ )
166
+
167
+ @override
168
+ def __eq__(self, other: object) -> bool:
169
+ if not isinstance(other, HttpRequest):
170
+ return NotImplemented
171
+ return (
172
+ self.method == other.method
173
+ and self.path == other.path
174
+ and self.version == other.version
175
+ and self.headers == other.headers
176
+ and self.body == other.body
177
+ )
178
+
179
+ def __bytes__(self) -> bytes:
180
+ status_line = (f"{self.method} {self.path} {self.version}").encode() + b"\r\n"
181
+
182
+ headers = dict(self.headers)
183
+ body = self.body
184
+ if body is not None:
185
+ if headers.get("Transfer-Encoding", "").lower() == "chunked":
186
+ body = encode_chunked(body)
187
+ headers["Transfer-Encoding"] = "chunked"
188
+ _ = headers.pop("Content-Length", None)
189
+
190
+ elif "Content-Length" not in headers:
191
+ headers["Content-Length"] = str(len(body))
192
+
193
+ header_bytes = (
194
+ b"\r\n".join([f"{k}: {v}".encode() for k, v in headers.items()])
195
+ + b"\r\n"
196
+ + b"\r\n"
197
+ )
198
+ if body is None:
199
+ return status_line + header_bytes
200
+
201
+ return status_line + header_bytes + body
202
+
203
+
204
+ def reason_text(status: int) -> str:
205
+ """Return default reason phrase for status code."""
206
+ reasons = {
207
+ 100: "Continue",
208
+ 101: "Switching Protocols",
209
+ 200: "OK",
210
+ 201: "Created",
211
+ 202: "Accepted",
212
+ 204: "No Content",
213
+ 301: "Moved Permanently",
214
+ 302: "Found",
215
+ 304: "Not Modified",
216
+ 400: "Bad Request",
217
+ 401: "Unauthorized",
218
+ 403: "Forbidden",
219
+ 404: "Not Found",
220
+ 405: "Method Not Allowed",
221
+ 408: "Request Timeout",
222
+ 409: "Conflict",
223
+ 413: "Payload Too Large",
224
+ 414: "URI Too Long",
225
+ 500: "Internal Server Error",
226
+ 501: "Not Implemented",
227
+ 502: "Bad Gateway",
228
+ 503: "Service Unavailable",
229
+ 504: "Gateway Timeout",
230
+ }
231
+ return reasons.get(status, "Unknown")
232
+
233
+
234
+ @final
235
+ class HttpResponse:
236
+ """Represents an HTTP/1.1 response."""
237
+
238
+ __slots__ = ("version", "status", "reason", "headers", "body")
239
+
240
+ def __init__(
241
+ self,
242
+ status: int,
243
+ reason: str | None = None,
244
+ version: str = "HTTP/1.1",
245
+ headers: dict[str, str] | None = None,
246
+ body: bytes | None = None,
247
+ ) -> None:
248
+ self.version = version
249
+ self.status = status
250
+ self.reason = reason if reason is not None else reason_text(status)
251
+ self.headers = headers if headers is not None else {}
252
+ self.body = body
253
+
254
+ @staticmethod
255
+ def parse(data: bytes) -> "HttpResponse":
256
+ """Parse an HTTP/1.1 response from raw bytes.
257
+
258
+ Args:
259
+ data: Raw HTTP response bytes
260
+
261
+ Returns:
262
+ HttpResponse object
263
+
264
+ Raises:
265
+ HttpParserError: If response is malformed
266
+ """
267
+ if not data:
268
+ raise HttpParserError("Incomplete response")
269
+
270
+ callbacks = ResponseCallbacks()
271
+ parser = HttpResponseParser(callbacks)
272
+
273
+ try:
274
+ parser.feed_data(data) # pyright: ignore[reportUnknownMemberType]
275
+
276
+ except HttptoolsParserError as e:
277
+ raise HttpParserError(str(e)) from e
278
+
279
+ status = parser.get_status_code()
280
+ if not status:
281
+ raise HttpParserError("Incomplete response")
282
+
283
+ version = parser.get_http_version()
284
+ return HttpResponse(
285
+ version=f"HTTP/{version}" if version else "HTTP/1.1",
286
+ status=status,
287
+ reason=callbacks.reason.decode("utf-8") if callbacks.reason else None,
288
+ headers={
289
+ k.decode("utf-8").lower(): v.decode("utf-8")
290
+ for k, v in callbacks.headers.items()
291
+ },
292
+ body=b"".join(callbacks.body_chunks) if callbacks.body_chunks else None,
293
+ )
294
+
295
+ @override
296
+ def __repr__(self) -> str:
297
+ body_len = len(self.body) if self.body else 0
298
+ return (
299
+ f"HttpResponse(status={self.status}, reason={self.reason!r}, "
300
+ f"version={self.version!r}, body_len={body_len})"
301
+ )
302
+
303
+ @override
304
+ def __eq__(self, other: object) -> bool:
305
+ if not isinstance(other, HttpResponse):
306
+ return NotImplemented
307
+ return (
308
+ self.version == other.version
309
+ and self.status == other.status
310
+ and self.reason == other.reason
311
+ and self.headers == other.headers
312
+ and self.body == other.body
313
+ )
314
+
315
+ def __bytes__(self) -> bytes:
316
+ status_line = (f"{self.version} {self.status} {self.reason}").encode() + b"\r\n"
317
+ headers = dict(self.headers)
318
+ body = self.body
319
+ if body is not None:
320
+ if headers.get("Transfer-Encoding", "").lower() == "chunked":
321
+ body = encode_chunked(body)
322
+ headers["Transfer-Encoding"] = "chunked"
323
+ _ = headers.pop("Content-Length", None)
324
+
325
+ elif "Content-Length" not in headers:
326
+ headers["Content-Length"] = str(len(body))
327
+
328
+ header_bytes = (
329
+ b"\r\n".join([f"{k}: {v}".encode() for k, v in headers.items()])
330
+ + b"\r\n"
331
+ + b"\r\n"
332
+ )
333
+ if body is None:
334
+ return status_line + header_bytes
335
+
336
+ return status_line + header_bytes + body
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: rnhttp
3
+ Version: 0.0.1
4
+ Summary: HTTP/1.1 over Reticulum Network Stack
5
+ Author-email: Eeems <eeems@eeems.email>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: rns>=1.1.0
21
+ Requires-Dist: httptools>=0.7.1
22
+ Requires-Dist: overrides==7.7.0; python_version < "3.12"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
25
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Requires-Dist: basedpyright; extra == "dev"
28
+ Requires-Dist: vulture; extra == "dev"
29
+ Requires-Dist: dodgy; extra == "dev"
30
+ Requires-Dist: pyroma; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ rnhttp
34
+ ======
35
+
36
+ Library for HTTP/1.1 over Reticulum. It provides both a server and client library as well as an example server. You can run `python -m rnhttp.client` to make web requests against an arbirary server. The servers have a concept of the port they are hosting on as well. This will allow building applications over RNS that use the HTTP stack without having to build any sort of netowrk proxying.
@@ -0,0 +1,11 @@
1
+ rnhttp/__init__.py,sha256=OkVTVa6Gzss84w1oMytfQrKpK3e_76XWsyg0MpLCzT0,338
2
+ rnhttp/__whitelist.py,sha256=2cIzViibk4ixv9IX-qqz8rp-GOkvG4YICS18ZTuiZ50,1093
3
+ rnhttp/_compat.py,sha256=8wBuTnFCiW0hj9tEZHds3Dcv33DRwQX5uaGIRkIaJv4,708
4
+ rnhttp/client.py,sha256=SWAvaqcoQl5BWq-CuVTtZLyC1w77dpNDE7OPd-emHUU,10914
5
+ rnhttp/server.py,sha256=LwrnW_YqgFlb4RdqcQNDjAsUsJZpGKB7ytKoKzSbYnA,10106
6
+ rnhttp/types.py,sha256=zsnf0OKiHFZjCbd8hfHoPcIc4T7Fm6jJFlVu9tLAaDI,9758
7
+ rnhttp-0.0.1.dist-info/licenses/LICENSE,sha256=KYPeRZiMx0-RtlpktedIUotMEOaCVwCrDyMjoltMR0A,1085
8
+ rnhttp-0.0.1.dist-info/METADATA,sha256=bHTlrYbv3bYZ8RZCWuoZNC0VD7r8gcxrThxbLrXXmHA,1583
9
+ rnhttp-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ rnhttp-0.0.1.dist-info/top_level.txt,sha256=iHiIPAxfH20gf73yOIEly96aecVVFFNXOYAgKwNEPR4,7
11
+ rnhttp-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathaniel "Eeems" van Diepen
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.
@@ -0,0 +1 @@
1
+ rnhttp