aspyx-service 0.10.1__py3-none-any.whl → 0.10.3__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.
Potentially problematic release.
This version of aspyx-service might be problematic. Click here for more details.
- aspyx_service/channels.py +78 -43
- aspyx_service/registries.py +25 -12
- aspyx_service/restchannel.py +89 -17
- aspyx_service/serialization.py +64 -61
- aspyx_service/server.py +5 -8
- aspyx_service/service.py +31 -13
- {aspyx_service-0.10.1.dist-info → aspyx_service-0.10.3.dist-info}/METADATA +24 -8
- aspyx_service-0.10.3.dist-info/RECORD +12 -0
- aspyx_service-0.10.1.dist-info/RECORD +0 -12
- {aspyx_service-0.10.1.dist-info → aspyx_service-0.10.3.dist-info}/WHEEL +0 -0
- {aspyx_service-0.10.1.dist-info → aspyx_service-0.10.3.dist-info}/licenses/LICENSE +0 -0
aspyx_service/channels.py
CHANGED
|
@@ -11,8 +11,10 @@ import msgpack
|
|
|
11
11
|
from httpx import Client, AsyncClient
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
|
|
14
|
+
from aspyx.di.configuration import inject_value
|
|
14
15
|
from aspyx.reflection import DynamicProxy, TypeDescriptor
|
|
15
|
-
from .
|
|
16
|
+
from aspyx.threading import ThreadLocal
|
|
17
|
+
from .service import ServiceManager, ServiceCommunicationException
|
|
16
18
|
|
|
17
19
|
from .service import ComponentDescriptor, ChannelInstances, ServiceException, channel, Channel, RemoteServiceException
|
|
18
20
|
from .serialization import get_deserializer
|
|
@@ -23,9 +25,15 @@ class HTTPXChannel(Channel):
|
|
|
23
25
|
"client",
|
|
24
26
|
"async_client",
|
|
25
27
|
"service_names",
|
|
26
|
-
"deserializers"
|
|
28
|
+
"deserializers",
|
|
29
|
+
"timeout"
|
|
27
30
|
]
|
|
28
31
|
|
|
32
|
+
# class properties
|
|
33
|
+
|
|
34
|
+
client_local = ThreadLocal[Client]()
|
|
35
|
+
async_client_local = ThreadLocal[AsyncClient]()
|
|
36
|
+
|
|
29
37
|
# class methods
|
|
30
38
|
|
|
31
39
|
@classmethod
|
|
@@ -58,11 +66,16 @@ class HTTPXChannel(Channel):
|
|
|
58
66
|
def __init__(self):
|
|
59
67
|
super().__init__()
|
|
60
68
|
|
|
61
|
-
self.
|
|
62
|
-
self.async_client: Optional[AsyncClient] = None
|
|
69
|
+
self.timeout = 1000.0
|
|
63
70
|
self.service_names: dict[Type, str] = {}
|
|
64
71
|
self.deserializers: dict[Callable, Callable] = {}
|
|
65
72
|
|
|
73
|
+
# inject
|
|
74
|
+
|
|
75
|
+
@inject_value("http.timeout", default=1000.0)
|
|
76
|
+
def set_timeout(self, timeout: float) -> None:
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
|
|
66
79
|
# protected
|
|
67
80
|
|
|
68
81
|
def get_deserializer(self, type: Type, method: Callable) -> Type:
|
|
@@ -86,29 +99,30 @@ class HTTPXChannel(Channel):
|
|
|
86
99
|
for service in component_descriptor.services:
|
|
87
100
|
self.service_names[service.type] = service.name
|
|
88
101
|
|
|
89
|
-
# make client
|
|
90
|
-
|
|
91
|
-
self.client = self.make_client()
|
|
92
|
-
self.async_client = self.make_async_client()
|
|
93
|
-
|
|
94
102
|
# public
|
|
95
103
|
|
|
96
104
|
def get_client(self) -> Client:
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
client = self.client_local.get()
|
|
106
|
+
|
|
107
|
+
if client is None:
|
|
108
|
+
client = self.make_client()
|
|
109
|
+
self.client_local.set(client)
|
|
99
110
|
|
|
100
|
-
return
|
|
111
|
+
return client
|
|
101
112
|
|
|
102
|
-
def get_async_client(self) ->
|
|
103
|
-
|
|
104
|
-
self.async_client = self.make_async_client()
|
|
113
|
+
def get_async_client(self) -> AsyncClient:
|
|
114
|
+
async_client = self.async_client_local.get()
|
|
105
115
|
|
|
106
|
-
|
|
116
|
+
if async_client is None:
|
|
117
|
+
async_client = self.make_async_client()
|
|
118
|
+
self.async_client_local.set(async_client)
|
|
107
119
|
|
|
108
|
-
|
|
120
|
+
return async_client
|
|
121
|
+
|
|
122
|
+
def make_client(self) -> Client:
|
|
109
123
|
return Client() # base_url=url
|
|
110
124
|
|
|
111
|
-
def make_async_client(self):
|
|
125
|
+
def make_async_client(self) -> AsyncClient:
|
|
112
126
|
return AsyncClient() # base_url=url
|
|
113
127
|
|
|
114
128
|
class Request(BaseModel):
|
|
@@ -122,7 +136,7 @@ class Response(BaseModel):
|
|
|
122
136
|
@channel("dispatch-json")
|
|
123
137
|
class DispatchJSONChannel(HTTPXChannel):
|
|
124
138
|
"""
|
|
125
|
-
A channel that calls a POST on
|
|
139
|
+
A channel that calls a POST on the endpoint `ìnvoke` sending a request body containing information on the
|
|
126
140
|
called component, service and method and the arguments.
|
|
127
141
|
"""
|
|
128
142
|
# constructor
|
|
@@ -144,40 +158,49 @@ class DispatchJSONChannel(HTTPXChannel):
|
|
|
144
158
|
|
|
145
159
|
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
146
160
|
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
147
|
-
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
161
|
+
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
162
|
+
args=invocation.args)
|
|
148
163
|
|
|
149
164
|
dict = self.to_dict(request)
|
|
150
165
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=dict, timeout=self.timeout).json())
|
|
167
|
+
if result.exception is not None:
|
|
168
|
+
raise RemoteServiceException(f"server side exception {result.exception}")
|
|
169
|
+
|
|
170
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.result)
|
|
171
|
+
except ServiceCommunicationException:
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
except RemoteServiceException:
|
|
175
|
+
raise
|
|
176
|
+
|
|
159
177
|
except Exception as e:
|
|
160
|
-
raise
|
|
178
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
|
179
|
+
|
|
161
180
|
|
|
162
181
|
async def invoke_async(self, invocation: DynamicProxy.Invocation):
|
|
163
182
|
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
164
183
|
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
165
184
|
args=invocation.args)
|
|
166
|
-
|
|
167
185
|
dict = self.to_dict(request)
|
|
168
186
|
try:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
data = await self.get_async_client().post(f"{self.get_url()}/invoke", json=dict, timeout=self.timeout)
|
|
188
|
+
result = Response(**data.json())
|
|
189
|
+
|
|
190
|
+
if result.exception is not None:
|
|
191
|
+
raise RemoteServiceException(f"server side exception {result.exception}")
|
|
192
|
+
|
|
193
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.result)
|
|
194
|
+
|
|
195
|
+
except ServiceCommunicationException:
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
except RemoteServiceException:
|
|
199
|
+
raise
|
|
200
|
+
|
|
179
201
|
except Exception as e:
|
|
180
|
-
raise
|
|
202
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
|
203
|
+
|
|
181
204
|
|
|
182
205
|
@channel("dispatch-msgpack")
|
|
183
206
|
class DispatchMSPackChannel(HTTPXChannel):
|
|
@@ -208,7 +231,7 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
208
231
|
f"{self.get_url()}/invoke",
|
|
209
232
|
content=packed,
|
|
210
233
|
headers={"Content-Type": "application/msgpack"},
|
|
211
|
-
timeout=
|
|
234
|
+
timeout=self.timeout
|
|
212
235
|
)
|
|
213
236
|
|
|
214
237
|
result = msgpack.unpackb(response.content, raw=False)
|
|
@@ -218,6 +241,12 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
218
241
|
|
|
219
242
|
return self.get_deserializer(invocation.type, invocation.method)(result["result"])
|
|
220
243
|
|
|
244
|
+
except ServiceCommunicationException:
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
except RemoteServiceException:
|
|
248
|
+
raise
|
|
249
|
+
|
|
221
250
|
except Exception as e:
|
|
222
251
|
raise ServiceException(f"msgpack exception: {e}") from e
|
|
223
252
|
|
|
@@ -233,7 +262,7 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
233
262
|
f"{self.get_url()}/invoke",
|
|
234
263
|
content=packed,
|
|
235
264
|
headers={"Content-Type": "application/msgpack"},
|
|
236
|
-
timeout=
|
|
265
|
+
timeout=self.timeout
|
|
237
266
|
)
|
|
238
267
|
|
|
239
268
|
result = msgpack.unpackb(response.content, raw=False)
|
|
@@ -243,5 +272,11 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
243
272
|
|
|
244
273
|
return self.get_deserializer(invocation.type, invocation.method)(result["result"])
|
|
245
274
|
|
|
275
|
+
except ServiceCommunicationException:
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
except RemoteServiceException:
|
|
279
|
+
raise
|
|
280
|
+
|
|
246
281
|
except Exception as e:
|
|
247
282
|
raise ServiceException(f"msgpack exception: {e}") from e
|
aspyx_service/registries.py
CHANGED
|
@@ -6,7 +6,6 @@ import threading
|
|
|
6
6
|
from abc import abstractmethod
|
|
7
7
|
import time
|
|
8
8
|
from typing import Optional
|
|
9
|
-
from urllib.parse import urlparse
|
|
10
9
|
|
|
11
10
|
import consul
|
|
12
11
|
|
|
@@ -36,6 +35,9 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
36
35
|
self.component_addresses : dict[str, dict[str, ChannelInstances]] = {} # comp -> channel -> address
|
|
37
36
|
self.watch_channels : list[Channel] = []
|
|
38
37
|
self.watchdog_interval = 5
|
|
38
|
+
self.healthcheck_interval = "10s"
|
|
39
|
+
self.healthcheck_timeout= "5s"
|
|
40
|
+
self.healthcheck_deregister = "5m"
|
|
39
41
|
|
|
40
42
|
# injections
|
|
41
43
|
|
|
@@ -43,6 +45,18 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
43
45
|
def set_interval(self, interval):
|
|
44
46
|
self.watchdog_interval = interval
|
|
45
47
|
|
|
48
|
+
@inject_value("consul.healthcheck.interval", default="10s")
|
|
49
|
+
def set_interval(self, interval):
|
|
50
|
+
self.healthcheck_interval = interval
|
|
51
|
+
|
|
52
|
+
@inject_value("consul.healthcheck.timeout", default="3s")
|
|
53
|
+
def set_interval(self, interval):
|
|
54
|
+
self.healthcheck_timeout = interval
|
|
55
|
+
|
|
56
|
+
@inject_value("consul.healthcheck.deregister", default="5m")
|
|
57
|
+
def set_interval(self, interval):
|
|
58
|
+
self.healthcheck_deregister = interval
|
|
59
|
+
|
|
46
60
|
# lifecycle hooks
|
|
47
61
|
|
|
48
62
|
@on_init()
|
|
@@ -116,8 +130,6 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
116
130
|
def watch(self, channel: Channel) -> None:
|
|
117
131
|
self.watch_channels.append(channel)
|
|
118
132
|
|
|
119
|
-
#self.component_addresses[channel.component_descriptor.name] = {}
|
|
120
|
-
|
|
121
133
|
# public
|
|
122
134
|
|
|
123
135
|
def register_service(self, name, service_id, health: str, tags=None, meta=None) -> None:
|
|
@@ -130,9 +142,9 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
130
142
|
meta=meta or {},
|
|
131
143
|
check=consul.Check().http(
|
|
132
144
|
url=f"http://{self.ip}:{self.port}{health}",
|
|
133
|
-
interval=
|
|
134
|
-
timeout=
|
|
135
|
-
deregister=
|
|
145
|
+
interval=self.healthcheck_interval,
|
|
146
|
+
timeout=self.healthcheck_timeout,
|
|
147
|
+
deregister=self.healthcheck_deregister)
|
|
136
148
|
)
|
|
137
149
|
|
|
138
150
|
def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
|
|
@@ -213,12 +225,13 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
213
225
|
|
|
214
226
|
return list(component_addresses.values())
|
|
215
227
|
|
|
216
|
-
#200–299 passing Service is healthy (OK, Created, No Content…)
|
|
217
|
-
#429 warning Rate limited or degraded
|
|
218
|
-
#300–399 warning Redirects interpreted as potential issues
|
|
219
|
-
#400–499 critical Client errors (Bad Request, Unauthorized…)
|
|
220
|
-
#500–599 critical Server errors (Internal Error, Timeout…)
|
|
221
|
-
#Other / No response critical Timeout, connection refused, etc.
|
|
228
|
+
# 200–299 passing Service is healthy (OK, Created, No Content…)
|
|
229
|
+
# 429 warning Rate limited or degraded
|
|
230
|
+
# 300–399 warning Redirects interpreted as potential issues
|
|
231
|
+
# 400–499 critical Client errors (Bad Request, Unauthorized…)
|
|
232
|
+
# 500–599 critical Server errors (Internal Error, Timeout…)
|
|
233
|
+
# Other / No response critical Timeout, connection refused, etc.
|
|
234
|
+
|
|
222
235
|
def map_health(self, health: HealthCheckManager.Health) -> int:
|
|
223
236
|
if health.status is HealthStatus.OK:
|
|
224
237
|
return 200
|
aspyx_service/restchannel.py
CHANGED
|
@@ -10,7 +10,7 @@ from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get
|
|
|
10
10
|
from pydantic import BaseModel
|
|
11
11
|
|
|
12
12
|
from aspyx.reflection import DynamicProxy, Decorators
|
|
13
|
-
from .service import
|
|
13
|
+
from .service import channel, ServiceCommunicationException
|
|
14
14
|
|
|
15
15
|
T = TypeVar("T")
|
|
16
16
|
|
|
@@ -134,6 +134,9 @@ class RestChannel(HTTPXChannel):
|
|
|
134
134
|
def __init__(self, type: Type, method : Callable):
|
|
135
135
|
self.signature = inspect.signature(method)
|
|
136
136
|
|
|
137
|
+
param_names = list(self.signature.parameters.keys())
|
|
138
|
+
param_names.remove("self")
|
|
139
|
+
|
|
137
140
|
prefix = ""
|
|
138
141
|
if Decorators.has_decorator(type, rest):
|
|
139
142
|
prefix = Decorators.get_decorator(type, rest).args[0]
|
|
@@ -155,6 +158,9 @@ class RestChannel(HTTPXChannel):
|
|
|
155
158
|
|
|
156
159
|
self.path_param_names = set(re.findall(r"{(.*?)}", self.url_template))
|
|
157
160
|
|
|
161
|
+
for param_name in self.path_param_names:
|
|
162
|
+
param_names.remove(param_name)
|
|
163
|
+
|
|
158
164
|
hints = get_type_hints(method, include_extras=True)
|
|
159
165
|
|
|
160
166
|
self.body_param_name = None
|
|
@@ -163,10 +169,42 @@ class RestChannel(HTTPXChannel):
|
|
|
163
169
|
for param_name, hint in hints.items():
|
|
164
170
|
if get_origin(hint) is Annotated:
|
|
165
171
|
metadata = get_args(hint)[1:]
|
|
172
|
+
|
|
166
173
|
if BodyMarker in metadata:
|
|
167
174
|
self.body_param_name = param_name
|
|
175
|
+
param_names.remove(param_name)
|
|
168
176
|
elif QueryParamMarker in metadata:
|
|
169
177
|
self.query_param_names.add(param_name)
|
|
178
|
+
param_names.remove(param_name)
|
|
179
|
+
|
|
180
|
+
# check if something is missing
|
|
181
|
+
|
|
182
|
+
if len(param_names) > 0:
|
|
183
|
+
# check body params
|
|
184
|
+
if self.type in ("post", "put", "patch"):
|
|
185
|
+
if self.body_param_name is None:
|
|
186
|
+
candidates = [
|
|
187
|
+
(name, hint)
|
|
188
|
+
for name, hint in hints.items()
|
|
189
|
+
if name not in self.path_param_names
|
|
190
|
+
]
|
|
191
|
+
# find first dataclass or pydantic argument
|
|
192
|
+
for name, hint in candidates:
|
|
193
|
+
typ = hint
|
|
194
|
+
if get_origin(typ) is Annotated:
|
|
195
|
+
typ = get_args(typ)[0]
|
|
196
|
+
if (
|
|
197
|
+
(isinstance(typ, type) and issubclass(typ, BaseModel))
|
|
198
|
+
or is_dataclass(typ)
|
|
199
|
+
):
|
|
200
|
+
self.body_param_name = name
|
|
201
|
+
param_names.remove(name)
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
# the rest are query params
|
|
205
|
+
|
|
206
|
+
for param in param_names:
|
|
207
|
+
self.query_param_names.add(param)
|
|
170
208
|
|
|
171
209
|
# return type
|
|
172
210
|
|
|
@@ -191,6 +229,41 @@ class RestChannel(HTTPXChannel):
|
|
|
191
229
|
|
|
192
230
|
# override
|
|
193
231
|
|
|
232
|
+
async def invoke_async(self, invocation: 'DynamicProxy.Invocation'):
|
|
233
|
+
call = self.get_call(invocation.type, invocation.method)
|
|
234
|
+
|
|
235
|
+
bound = call.signature.bind(self, *invocation.args, **invocation.kwargs)
|
|
236
|
+
bound.apply_defaults()
|
|
237
|
+
arguments = bound.arguments
|
|
238
|
+
|
|
239
|
+
# url
|
|
240
|
+
|
|
241
|
+
url = call.url_template.format(**arguments)
|
|
242
|
+
query_params = {k: arguments[k] for k in call.query_param_names if k in arguments}
|
|
243
|
+
body = {}
|
|
244
|
+
if call.body_param_name is not None:
|
|
245
|
+
body = self.to_dict(arguments.get(call.body_param_name))
|
|
246
|
+
|
|
247
|
+
# call
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
result = None
|
|
251
|
+
if call.type == "get":
|
|
252
|
+
result = await self.get_async_client().get(self.get_url() + url, params=query_params, timeout=self.timeout)
|
|
253
|
+
elif call.type == "put":
|
|
254
|
+
result = await self.get_async_client().put(self.get_url() + url, params=query_params, timeout=self.timeout)
|
|
255
|
+
elif call.type == "delete":
|
|
256
|
+
result = await self.get_async_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout)
|
|
257
|
+
elif call.type == "post":
|
|
258
|
+
result = await self.get_async_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
|
|
259
|
+
|
|
260
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.json())
|
|
261
|
+
except ServiceCommunicationException:
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
except Exception as e:
|
|
265
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
|
266
|
+
|
|
194
267
|
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
195
268
|
call = self.get_call(invocation.type, invocation.method)
|
|
196
269
|
|
|
@@ -209,20 +282,19 @@ class RestChannel(HTTPXChannel):
|
|
|
209
282
|
# call
|
|
210
283
|
|
|
211
284
|
try:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
|
|
285
|
+
result = None
|
|
286
|
+
if call.type == "get":
|
|
287
|
+
result = self.get_client().get(self.get_url() + url, params=query_params, timeout=self.timeout)
|
|
288
|
+
elif call.type == "put":
|
|
289
|
+
result = self.get_client().put(self.get_url() + url, params=query_params, timeout=self.timeout)
|
|
290
|
+
elif call.type == "delete":
|
|
291
|
+
result = self.get_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout)
|
|
292
|
+
elif call.type == "post":
|
|
293
|
+
result = self.get_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
|
|
294
|
+
|
|
295
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.json())
|
|
296
|
+
except ServiceCommunicationException:
|
|
297
|
+
raise
|
|
298
|
+
|
|
227
299
|
except Exception as e:
|
|
228
|
-
raise
|
|
300
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
aspyx_service/serialization.py
CHANGED
|
@@ -7,67 +7,6 @@ from typing import get_origin, get_args, Union
|
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
|
-
def deserialize(value, return_type):
|
|
11
|
-
if value is None:
|
|
12
|
-
return None
|
|
13
|
-
|
|
14
|
-
origin = get_origin(return_type)
|
|
15
|
-
args = get_args(return_type)
|
|
16
|
-
|
|
17
|
-
# Handle Optional / Union
|
|
18
|
-
if origin is Union and type(None) in args:
|
|
19
|
-
real_type = [arg for arg in args if arg is not type(None)][0]
|
|
20
|
-
return deserialize(value, real_type)
|
|
21
|
-
|
|
22
|
-
# Handle pydantic
|
|
23
|
-
if isinstance(return_type, type) and issubclass(return_type, BaseModel):
|
|
24
|
-
return return_type.parse_obj(value)
|
|
25
|
-
|
|
26
|
-
# Handle dataclass
|
|
27
|
-
if is_dataclass(return_type):
|
|
28
|
-
return from_dict(return_type, value)
|
|
29
|
-
|
|
30
|
-
# Handle List[T]
|
|
31
|
-
if origin is list:
|
|
32
|
-
item_type = args[0]
|
|
33
|
-
return [deserialize(v, item_type) for v in value]
|
|
34
|
-
|
|
35
|
-
# Fallback: primitive
|
|
36
|
-
return value
|
|
37
|
-
|
|
38
|
-
def from_dict(cls, data: dict):
|
|
39
|
-
if not is_dataclass(cls):
|
|
40
|
-
return data # primitive or unknown
|
|
41
|
-
|
|
42
|
-
kwargs = {}
|
|
43
|
-
for field in fields(cls):
|
|
44
|
-
name = field.name
|
|
45
|
-
field_type = field.type
|
|
46
|
-
value = data.get(name)
|
|
47
|
-
|
|
48
|
-
if value is None:
|
|
49
|
-
kwargs[name] = None
|
|
50
|
-
continue
|
|
51
|
-
|
|
52
|
-
origin = get_origin(field_type)
|
|
53
|
-
args = get_args(field_type)
|
|
54
|
-
|
|
55
|
-
if origin is Union and type(None) in args:
|
|
56
|
-
real_type = [arg for arg in args if arg is not type(None)][0]
|
|
57
|
-
kwargs[name] = deserialize(value, real_type)
|
|
58
|
-
|
|
59
|
-
elif is_dataclass(field_type):
|
|
60
|
-
kwargs[name] = from_dict(field_type, value)
|
|
61
|
-
|
|
62
|
-
elif origin is list:
|
|
63
|
-
item_type = args[0]
|
|
64
|
-
kwargs[name] = [deserialize(v, item_type) for v in value]
|
|
65
|
-
|
|
66
|
-
else:
|
|
67
|
-
kwargs[name] = value
|
|
68
|
-
|
|
69
|
-
return cls(**kwargs)
|
|
70
|
-
|
|
71
10
|
class TypeDeserializer:
|
|
72
11
|
# constructor
|
|
73
12
|
|
|
@@ -103,6 +42,9 @@ class TypeDeserializer:
|
|
|
103
42
|
if is_dataclass(typ):
|
|
104
43
|
field_deserializers = {f.name: TypeDeserializer(f.type) for f in fields(typ)}
|
|
105
44
|
def deser_dataclass(value):
|
|
45
|
+
if is_dataclass(value):
|
|
46
|
+
return value
|
|
47
|
+
|
|
106
48
|
return typ(**{
|
|
107
49
|
k: field_deserializers[k](v) for k, v in value.items()
|
|
108
50
|
})
|
|
@@ -119,6 +61,54 @@ class TypeDeserializer:
|
|
|
119
61
|
|
|
120
62
|
# Fallback
|
|
121
63
|
return lambda v: v
|
|
64
|
+
|
|
65
|
+
class TypeSerializer:
|
|
66
|
+
def __init__(self, typ):
|
|
67
|
+
self.typ = typ
|
|
68
|
+
self.serializer = self._build_serializer(typ)
|
|
69
|
+
|
|
70
|
+
def __call__(self, value):
|
|
71
|
+
return self.serializer(value)
|
|
72
|
+
|
|
73
|
+
def _build_serializer(self, typ):
|
|
74
|
+
origin = get_origin(typ)
|
|
75
|
+
args = get_args(typ)
|
|
76
|
+
|
|
77
|
+
if origin is Union:
|
|
78
|
+
serializers = [TypeSerializer(arg) for arg in args if arg is not type(None)]
|
|
79
|
+
def ser_union(value):
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
for s in serializers:
|
|
83
|
+
try:
|
|
84
|
+
return s(value)
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
return value
|
|
88
|
+
return ser_union
|
|
89
|
+
|
|
90
|
+
if isinstance(typ, type) and issubclass(typ, BaseModel):
|
|
91
|
+
return lambda v: v.dict() if v is not None else None
|
|
92
|
+
|
|
93
|
+
if is_dataclass(typ):
|
|
94
|
+
field_serializers = {f.name: TypeSerializer(f.type) for f in fields(typ)}
|
|
95
|
+
def ser_dataclass(obj):
|
|
96
|
+
if obj is None:
|
|
97
|
+
return None
|
|
98
|
+
return {k: field_serializers[k](getattr(obj, k)) for k in field_serializers}
|
|
99
|
+
return ser_dataclass
|
|
100
|
+
|
|
101
|
+
if origin is list:
|
|
102
|
+
item_ser = TypeSerializer(args[0]) if args else lambda x: x
|
|
103
|
+
return lambda v: [item_ser(item) for item in v] if v is not None else None
|
|
104
|
+
|
|
105
|
+
if origin is dict:
|
|
106
|
+
key_ser = TypeSerializer(args[0]) if args else lambda x: x
|
|
107
|
+
val_ser = TypeSerializer(args[1]) if len(args) > 1 else lambda x: x
|
|
108
|
+
return lambda v: {key_ser(k): val_ser(val) for k, val in v.items()} if v is not None else None
|
|
109
|
+
|
|
110
|
+
# Fallback: primitive Typen oder unbekannt
|
|
111
|
+
return lambda v: v
|
|
122
112
|
|
|
123
113
|
@lru_cache(maxsize=512)
|
|
124
114
|
def get_deserializer(typ):
|
|
@@ -132,3 +122,16 @@ def get_deserializer(typ):
|
|
|
132
122
|
|
|
133
123
|
"""
|
|
134
124
|
return TypeDeserializer(typ)
|
|
125
|
+
|
|
126
|
+
@lru_cache(maxsize=512)
|
|
127
|
+
def get_serializer(typ):
|
|
128
|
+
"""
|
|
129
|
+
return a function that is able to deserialize a value of the specified type
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
typ: the type
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
return TypeSerializer(typ)
|
aspyx_service/server.py
CHANGED
|
@@ -38,7 +38,6 @@ class FastAPIServer(Server):
|
|
|
38
38
|
self.host = host
|
|
39
39
|
Server.port = port
|
|
40
40
|
self.server_thread = None
|
|
41
|
-
self.environment : Optional[Environment] = None
|
|
42
41
|
self.service_manager : Optional[ServiceManager] = None
|
|
43
42
|
self.component_registry: Optional[ComponentRegistry] = None
|
|
44
43
|
|
|
@@ -83,19 +82,17 @@ class FastAPIServer(Server):
|
|
|
83
82
|
response_model=method.return_type,
|
|
84
83
|
)
|
|
85
84
|
|
|
86
|
-
def
|
|
85
|
+
def start_fastapi_thread(self):
|
|
87
86
|
"""
|
|
88
87
|
start the fastapi server in a thread
|
|
89
88
|
"""
|
|
90
89
|
|
|
91
|
-
config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, log_level="debug"
|
|
90
|
+
config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, access_log=False) #log_level="debug"
|
|
92
91
|
server = uvicorn.Server(config)
|
|
93
92
|
|
|
94
93
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
95
94
|
thread.start()
|
|
96
95
|
|
|
97
|
-
print(f"server started on {self.host}:{self.port}")
|
|
98
|
-
|
|
99
96
|
return thread
|
|
100
97
|
|
|
101
98
|
|
|
@@ -212,12 +209,12 @@ class FastAPIServer(Server):
|
|
|
212
209
|
self.add_routes()
|
|
213
210
|
self.fast_api.include_router(self.router)
|
|
214
211
|
|
|
215
|
-
for route in self.fast_api.routes:
|
|
216
|
-
|
|
212
|
+
#for route in self.fast_api.routes:
|
|
213
|
+
# print(f"{route.name}: {route.path} [{route.methods}]")
|
|
217
214
|
|
|
218
215
|
# start server thread
|
|
219
216
|
|
|
220
|
-
self.
|
|
217
|
+
self.start_fastapi_thread()
|
|
221
218
|
|
|
222
219
|
# shutdown
|
|
223
220
|
|
aspyx_service/service.py
CHANGED
|
@@ -48,7 +48,12 @@ class Server(ABC):
|
|
|
48
48
|
# constructor
|
|
49
49
|
|
|
50
50
|
def __init__(self):
|
|
51
|
-
|
|
51
|
+
self.environment : Optional[Environment] = None
|
|
52
|
+
|
|
53
|
+
# public
|
|
54
|
+
|
|
55
|
+
def get(self, type: Type[T]) -> T:
|
|
56
|
+
return self.environment.get(type)
|
|
52
57
|
|
|
53
58
|
@abstractmethod
|
|
54
59
|
def boot(self, module_type: Type):
|
|
@@ -475,6 +480,9 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
|
|
|
475
480
|
self.url_selector = Channel.FirstURLSelector()
|
|
476
481
|
|
|
477
482
|
def get_url(self) -> str:
|
|
483
|
+
if self.address is None:
|
|
484
|
+
raise ServiceCommunicationException(f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
|
|
485
|
+
|
|
478
486
|
return self.url_selector.get(self.address.urls)
|
|
479
487
|
|
|
480
488
|
def set_address(self, address: Optional[ChannelInstances]):
|
|
@@ -637,7 +645,8 @@ class ServiceManager:
|
|
|
637
645
|
def __init__(self, component_registry: ComponentRegistry, channel_manager: ChannelManager):
|
|
638
646
|
self.component_registry = component_registry
|
|
639
647
|
self.channel_manager = channel_manager
|
|
640
|
-
self.environment = None
|
|
648
|
+
self.environment : Optional[Environment] = None
|
|
649
|
+
self.preferred_channel = ""
|
|
641
650
|
|
|
642
651
|
self.ip = Server.get_local_ip()
|
|
643
652
|
|
|
@@ -732,6 +741,9 @@ class ServiceManager:
|
|
|
732
741
|
|
|
733
742
|
return address
|
|
734
743
|
|
|
744
|
+
def set_preferred_channel(self, preferred_channel: str):
|
|
745
|
+
self.preferred_channel = preferred_channel
|
|
746
|
+
|
|
735
747
|
def get_service(self, service_type: Type[T], preferred_channel="") -> T:
|
|
736
748
|
"""
|
|
737
749
|
return a service proxy given a service type and preferred channel name
|
|
@@ -743,6 +755,10 @@ class ServiceManager:
|
|
|
743
755
|
Returns:
|
|
744
756
|
the proxy
|
|
745
757
|
"""
|
|
758
|
+
|
|
759
|
+
if len(preferred_channel) == 0:
|
|
760
|
+
preferred_channel = self.preferred_channel
|
|
761
|
+
|
|
746
762
|
service_descriptor = ServiceManager.get_descriptor(service_type)
|
|
747
763
|
component_descriptor = service_descriptor.get_component_descriptor()
|
|
748
764
|
|
|
@@ -753,11 +769,12 @@ class ServiceManager:
|
|
|
753
769
|
|
|
754
770
|
# check proxy
|
|
755
771
|
|
|
756
|
-
|
|
772
|
+
channel_key = TypeAndChannel(type=component_descriptor.type, channel=preferred_channel)
|
|
773
|
+
proxy_key = TypeAndChannel(type=service_type, channel=preferred_channel)
|
|
757
774
|
|
|
758
|
-
proxy = self.proxy_cache.get(
|
|
775
|
+
proxy = self.proxy_cache.get(proxy_key, None)
|
|
759
776
|
if proxy is None:
|
|
760
|
-
channel_instance = self.channel_cache.get(
|
|
777
|
+
channel_instance = self.channel_cache.get(channel_key, None)
|
|
761
778
|
|
|
762
779
|
if channel_instance is None:
|
|
763
780
|
address = self.find_service_address(component_descriptor, preferred_channel)
|
|
@@ -770,9 +787,9 @@ class ServiceManager:
|
|
|
770
787
|
# channel may have changed
|
|
771
788
|
|
|
772
789
|
if address.channel != preferred_channel:
|
|
773
|
-
|
|
790
|
+
channel_key = TypeAndChannel(type=component_descriptor.type, channel=address.channel)
|
|
774
791
|
|
|
775
|
-
channel_instance = self.channel_cache.get(
|
|
792
|
+
channel_instance = self.channel_cache.get(channel_key, None)
|
|
776
793
|
if channel_instance is None:
|
|
777
794
|
# create channel
|
|
778
795
|
|
|
@@ -780,7 +797,7 @@ class ServiceManager:
|
|
|
780
797
|
|
|
781
798
|
# cache
|
|
782
799
|
|
|
783
|
-
self.channel_cache[
|
|
800
|
+
self.channel_cache[channel_key] = channel_instance
|
|
784
801
|
|
|
785
802
|
# and watch for changes in the addresses
|
|
786
803
|
|
|
@@ -789,7 +806,7 @@ class ServiceManager:
|
|
|
789
806
|
# create proxy
|
|
790
807
|
|
|
791
808
|
proxy = DynamicProxy.create(service_type, channel_instance)
|
|
792
|
-
self.proxy_cache[
|
|
809
|
+
self.proxy_cache[proxy_key] = proxy
|
|
793
810
|
|
|
794
811
|
return proxy
|
|
795
812
|
|
|
@@ -850,18 +867,19 @@ class LocalChannel(Channel):
|
|
|
850
867
|
|
|
851
868
|
return getattr(instance, invocation.method.__name__)(*invocation.args, **invocation.kwargs)
|
|
852
869
|
|
|
853
|
-
|
|
854
|
-
#@injectable()
|
|
855
870
|
class LocalComponentRegistry(ComponentRegistry):
|
|
856
871
|
# constructor
|
|
857
872
|
|
|
858
873
|
def __init__(self):
|
|
859
|
-
self.component_channels : dict[ComponentDescriptor, list[
|
|
874
|
+
self.component_channels : dict[ComponentDescriptor, list[ChannelInstances]] = {}
|
|
860
875
|
|
|
861
876
|
# implement
|
|
862
877
|
|
|
863
878
|
def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]) -> None:
|
|
864
|
-
self.component_channels
|
|
879
|
+
if self.component_channels.get(descriptor, None) is None:
|
|
880
|
+
self.component_channels[descriptor] = []
|
|
881
|
+
|
|
882
|
+
self.component_channels[descriptor].extend([ChannelInstances(descriptor.name, address.channel, [address.uri]) for address in addresses])
|
|
865
883
|
|
|
866
884
|
def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
|
|
867
885
|
pass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aspyx_service
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.3
|
|
4
4
|
Summary: Aspyx Service framework
|
|
5
5
|
Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -26,7 +26,7 @@ License: MIT License
|
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Python: >=3.9
|
|
29
|
-
Requires-Dist: aspyx>=1.5.
|
|
29
|
+
Requires-Dist: aspyx>=1.5.1
|
|
30
30
|
Requires-Dist: fastapi~=0.115.13
|
|
31
31
|
Requires-Dist: httpx~=0.28.1
|
|
32
32
|
Requires-Dist: msgpack~=1.1.1
|
|
@@ -34,6 +34,18 @@ Requires-Dist: python-consul2~=0.1.5
|
|
|
34
34
|
Requires-Dist: uvicorn[standard]
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
36
36
|
|
|
37
|
+
# aspyx
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
43
|
+

|
|
44
|
+
[](https://pypi.org/project/aspyx/)
|
|
45
|
+
[](https://coolsamson7.github.io/aspyx/index/introduction)
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
37
49
|
# Service
|
|
38
50
|
|
|
39
51
|
- [Introduction](#introduction)
|
|
@@ -58,19 +70,23 @@ that lets you deploy, discover and call services with different remoting protoco
|
|
|
58
70
|
|
|
59
71
|
The basic design consists of four different concepts:
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
**Service**
|
|
74
|
+
|
|
62
75
|
defines a group of methods that can be called either locally or remotely.
|
|
63
76
|
These methods represent the functional interface exposed to clients — similar to an interface in traditional programming
|
|
64
77
|
|
|
65
|
-
|
|
78
|
+
**Component**
|
|
79
|
+
|
|
66
80
|
a component bundles one or more services and declares the channels (protocols) used to expose them.
|
|
67
81
|
Think of a component as a deployment unit or module.
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
**Component Registry**
|
|
84
|
+
|
|
70
85
|
acts as the central directory for managing available components.
|
|
71
86
|
It allows the framework to register, discover, and resolve components and their services.
|
|
72
87
|
|
|
73
|
-
|
|
88
|
+
**Channel**
|
|
89
|
+
|
|
74
90
|
is a pluggable transport layer that defines how service method invocations are transmitted and handled.
|
|
75
91
|
|
|
76
92
|
Let's look at the "interface" layer first.
|
|
@@ -99,7 +115,7 @@ class Module:
|
|
|
99
115
|
|
|
100
116
|
@create()
|
|
101
117
|
def create_registry(self) -> ConsulComponentRegistry:
|
|
102
|
-
return ConsulComponentRegistry(Server.port, "
|
|
118
|
+
return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500)) # a consul based registry!
|
|
103
119
|
|
|
104
120
|
environment = Environment(Module)
|
|
105
121
|
service_manager = environment.get(ServiceManager)
|
|
@@ -349,7 +365,7 @@ A so called `URLSelector` is used internally to provide URLs for every single ca
|
|
|
349
365
|
- `FirstURLSelector` always returns the first URL of the list of possible URLs
|
|
350
366
|
- `RoundRobinURLSelector` switches sequentially between all URLs.
|
|
351
367
|
|
|
352
|
-
To customize the behavior, an around advice can be implemented easily:
|
|
368
|
+
To customize the behavior, an `around` advice can be implemented easily:
|
|
353
369
|
|
|
354
370
|
**Example**:
|
|
355
371
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
aspyx_service/__init__.py,sha256=6t24VPrSCG83EAvYlqCKdEcEbyCY3vrSb5GoAx01Ymg,1662
|
|
2
|
+
aspyx_service/channels.py,sha256=p5JIyo7eWyBiR2xQrfsEvq2L89FzeFT1tqKYhvXQbXs,9035
|
|
3
|
+
aspyx_service/healthcheck.py,sha256=8ZPSkAx6ypoYaxDMkJT_MtL2pEN2LcUAishAWPCy-3I,5624
|
|
4
|
+
aspyx_service/registries.py,sha256=JSsD32F8VffZMHyEDuapEWtvmem5SK9kR6bgsFRLFZQ,8002
|
|
5
|
+
aspyx_service/restchannel.py,sha256=Q_7RURjZZW7N2LBLY9BL7qKyS_A62X7yFoGUoE-_0YY,9103
|
|
6
|
+
aspyx_service/serialization.py,sha256=GEgfg1cNSOJ_oe0gEm0ajzugLyUmPiEsp9Qz6Fu4vkA,4207
|
|
7
|
+
aspyx_service/server.py,sha256=PHKx3O90jnxm8dA4z331_51znhU-HlRB2Fa1AikmFXA,7052
|
|
8
|
+
aspyx_service/service.py,sha256=Odl6_nvOOJ2lFjqCLJD8KLPt-sCG55VtQQiKTyNEOPs,26062
|
|
9
|
+
aspyx_service-0.10.3.dist-info/METADATA,sha256=MrWNfy3T2m4G5MyKlV4Spr2yi6N_hE1VLRT3vNJ6Hm0,16955
|
|
10
|
+
aspyx_service-0.10.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
+
aspyx_service-0.10.3.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
|
|
12
|
+
aspyx_service-0.10.3.dist-info/RECORD,,
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
aspyx_service/__init__.py,sha256=6t24VPrSCG83EAvYlqCKdEcEbyCY3vrSb5GoAx01Ymg,1662
|
|
2
|
-
aspyx_service/channels.py,sha256=ts-LgqIgt02W72cMS1LrimOCyKHfpzL3BC7s2vFjWFs,8492
|
|
3
|
-
aspyx_service/healthcheck.py,sha256=8ZPSkAx6ypoYaxDMkJT_MtL2pEN2LcUAishAWPCy-3I,5624
|
|
4
|
-
aspyx_service/registries.py,sha256=4PvMZaFolZH0D2GD6NWXlmaOXJ2kQxuWEBMwoQqsJRg,7473
|
|
5
|
-
aspyx_service/restchannel.py,sha256=d7j7z6T3HQ7oj_KJ9aLrmoJpmSRqa6cNh7o_IkCIfBg,6096
|
|
6
|
-
aspyx_service/serialization.py,sha256=T6JDURwYd7V4240j1BnNsFKdyiMgePCsCwKa9rf8hq4,3779
|
|
7
|
-
aspyx_service/server.py,sha256=wEl1Y_2aolG3n4w4qUtl8X4_anjd_m9WYMT2twModv8,7119
|
|
8
|
-
aspyx_service/service.py,sha256=T7rZ8EPEzOusP_xOk-7KwTQfX_iJQ291XDL_ieKa_d8,25148
|
|
9
|
-
aspyx_service-0.10.1.dist-info/METADATA,sha256=PbMCzxd082R1yuesNe2sjqYENT1NNm_3fHaHcd46rho,16249
|
|
10
|
-
aspyx_service-0.10.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
-
aspyx_service-0.10.1.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
|
|
12
|
-
aspyx_service-0.10.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|