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.
Files changed (34) hide show
  1. relst-1.1.0/PKG-INFO +228 -0
  2. relst-1.1.0/README.md +187 -0
  3. relst-1.1.0/pyproject.toml +3 -0
  4. relst-1.1.0/relst/__init__.py +3 -0
  5. relst-1.1.0/relst/client/__init__.py +3 -0
  6. relst-1.1.0/relst/client/relst_client.py +100 -0
  7. relst-1.1.0/relst/client/relst_flask_client.py +32 -0
  8. relst-1.1.0/relst/client/relst_socket_client.py +61 -0
  9. relst-1.1.0/relst/client/relst_ui.py +22 -0
  10. relst-1.1.0/relst/protocol/__init__.py +38 -0
  11. relst-1.1.0/relst/protocol/binary_codec.py +486 -0
  12. relst-1.1.0/relst/protocol/codec.py +45 -0
  13. relst-1.1.0/relst/protocol/errors.py +11 -0
  14. relst-1.1.0/relst/protocol/json_codec.py +119 -0
  15. relst-1.1.0/relst/protocol/values.py +125 -0
  16. relst-1.1.0/relst/protocol/xml_codec.py +243 -0
  17. relst-1.1.0/relst/server/__init__.py +4 -0
  18. relst-1.1.0/relst/server/binary_server.py +106 -0
  19. relst-1.1.0/relst/server/name_generator.py +24 -0
  20. relst-1.1.0/relst/server/relst_server.py +245 -0
  21. relst-1.1.0/relst/server/textual_server.py +185 -0
  22. relst-1.1.0/relst/server/tracing.py +44 -0
  23. relst-1.1.0/relst/server/ui/index.html +239 -0
  24. relst-1.1.0/relst.egg-info/PKG-INFO +228 -0
  25. relst-1.1.0/relst.egg-info/SOURCES.txt +32 -0
  26. relst-1.1.0/relst.egg-info/dependency_links.txt +1 -0
  27. relst-1.1.0/relst.egg-info/requires.txt +13 -0
  28. relst-1.1.0/relst.egg-info/top_level.txt +1 -0
  29. relst-1.1.0/setup.cfg +4 -0
  30. relst-1.1.0/setup.py +40 -0
  31. relst-1.1.0/tests/test_codecs.py +383 -0
  32. relst-1.1.0/tests/test_http_server.py +236 -0
  33. relst-1.1.0/tests/test_tcp_server.py +190 -0
  34. 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
+ [![PyPI](https://img.shields.io/pypi/v/relst)](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
+ [![PyPI](https://img.shields.io/pypi/v/relst)](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,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,3 @@
1
+ from . import protocol
2
+ from . import server
3
+ from . import client
@@ -0,0 +1,3 @@
1
+ from .relst_client import RelstClient
2
+ from .relst_flask_client import RelstFlaskClient
3
+ from .relst_socket_client import RelstSocketClient
@@ -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}')