rnhttp 0.0.1__tar.gz

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-0.0.1/LICENSE ADDED
@@ -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.
rnhttp-0.0.1/PKG-INFO ADDED
@@ -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.
rnhttp-0.0.1/README.md ADDED
@@ -0,0 +1,4 @@
1
+ rnhttp
2
+ ======
3
+
4
+ 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,96 @@
1
+ [project]
2
+ name = "rnhttp"
3
+ version = "0.0.1"
4
+ description = "HTTP/1.1 over Reticulum Network Stack"
5
+ requires-python = ">=3.10"
6
+ license = "MIT"
7
+ authors = [{name = "Eeems", email="eeems@eeems.email"}]
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Environment :: Console",
11
+ "Intended Audience :: Developers",
12
+ "Operating System :: POSIX :: Linux",
13
+ "Programming Language :: Python :: 3 :: Only",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ ]
20
+ dynamic = ["readme"]
21
+ dependencies = [
22
+ "rns>=1.1.0",
23
+ "httptools>=0.7.1",
24
+ "overrides==7.7.0; python_version<\"3.12\""
25
+ ]
26
+
27
+ [tool.setuptools]
28
+ packages = ["rnhttp"]
29
+
30
+ [tool.setuptools.dynamic]
31
+ readme = {file= ["README.md"], content-type = "text/markdown"}
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=7.0.0",
36
+ "pytest-asyncio>=0.21.0",
37
+ "ruff",
38
+ "basedpyright",
39
+ "vulture",
40
+ "dodgy",
41
+ "pyroma",
42
+ ]
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
46
+ testpaths = ["tests"]
47
+
48
+ [build-system]
49
+ requires = ["setuptools>=70.1", "nuitka>=4.0.6"]
50
+ build-backend = "nuitka.distutils.Build"
51
+
52
+ [tool.ruff]
53
+ exclude = [".venv", "build", "**/__whitelist.py"]
54
+
55
+ [tool.ruff.lint]
56
+ extend-select = [
57
+ "UP",
58
+ "PL",
59
+ "ANN",
60
+ "S",
61
+ ]
62
+ ignore = [
63
+ "PLW0603",
64
+ "PLR2004",
65
+ "PLR0915",
66
+ "PLR0912",
67
+ "PLR0911",
68
+ "PLR6301",
69
+ "PLR0913",
70
+ "PLW1641",
71
+ "S101",
72
+ "S404",
73
+ "S603",
74
+ "S607",
75
+ "ANN401",
76
+ "ANN001",
77
+ "ANN003",
78
+ "ANN201",
79
+ ]
80
+
81
+ [tool.pyright]
82
+ exclude = [".venv", "build", "**/__whitelist.py"]
83
+ reportMissingTypeStubs = false
84
+
85
+ [tool.vulture]
86
+ ignore_names = ["__*", "_*", "_"]
87
+ ignore_decorators = [
88
+ "@app.request",
89
+ "@app.page",
90
+ "@app.file",
91
+ '@pytest.fixture',
92
+ ]
93
+ exclude = [
94
+ ".venv/",
95
+ "build/",
96
+ ]
@@ -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
+ ]
@@ -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)
@@ -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"]
@@ -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())