relst 1.1.0__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.
- relst-1.1.0/PKG-INFO +228 -0
- relst-1.1.0/README.md +187 -0
- relst-1.1.0/pyproject.toml +3 -0
- relst-1.1.0/relst/__init__.py +3 -0
- relst-1.1.0/relst/client/__init__.py +3 -0
- relst-1.1.0/relst/client/relst_client.py +100 -0
- relst-1.1.0/relst/client/relst_flask_client.py +32 -0
- relst-1.1.0/relst/client/relst_socket_client.py +61 -0
- relst-1.1.0/relst/client/relst_ui.py +22 -0
- relst-1.1.0/relst/protocol/__init__.py +38 -0
- relst-1.1.0/relst/protocol/binary_codec.py +486 -0
- relst-1.1.0/relst/protocol/codec.py +45 -0
- relst-1.1.0/relst/protocol/errors.py +11 -0
- relst-1.1.0/relst/protocol/json_codec.py +119 -0
- relst-1.1.0/relst/protocol/values.py +125 -0
- relst-1.1.0/relst/protocol/xml_codec.py +243 -0
- relst-1.1.0/relst/server/__init__.py +4 -0
- relst-1.1.0/relst/server/binary_server.py +106 -0
- relst-1.1.0/relst/server/name_generator.py +24 -0
- relst-1.1.0/relst/server/relst_server.py +245 -0
- relst-1.1.0/relst/server/textual_server.py +185 -0
- relst-1.1.0/relst/server/tracing.py +44 -0
- relst-1.1.0/relst/server/ui/index.html +239 -0
- relst-1.1.0/relst.egg-info/PKG-INFO +228 -0
- relst-1.1.0/relst.egg-info/SOURCES.txt +32 -0
- relst-1.1.0/relst.egg-info/dependency_links.txt +1 -0
- relst-1.1.0/relst.egg-info/requires.txt +13 -0
- relst-1.1.0/relst.egg-info/top_level.txt +1 -0
- relst-1.1.0/setup.cfg +4 -0
- relst-1.1.0/setup.py +40 -0
- relst-1.1.0/tests/test_codecs.py +383 -0
- relst-1.1.0/tests/test_http_server.py +236 -0
- relst-1.1.0/tests/test_tcp_server.py +190 -0
- relst-1.1.0/tests/test_ui_and_traces.py +173 -0
relst-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: relst
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Expose any python object as an API: relst server and client over json, xml and a compact binary protocol
|
|
5
|
+
Home-page: https://pypi.org/project/relst/
|
|
6
|
+
Author: Ariel Tubul
|
|
7
|
+
Author-email: avarti765@gmail.com
|
|
8
|
+
Project-URL: Source, https://github.com/mon231/relst
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: flask
|
|
20
|
+
Requires-Dist: waitress
|
|
21
|
+
Requires-Dist: requests
|
|
22
|
+
Requires-Dist: firstclass
|
|
23
|
+
Provides-Extra: gui
|
|
24
|
+
Requires-Dist: eel; extra == "gui"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Requires-Dist: hypothesis; extra == "dev"
|
|
28
|
+
Requires-Dist: build; extra == "dev"
|
|
29
|
+
Requires-Dist: twine; extra == "dev"
|
|
30
|
+
Dynamic: author
|
|
31
|
+
Dynamic: author-email
|
|
32
|
+
Dynamic: classifier
|
|
33
|
+
Dynamic: description
|
|
34
|
+
Dynamic: description-content-type
|
|
35
|
+
Dynamic: home-page
|
|
36
|
+
Dynamic: project-url
|
|
37
|
+
Dynamic: provides-extra
|
|
38
|
+
Dynamic: requires-dist
|
|
39
|
+
Dynamic: requires-python
|
|
40
|
+
Dynamic: summary
|
|
41
|
+
|
|
42
|
+
# relst
|
|
43
|
+
|
|
44
|
+
Expose any python object as an API, and call it from python as if it were local.
|
|
45
|
+
One server, three wire formats: **json**, **xml** and a very small **binary** protocol,
|
|
46
|
+
plus a swagger-like console to trace and hand-craft requests.
|
|
47
|
+
|
|
48
|
+
[](https://pypi.org/project/relst/)
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install relst
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Python 3.10+. Optional desktop client extras: `pip install "relst[gui]"`.
|
|
57
|
+
Source code: <https://github.com/mon231/relst>
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
Server:
|
|
62
|
+
|
|
63
|
+
```py
|
|
64
|
+
from relst.server import RelstTextualServer
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Calculator:
|
|
68
|
+
"""Server's help string"""
|
|
69
|
+
|
|
70
|
+
def add(self, a: int, b: int = 10) -> int:
|
|
71
|
+
"Help string of this method"
|
|
72
|
+
return a + b
|
|
73
|
+
|
|
74
|
+
async def shout(self, text: str) -> str:
|
|
75
|
+
return text.upper()
|
|
76
|
+
|
|
77
|
+
def _not_exposed(self):
|
|
78
|
+
return 'MySecretCode'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
RelstTextualServer(1736, Calculator()).serve()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Client:
|
|
85
|
+
|
|
86
|
+
```py
|
|
87
|
+
from relst.client import RelstFlaskClient
|
|
88
|
+
|
|
89
|
+
with RelstFlaskClient('127.0.0.1', 1736, protocol='binary') as client: # json | xml | binary
|
|
90
|
+
print(client.add(1, b=2)) # 3, server methods became python methods
|
|
91
|
+
print(client.shout('hi')) # 'HI'
|
|
92
|
+
print(client.METHOD_HELP('add'))
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Open <http://127.0.0.1:1736/_relst/ui> for the console. More in [`examples/`](examples/README.md).
|
|
96
|
+
|
|
97
|
+
## What gets exposed
|
|
98
|
+
|
|
99
|
+
Every public (not starting with `_`) method of the handler object: instance methods, `staticmethod`,
|
|
100
|
+
`classmethod`, and `async def` coroutines. Arguments are keyword arguments, validated against the
|
|
101
|
+
method signature before the call (`400` on mismatch). Type hints are used to rebuild values:
|
|
102
|
+
`float` accepts ints, `bytes` accepts hex strings, and `firstclass` / dataclass hints accept dicts.
|
|
103
|
+
|
|
104
|
+
Return values may be `None`, `bool`, `int`, `float`, `str`, `bytes`, lists/tuples/sets, dicts,
|
|
105
|
+
enums, dataclasses and [`firstclass`](https://pypi.org/project/firstclass/) objects.
|
|
106
|
+
|
|
107
|
+
Failures reach the client as `RelstRemoteError(code, message)`:
|
|
108
|
+
|
|
109
|
+
| code | meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| 400 | malformed payload, bad / missing arguments, bad value for a `firstclass` argument |
|
|
112
|
+
| 404 | unknown (or private) method |
|
|
113
|
+
| 413 | request bigger than 1 MiB (http) |
|
|
114
|
+
| 415 | unsupported `Content-Type` |
|
|
115
|
+
| 500 | the method raised, the message is generic, details stay in the server log |
|
|
116
|
+
|
|
117
|
+
Built-in methods (`__get_class_name`, `__get_class_help`, `__get_methods_list`, `__get_method_help`,
|
|
118
|
+
`__update_server`) power the client's `CLASS_NAME()`, `CLASS_HELP()`, `METHOD_HELP(name)`,
|
|
119
|
+
`UPDATE_SERVER()` and `UPDATE_METHODS()`. Uppercase client names are reserved, a server method can
|
|
120
|
+
never shadow them.
|
|
121
|
+
|
|
122
|
+
## Servers and clients
|
|
123
|
+
|
|
124
|
+
| class | transport | protocols |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `RelstTextualServer(port, handler, host='127.0.0.1', enable_ui=True, trace_limit=500)` | http (waitress + flask) | json, xml, binary by `Content-Type` |
|
|
127
|
+
| `RelstBinaryServer(port, handler, host='127.0.0.1')` | raw tcp, connections are reused | binary |
|
|
128
|
+
| `RelstFlaskClient(address, port, is_https=False, protocol='json')` | http | json, xml, binary |
|
|
129
|
+
| `RelstSocketClient(address, port)` | tcp (thread-safe, auto-reconnect) | binary |
|
|
130
|
+
|
|
131
|
+
Servers bind to `127.0.0.1` by default; pass `host='0.0.0.0'` to expose them. `serve()` blocks,
|
|
132
|
+
`start()` / `stop()` / `with server:` run in the background (`port=0` picks a free port, read it
|
|
133
|
+
from `server.port`).
|
|
134
|
+
|
|
135
|
+
## Protocols
|
|
136
|
+
|
|
137
|
+
The protocol of an http request is picked by `Content-Type` (default json), the response uses the
|
|
138
|
+
same protocol unless `Accept` names another one. The method name is the url path: `POST /add`.
|
|
139
|
+
|
|
140
|
+
### json (`application/json`)
|
|
141
|
+
|
|
142
|
+
Body is an object of keyword arguments, the response body is the returned value.
|
|
143
|
+
Things json can't carry are wrapped: bytes as `{"$bytes": "00ff"}`, nan/inf as `{"$float": "nan"}`
|
|
144
|
+
(a dict that itself has `$`-keys is wrapped in `{"$dict": ...}`).
|
|
145
|
+
Errors: `{"error": {"code": 404, "message": "..."}}` with the matching http status.
|
|
146
|
+
|
|
147
|
+
### xml (`application/xml`)
|
|
148
|
+
|
|
149
|
+
```xml
|
|
150
|
+
<request method="add">
|
|
151
|
+
<arg name="a"><value type="int">1</value></arg>
|
|
152
|
+
</request>
|
|
153
|
+
<!-- response --> <response><value type="int">11</value></response>
|
|
154
|
+
<!-- error --> <error code="404">Method x does not exist</error>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Value types: `none bool int float str bytes list dict`; `<value>` without a type is a string,
|
|
158
|
+
strings that xml can't carry (control characters, `\r`) travel as `encoding="base64"`.
|
|
159
|
+
DTDs / entities are rejected.
|
|
160
|
+
|
|
161
|
+
### binary (`application/octet-stream`, also raw tcp)
|
|
162
|
+
|
|
163
|
+
Built for minimal traffic: a 1-byte header, varint sizes (unsigned LEB128, uint32 range), no magic,
|
|
164
|
+
no padding.
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
frame = header:1 [name_size:varint name:utf8] payload_size:varint payload
|
|
168
|
+
header = FrameKind:2 bits | version:2 bits | reserved:4 bits (a firstclass bitfield struct)
|
|
169
|
+
FrameKind: REQUEST=0 RESPONSE=1 ERROR=2
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- The `name` part exists only in requests, and is empty (1 byte) over http where the url has the method.
|
|
173
|
+
- An empty payload is `{}` in a request and `None` in a response.
|
|
174
|
+
- An error payload is the value `[code, message]`.
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
value (first byte)
|
|
178
|
+
1xxxxxxx small uint 0..127 (1 byte total)
|
|
179
|
+
01xxxxxx str, 0..63 utf8 bytes follow
|
|
180
|
+
001xxxxx list, 0..31 elements follow
|
|
181
|
+
0001xxxx dict, 0..15 key/value pairs follow
|
|
182
|
+
0000xxxx ValueTag: NONE=0 FALSE=1 TRUE=2 INT=3 FLOAT32=4 FLOAT64=5 STR=6 BYTES=7 LIST=8 DICT=9
|
|
183
|
+
INT is a zigzag varint (any size), STR/BYTES/LIST/DICT carry a varint size or count,
|
|
184
|
+
floats are 4 bytes when that is lossless, else 8 (big-endian)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The same call `{"prefix": "pre", "text": "txt", "count": 3, "flag": true, "blob": b"\x00\x01"}`:
|
|
188
|
+
json 86 bytes, xml 324 bytes, binary 46 bytes (`python examples/wire_formats.py`).
|
|
189
|
+
|
|
190
|
+
Safety limits (all protocols): nesting depth 64, 1 MiB per http body / tcp frame, forged lengths
|
|
191
|
+
and counts are rejected before allocating, bad input is always a `400` / `RelstProtocolError`,
|
|
192
|
+
never a crash.
|
|
193
|
+
|
|
194
|
+
## Console (swagger-like UI)
|
|
195
|
+
|
|
196
|
+
`http://<host>:<port>/_relst/ui` on every `RelstTextualServer`:
|
|
197
|
+
|
|
198
|
+
- **Methods**: signatures, docstrings, a generated form per method; run it in json, xml or binary.
|
|
199
|
+
- **Generate request**: shows the raw request (text, or hex for binary) without sending it.
|
|
200
|
+
Edit the bytes freely and **Send raw** to test malformed or hand-written payloads.
|
|
201
|
+
- **Traces**: live table of everything the server handled (http, tcp and console calls) with raw
|
|
202
|
+
request/response, status and duration. `GET /_relst/traces?after=<id>` returns the same as json,
|
|
203
|
+
`DELETE /_relst/traces` clears it. `RelstBinaryServer.traces` is available in code for tcp.
|
|
204
|
+
|
|
205
|
+
Disable with `enable_ui=False`. Executing methods from the console needs a json POST (so
|
|
206
|
+
other websites can't trigger calls through your browser), keep the default `127.0.0.1` bind
|
|
207
|
+
unless you trust the network.
|
|
208
|
+
|
|
209
|
+
## Development
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
git clone https://github.com/mon231/relst
|
|
213
|
+
cd relst
|
|
214
|
+
pip install -e ".[dev]"
|
|
215
|
+
python -m pytest tests
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The suite (250+ tests) covers property-based round trips of every protocol, fuzzed / mutated /
|
|
219
|
+
hostile payloads (XML entity bombs, deep nesting, huge ints, forged binary lengths), http and tcp
|
|
220
|
+
end-to-end behavior, concurrency, server restarts and the console endpoints.
|
|
221
|
+
|
|
222
|
+
Publishing:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
python -m build
|
|
226
|
+
python -m twine check dist/*
|
|
227
|
+
python -m twine upload dist/*
|
|
228
|
+
```
|
relst-1.1.0/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# relst
|
|
2
|
+
|
|
3
|
+
Expose any python object as an API, and call it from python as if it were local.
|
|
4
|
+
One server, three wire formats: **json**, **xml** and a very small **binary** protocol,
|
|
5
|
+
plus a swagger-like console to trace and hand-craft requests.
|
|
6
|
+
|
|
7
|
+
[](https://pypi.org/project/relst/)
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install relst
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.10+. Optional desktop client extras: `pip install "relst[gui]"`.
|
|
16
|
+
Source code: <https://github.com/mon231/relst>
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
Server:
|
|
21
|
+
|
|
22
|
+
```py
|
|
23
|
+
from relst.server import RelstTextualServer
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Calculator:
|
|
27
|
+
"""Server's help string"""
|
|
28
|
+
|
|
29
|
+
def add(self, a: int, b: int = 10) -> int:
|
|
30
|
+
"Help string of this method"
|
|
31
|
+
return a + b
|
|
32
|
+
|
|
33
|
+
async def shout(self, text: str) -> str:
|
|
34
|
+
return text.upper()
|
|
35
|
+
|
|
36
|
+
def _not_exposed(self):
|
|
37
|
+
return 'MySecretCode'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
RelstTextualServer(1736, Calculator()).serve()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Client:
|
|
44
|
+
|
|
45
|
+
```py
|
|
46
|
+
from relst.client import RelstFlaskClient
|
|
47
|
+
|
|
48
|
+
with RelstFlaskClient('127.0.0.1', 1736, protocol='binary') as client: # json | xml | binary
|
|
49
|
+
print(client.add(1, b=2)) # 3, server methods became python methods
|
|
50
|
+
print(client.shout('hi')) # 'HI'
|
|
51
|
+
print(client.METHOD_HELP('add'))
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Open <http://127.0.0.1:1736/_relst/ui> for the console. More in [`examples/`](examples/README.md).
|
|
55
|
+
|
|
56
|
+
## What gets exposed
|
|
57
|
+
|
|
58
|
+
Every public (not starting with `_`) method of the handler object: instance methods, `staticmethod`,
|
|
59
|
+
`classmethod`, and `async def` coroutines. Arguments are keyword arguments, validated against the
|
|
60
|
+
method signature before the call (`400` on mismatch). Type hints are used to rebuild values:
|
|
61
|
+
`float` accepts ints, `bytes` accepts hex strings, and `firstclass` / dataclass hints accept dicts.
|
|
62
|
+
|
|
63
|
+
Return values may be `None`, `bool`, `int`, `float`, `str`, `bytes`, lists/tuples/sets, dicts,
|
|
64
|
+
enums, dataclasses and [`firstclass`](https://pypi.org/project/firstclass/) objects.
|
|
65
|
+
|
|
66
|
+
Failures reach the client as `RelstRemoteError(code, message)`:
|
|
67
|
+
|
|
68
|
+
| code | meaning |
|
|
69
|
+
|---|---|
|
|
70
|
+
| 400 | malformed payload, bad / missing arguments, bad value for a `firstclass` argument |
|
|
71
|
+
| 404 | unknown (or private) method |
|
|
72
|
+
| 413 | request bigger than 1 MiB (http) |
|
|
73
|
+
| 415 | unsupported `Content-Type` |
|
|
74
|
+
| 500 | the method raised, the message is generic, details stay in the server log |
|
|
75
|
+
|
|
76
|
+
Built-in methods (`__get_class_name`, `__get_class_help`, `__get_methods_list`, `__get_method_help`,
|
|
77
|
+
`__update_server`) power the client's `CLASS_NAME()`, `CLASS_HELP()`, `METHOD_HELP(name)`,
|
|
78
|
+
`UPDATE_SERVER()` and `UPDATE_METHODS()`. Uppercase client names are reserved, a server method can
|
|
79
|
+
never shadow them.
|
|
80
|
+
|
|
81
|
+
## Servers and clients
|
|
82
|
+
|
|
83
|
+
| class | transport | protocols |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `RelstTextualServer(port, handler, host='127.0.0.1', enable_ui=True, trace_limit=500)` | http (waitress + flask) | json, xml, binary by `Content-Type` |
|
|
86
|
+
| `RelstBinaryServer(port, handler, host='127.0.0.1')` | raw tcp, connections are reused | binary |
|
|
87
|
+
| `RelstFlaskClient(address, port, is_https=False, protocol='json')` | http | json, xml, binary |
|
|
88
|
+
| `RelstSocketClient(address, port)` | tcp (thread-safe, auto-reconnect) | binary |
|
|
89
|
+
|
|
90
|
+
Servers bind to `127.0.0.1` by default; pass `host='0.0.0.0'` to expose them. `serve()` blocks,
|
|
91
|
+
`start()` / `stop()` / `with server:` run in the background (`port=0` picks a free port, read it
|
|
92
|
+
from `server.port`).
|
|
93
|
+
|
|
94
|
+
## Protocols
|
|
95
|
+
|
|
96
|
+
The protocol of an http request is picked by `Content-Type` (default json), the response uses the
|
|
97
|
+
same protocol unless `Accept` names another one. The method name is the url path: `POST /add`.
|
|
98
|
+
|
|
99
|
+
### json (`application/json`)
|
|
100
|
+
|
|
101
|
+
Body is an object of keyword arguments, the response body is the returned value.
|
|
102
|
+
Things json can't carry are wrapped: bytes as `{"$bytes": "00ff"}`, nan/inf as `{"$float": "nan"}`
|
|
103
|
+
(a dict that itself has `$`-keys is wrapped in `{"$dict": ...}`).
|
|
104
|
+
Errors: `{"error": {"code": 404, "message": "..."}}` with the matching http status.
|
|
105
|
+
|
|
106
|
+
### xml (`application/xml`)
|
|
107
|
+
|
|
108
|
+
```xml
|
|
109
|
+
<request method="add">
|
|
110
|
+
<arg name="a"><value type="int">1</value></arg>
|
|
111
|
+
</request>
|
|
112
|
+
<!-- response --> <response><value type="int">11</value></response>
|
|
113
|
+
<!-- error --> <error code="404">Method x does not exist</error>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Value types: `none bool int float str bytes list dict`; `<value>` without a type is a string,
|
|
117
|
+
strings that xml can't carry (control characters, `\r`) travel as `encoding="base64"`.
|
|
118
|
+
DTDs / entities are rejected.
|
|
119
|
+
|
|
120
|
+
### binary (`application/octet-stream`, also raw tcp)
|
|
121
|
+
|
|
122
|
+
Built for minimal traffic: a 1-byte header, varint sizes (unsigned LEB128, uint32 range), no magic,
|
|
123
|
+
no padding.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
frame = header:1 [name_size:varint name:utf8] payload_size:varint payload
|
|
127
|
+
header = FrameKind:2 bits | version:2 bits | reserved:4 bits (a firstclass bitfield struct)
|
|
128
|
+
FrameKind: REQUEST=0 RESPONSE=1 ERROR=2
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- The `name` part exists only in requests, and is empty (1 byte) over http where the url has the method.
|
|
132
|
+
- An empty payload is `{}` in a request and `None` in a response.
|
|
133
|
+
- An error payload is the value `[code, message]`.
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
value (first byte)
|
|
137
|
+
1xxxxxxx small uint 0..127 (1 byte total)
|
|
138
|
+
01xxxxxx str, 0..63 utf8 bytes follow
|
|
139
|
+
001xxxxx list, 0..31 elements follow
|
|
140
|
+
0001xxxx dict, 0..15 key/value pairs follow
|
|
141
|
+
0000xxxx ValueTag: NONE=0 FALSE=1 TRUE=2 INT=3 FLOAT32=4 FLOAT64=5 STR=6 BYTES=7 LIST=8 DICT=9
|
|
142
|
+
INT is a zigzag varint (any size), STR/BYTES/LIST/DICT carry a varint size or count,
|
|
143
|
+
floats are 4 bytes when that is lossless, else 8 (big-endian)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The same call `{"prefix": "pre", "text": "txt", "count": 3, "flag": true, "blob": b"\x00\x01"}`:
|
|
147
|
+
json 86 bytes, xml 324 bytes, binary 46 bytes (`python examples/wire_formats.py`).
|
|
148
|
+
|
|
149
|
+
Safety limits (all protocols): nesting depth 64, 1 MiB per http body / tcp frame, forged lengths
|
|
150
|
+
and counts are rejected before allocating, bad input is always a `400` / `RelstProtocolError`,
|
|
151
|
+
never a crash.
|
|
152
|
+
|
|
153
|
+
## Console (swagger-like UI)
|
|
154
|
+
|
|
155
|
+
`http://<host>:<port>/_relst/ui` on every `RelstTextualServer`:
|
|
156
|
+
|
|
157
|
+
- **Methods**: signatures, docstrings, a generated form per method; run it in json, xml or binary.
|
|
158
|
+
- **Generate request**: shows the raw request (text, or hex for binary) without sending it.
|
|
159
|
+
Edit the bytes freely and **Send raw** to test malformed or hand-written payloads.
|
|
160
|
+
- **Traces**: live table of everything the server handled (http, tcp and console calls) with raw
|
|
161
|
+
request/response, status and duration. `GET /_relst/traces?after=<id>` returns the same as json,
|
|
162
|
+
`DELETE /_relst/traces` clears it. `RelstBinaryServer.traces` is available in code for tcp.
|
|
163
|
+
|
|
164
|
+
Disable with `enable_ui=False`. Executing methods from the console needs a json POST (so
|
|
165
|
+
other websites can't trigger calls through your browser), keep the default `127.0.0.1` bind
|
|
166
|
+
unless you trust the network.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
git clone https://github.com/mon231/relst
|
|
172
|
+
cd relst
|
|
173
|
+
pip install -e ".[dev]"
|
|
174
|
+
python -m pytest tests
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The suite (250+ tests) covers property-based round trips of every protocol, fuzzed / mutated /
|
|
178
|
+
hostile payloads (XML entity bombs, deep nesting, huge ints, forged binary lengths), http and tcp
|
|
179
|
+
end-to-end behavior, concurrency, server restarts and the console endpoints.
|
|
180
|
+
|
|
181
|
+
Publishing:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
python -m build
|
|
185
|
+
python -m twine check dist/*
|
|
186
|
+
python -m twine upload dist/*
|
|
187
|
+
```
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from typing import Dict, Set, Tuple
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from ..protocol import Codec, get_codec, DEFAULT_PROTOCOL
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
GET_CLASS_NAME_REQUEST = '__get_class_name'
|
|
7
|
+
GET_CLASS_HELP_REQUEST = '__get_class_help'
|
|
8
|
+
GET_METHODS_LIST_REQUEST = '__get_methods_list'
|
|
9
|
+
GET_METHOD_HELP_REQUEST = '__get_method_help'
|
|
10
|
+
UPDATE_SERVER_REQUEST = '__update_server'
|
|
11
|
+
|
|
12
|
+
PRIVATE_OR_PROTECTED_PREFIX = '_'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RelstClient(ABC):
|
|
16
|
+
"""
|
|
17
|
+
Client of a relst server. After UPDATE_METHODS (or entering a `with` block)
|
|
18
|
+
every public server-method is available as a python method of the client.
|
|
19
|
+
NOTE: Uppercase names are reserved for the client, server methods can't override them
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, protocol: str = DEFAULT_PROTOCOL):
|
|
23
|
+
self._codec: Codec = get_codec(protocol)
|
|
24
|
+
self._dynamic_methods: Set[str] = set()
|
|
25
|
+
|
|
26
|
+
# NOTE: transports that carry the method-name themselves (http url) set this to False
|
|
27
|
+
_embed_method = True
|
|
28
|
+
|
|
29
|
+
def UPDATE_METHODS(self):
|
|
30
|
+
self._remove_methods()
|
|
31
|
+
methods_list = self._handle_request(GET_METHODS_LIST_REQUEST)
|
|
32
|
+
|
|
33
|
+
for method_name, method_description in methods_list.items():
|
|
34
|
+
self._add_method(method_name, method_description)
|
|
35
|
+
|
|
36
|
+
def CLASS_NAME(self) -> str:
|
|
37
|
+
return self._handle_request(GET_CLASS_NAME_REQUEST)
|
|
38
|
+
|
|
39
|
+
def CLASS_HELP(self) -> str:
|
|
40
|
+
return self._handle_request(GET_CLASS_HELP_REQUEST)
|
|
41
|
+
|
|
42
|
+
def METHOD_HELP(self, method_name: str) -> str:
|
|
43
|
+
return self._handle_request(GET_METHOD_HELP_REQUEST, method_name=method_name)
|
|
44
|
+
|
|
45
|
+
def UPDATE_SERVER(self):
|
|
46
|
+
self._handle_request(UPDATE_SERVER_REQUEST)
|
|
47
|
+
|
|
48
|
+
def _handle_request(self, method_name: str, /, **method_args):
|
|
49
|
+
"""
|
|
50
|
+
:except: RelstRemoteError when the server reports an error, RelstProtocolError for bad payloads
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
request = self._codec.encode_request(method_name, method_args, embed_method=self._embed_method)
|
|
54
|
+
status, response = self._send(method_name, request)
|
|
55
|
+
return self._codec.decode_response(response, status)
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def _send(self, method_name: str, request: bytes) -> Tuple[int, bytes]:
|
|
59
|
+
"""transport-level exchange, returns (http-like status, response bytes)"""
|
|
60
|
+
|
|
61
|
+
def _remove_methods(self):
|
|
62
|
+
for method_name in self._dynamic_methods:
|
|
63
|
+
self.__dict__.pop(method_name, None)
|
|
64
|
+
|
|
65
|
+
self._dynamic_methods.clear()
|
|
66
|
+
|
|
67
|
+
def _add_method(self, method_name: str, description: Dict):
|
|
68
|
+
# NOTE: server must not shadow client's own attributes
|
|
69
|
+
if method_name.startswith(PRIVATE_OR_PROTECTED_PREFIX) or hasattr(type(self), method_name):
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
parameters = [param['name'] for param in description.get('params', [])]
|
|
73
|
+
|
|
74
|
+
def method_caller(*args, **kwargs):
|
|
75
|
+
if len(args) > len(parameters):
|
|
76
|
+
raise TypeError(f'{method_name}() takes at most {len(parameters)} positional arguments')
|
|
77
|
+
|
|
78
|
+
for parameter_name, argument in zip(parameters, args):
|
|
79
|
+
if parameter_name in kwargs:
|
|
80
|
+
raise TypeError(f'{method_name}() got multiple values for argument {parameter_name!r}')
|
|
81
|
+
|
|
82
|
+
kwargs[parameter_name] = argument
|
|
83
|
+
|
|
84
|
+
return self._handle_request(method_name, **kwargs)
|
|
85
|
+
|
|
86
|
+
method_caller.__name__ = method_name
|
|
87
|
+
method_caller.__doc__ = description.get('doc')
|
|
88
|
+
|
|
89
|
+
self._dynamic_methods.add(method_name)
|
|
90
|
+
setattr(self, method_name, method_caller)
|
|
91
|
+
|
|
92
|
+
def close(self):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def __enter__(self):
|
|
96
|
+
self.UPDATE_METHODS()
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
100
|
+
self.close()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from .relst_client import RelstClient
|
|
4
|
+
from ..protocol import DEFAULT_PROTOCOL
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RelstFlaskClient(RelstClient):
|
|
8
|
+
"""Client of RelstTextualServer, speaks json / xml / binary over http"""
|
|
9
|
+
|
|
10
|
+
_embed_method = False
|
|
11
|
+
|
|
12
|
+
def __init__(self, server_address: str, server_port: int, is_https: bool = False,
|
|
13
|
+
protocol: str = DEFAULT_PROTOCOL, timeout: float = 30):
|
|
14
|
+
super().__init__(protocol)
|
|
15
|
+
|
|
16
|
+
requests_protocol = "https" if is_https else "http"
|
|
17
|
+
self.__request_prefix = f"{requests_protocol}://{server_address}:{server_port}"
|
|
18
|
+
self.__timeout = timeout
|
|
19
|
+
self.__session = requests.Session()
|
|
20
|
+
|
|
21
|
+
def _send(self, method_name: str, request: bytes) -> Tuple[int, bytes]:
|
|
22
|
+
response = self.__session.post(
|
|
23
|
+
f"{self.__request_prefix}/{method_name}",
|
|
24
|
+
data=request,
|
|
25
|
+
headers={'Content-Type': self._codec.content_type, 'Accept': self._codec.content_type},
|
|
26
|
+
timeout=self.__timeout,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return response.status_code, response.content
|
|
30
|
+
|
|
31
|
+
def close(self):
|
|
32
|
+
self.__session.close()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
from .relst_client import RelstClient
|
|
5
|
+
from ..protocol import Frame, RelstProtocolError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RelstSocketClient(RelstClient):
|
|
9
|
+
"""Client of RelstBinaryServer, keeps one tcp connection (thread-safe, calls are serialized)"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, server_address: str, server_port: int, timeout: float = 30):
|
|
12
|
+
super().__init__('binary')
|
|
13
|
+
|
|
14
|
+
self.__address = (server_address, server_port)
|
|
15
|
+
self.__timeout = timeout
|
|
16
|
+
self.__socket = None
|
|
17
|
+
self.__lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def _send(self, method_name: str, request: bytes) -> Tuple[int, bytes]:
|
|
20
|
+
with self.__lock:
|
|
21
|
+
try:
|
|
22
|
+
connection = self.__connection()
|
|
23
|
+
connection.sendall(request)
|
|
24
|
+
|
|
25
|
+
_, raw_response = Frame.read(lambda size: self.__read(connection, size))
|
|
26
|
+
except (OSError, RelstProtocolError):
|
|
27
|
+
self.__disconnect()
|
|
28
|
+
raise
|
|
29
|
+
|
|
30
|
+
# NOTE: binary frames carry their own kind, status is only informative
|
|
31
|
+
return 200, raw_response
|
|
32
|
+
|
|
33
|
+
def close(self):
|
|
34
|
+
with self.__lock:
|
|
35
|
+
self.__disconnect()
|
|
36
|
+
|
|
37
|
+
def __connection(self) -> socket.socket:
|
|
38
|
+
if self.__socket is None:
|
|
39
|
+
self.__socket = socket.create_connection(self.__address, timeout=self.__timeout)
|
|
40
|
+
|
|
41
|
+
return self.__socket
|
|
42
|
+
|
|
43
|
+
def __disconnect(self):
|
|
44
|
+
if self.__socket is not None:
|
|
45
|
+
self.__socket.close()
|
|
46
|
+
self.__socket = None
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def __read(connection: socket.socket, size: int) -> bytes:
|
|
50
|
+
chunks = []
|
|
51
|
+
|
|
52
|
+
while size > 0:
|
|
53
|
+
chunk = connection.recv(size)
|
|
54
|
+
|
|
55
|
+
if not chunk:
|
|
56
|
+
raise ConnectionError('server closed the connection')
|
|
57
|
+
|
|
58
|
+
chunks.append(chunk)
|
|
59
|
+
size -= len(chunk)
|
|
60
|
+
|
|
61
|
+
return b''.join(chunks)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import eel
|
|
2
|
+
from argparse import ArgumentParser
|
|
3
|
+
from .relst_flask_client import RelstFlaskClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main(args):
|
|
7
|
+
client = RelstFlaskClient(args.server_address, args.server_port)
|
|
8
|
+
|
|
9
|
+
@eel.expose
|
|
10
|
+
def handle_request(function_name, **function_args):
|
|
11
|
+
return client.handle_request(function_name, **function_args)
|
|
12
|
+
|
|
13
|
+
eel.init("frontend")
|
|
14
|
+
eel.start()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
args_parser = ArgumentParser("GUI-Client for relst-api server")
|
|
19
|
+
args_parser.add_argument("server_address", type=str, help='name or ip of relst-api server')
|
|
20
|
+
args_parser.add_argument("server_port", type=int, help='port of relst-api server')
|
|
21
|
+
|
|
22
|
+
main(args_parser.parse_args())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from .codec import Codec
|
|
2
|
+
from .json_codec import JsonCodec
|
|
3
|
+
from .xml_codec import XmlCodec
|
|
4
|
+
from .binary_codec import BinaryCodec, Frame, FrameHeader, FrameKind, ValueTag, FRAME_HEADER_SIZE
|
|
5
|
+
from .errors import RelstProtocolError, RelstRemoteError
|
|
6
|
+
from .values import to_plain
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CODECS = {codec.name: codec for codec in (JsonCodec(), XmlCodec(), BinaryCodec())}
|
|
10
|
+
DEFAULT_PROTOCOL = 'json'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_codec(protocol: str) -> Codec:
|
|
14
|
+
try:
|
|
15
|
+
return CODECS[protocol.lower()]
|
|
16
|
+
except (KeyError, AttributeError):
|
|
17
|
+
raise RelstProtocolError(f'unknown protocol {protocol!r}, expected one of {sorted(CODECS)}') from None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def codec_from_content_type(content_type) -> Codec:
|
|
21
|
+
"""
|
|
22
|
+
:brief: picks codec by http Content-Type (parameters like charset are ignored)
|
|
23
|
+
:except: RelstProtocolError for unsupported types
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
media_type = (content_type or '').split(';')[0].strip().lower()
|
|
27
|
+
|
|
28
|
+
if not media_type:
|
|
29
|
+
return CODECS[DEFAULT_PROTOCOL]
|
|
30
|
+
|
|
31
|
+
for codec in CODECS.values():
|
|
32
|
+
if media_type == codec.content_type:
|
|
33
|
+
return codec
|
|
34
|
+
|
|
35
|
+
if media_type in ('text/xml', 'text/json'):
|
|
36
|
+
return CODECS['xml' if media_type == 'text/xml' else 'json']
|
|
37
|
+
|
|
38
|
+
raise RelstProtocolError(f'unsupported content-type {media_type!r}')
|