aspyx-service 0.11.2__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.
@@ -0,0 +1,241 @@
1
+ """
2
+ registries for components in aspyx service
3
+ """
4
+ import re
5
+ import threading
6
+ from abc import abstractmethod
7
+ import time
8
+ from typing import Optional
9
+
10
+ import consul
11
+
12
+ from aspyx.di.configuration import inject_value
13
+ from aspyx.util import StringBuilder
14
+ from aspyx.di import on_init
15
+ from .healthcheck import HealthCheckManager, HealthStatus
16
+
17
+ from .server import Server
18
+ from .service import ComponentRegistry, Channel, ChannelInstances, ServiceManager, ComponentDescriptor, Component, ChannelAddress
19
+
20
+ class ConsulComponentRegistry(ComponentRegistry):
21
+ """
22
+ A specialized registry using consul.
23
+ A polling mechanism is used to identify changes in the component health.
24
+ """
25
+ # constructor
26
+
27
+ def __init__(self, port: int, consul: consul.Consul):
28
+ self.port = port
29
+ self.ip = Server.get_local_ip()
30
+ self.running = False
31
+ self.consul = consul
32
+ self.watchdog = None
33
+ self.interval = 5
34
+ self.last_index = {}
35
+ self.component_addresses : dict[str, dict[str, ChannelInstances]] = {} # comp -> channel -> address
36
+ self.watch_channels : list[Channel] = []
37
+ self.watchdog_interval = 5
38
+ self.healthcheck_interval = "10s"
39
+ self.healthcheck_timeout= "5s"
40
+ self.healthcheck_deregister = "5m"
41
+
42
+ # injections
43
+
44
+ @inject_value("consul.watchdog.interval", default=5)
45
+ def set_watchdog_interval(self, interval):
46
+ self.watchdog_interval = interval
47
+
48
+ @inject_value("consul.healthcheck.interval", default="10s")
49
+ def set_healthcheck_interval(self, interval):
50
+ self.healthcheck_interval = interval
51
+
52
+ @inject_value("consul.healthcheck.timeout", default="3s")
53
+ def set_healthcheck_timeout(self, interval):
54
+ self.healthcheck_timeout = interval
55
+
56
+ @inject_value("consul.healthcheck.deregister", default="5m")
57
+ def set_healthcheck_deregister(self, interval):
58
+ self.healthcheck_deregister = interval
59
+
60
+ # lifecycle hooks
61
+
62
+ @on_init()
63
+ def setup(self):
64
+ # create consul client
65
+
66
+ self.running = True
67
+
68
+ # start thread
69
+
70
+ self.watchdog = threading.Thread(target=self.watch_consul, daemon=True)
71
+ self.watchdog.start()
72
+
73
+ def inform_channels(self, old_address: ChannelInstances, new_address: Optional[ChannelInstances]):
74
+ for channel in self.watch_channels:
75
+ if channel.address is old_address:
76
+ channel.set_address(new_address)
77
+
78
+
79
+ def watch_consul(self):
80
+ while self.running:
81
+ # check services
82
+
83
+ for component, old_addresses in self.component_addresses.items():
84
+ old_addresses = dict(old_addresses) # we will modify it...
85
+ new_addresses = self.fetch_addresses(component, wait="1s")
86
+
87
+ # compare
88
+
89
+ changed = False
90
+ for channel_name, address in new_addresses.items():
91
+ service_address = old_addresses.get(channel_name, None)
92
+ if service_address is None:
93
+ ServiceManager.logger.info("new %s address for %s", channel_name, component)
94
+ changed = True
95
+ else:
96
+ if service_address != address:
97
+ changed = True
98
+
99
+ ServiceManager.logger.info("%s address for %s changed", channel_name, component)
100
+
101
+ # inform channels
102
+
103
+ self.inform_channels(service_address, address)
104
+
105
+ # delete
106
+
107
+ del old_addresses[channel_name]
108
+
109
+ # watchout for deleted addresses
110
+
111
+ for channel_name, address in old_addresses.items():
112
+ ServiceManager.logger.info("deleted %s address for %s", channel_name, component)
113
+
114
+ changed = True
115
+
116
+ # inform channel
117
+
118
+ self.inform_channels(address, None)
119
+
120
+ # replace ( does that work while iterating )
121
+
122
+ if changed:
123
+ self.component_addresses[component] = new_addresses
124
+
125
+ # time to sleep
126
+
127
+ time.sleep(self.watchdog_interval)
128
+
129
+ @abstractmethod
130
+ def watch(self, channel: Channel) -> None:
131
+ self.watch_channels.append(channel)
132
+
133
+ # public
134
+
135
+ def register_service(self, name, service_id, health: str, tags=None, meta=None) -> None:
136
+ self.consul.agent.service.register(
137
+ name=name,
138
+ service_id=service_id,
139
+ address=self.ip,
140
+ port=self.port,
141
+ tags=tags or [],
142
+ meta=meta or {},
143
+ check=consul.Check().http(
144
+ url=f"http://{self.ip}:{self.port}{health}",
145
+ interval=self.healthcheck_interval,
146
+ timeout=self.healthcheck_timeout,
147
+ deregister=self.healthcheck_deregister)
148
+ )
149
+
150
+ def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
151
+ name = descriptor.name
152
+
153
+ service_id = f"{self.ip}:{self.port}:{name}"
154
+
155
+ self.consul.agent.service.deregister(service_id)
156
+
157
+ def stop(self):
158
+ self.running = False
159
+ if self.watchdog is not None:
160
+ self.watchdog.join()
161
+ self.watchdog = None
162
+
163
+ # private
164
+
165
+ def fetch_addresses(self, component: str, wait=None) -> dict[str, ChannelInstances]:
166
+ addresses : dict[str, ChannelInstances] = {} # channel name -> ServiceAddress
167
+
168
+ index, nodes = self.consul.health.service(component, passing=True, index=self.last_index.get(component, None), wait=wait)
169
+ self.last_index[component] = index
170
+
171
+ for node in nodes:
172
+ service = node["Service"]
173
+
174
+ meta = service.get('Meta')
175
+
176
+ channels = meta.get("channels").split(",")
177
+
178
+ for channel in channels:
179
+ match = re.search(r"([\w-]+)\((.*)\)", channel)
180
+
181
+ channel_name = match.group(1)
182
+ url = match.group(2)
183
+
184
+ address = addresses.get(channel, None)
185
+ if address is None:
186
+ address = ChannelInstances(component=component, channel=channel_name)
187
+ addresses[channel] = address
188
+
189
+ address.urls.append(url)
190
+
191
+ # done
192
+
193
+ return addresses
194
+
195
+ # implement ComponentRegistry
196
+
197
+ def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]):
198
+ name = descriptor.name
199
+
200
+ id = f"{self.ip}:{self.port}:{name}"
201
+
202
+ builder = StringBuilder()
203
+ first = True
204
+ for address in addresses:
205
+ if not first:
206
+ builder.append(",")
207
+
208
+ builder.append(address.channel).append("(").append(address.uri).append(")")
209
+
210
+ first = False
211
+
212
+ addresses = str(builder)
213
+
214
+ self.register_service(name, id, descriptor.health, tags =["component"], meta={"channels": addresses})
215
+
216
+ def get_addresses(self, descriptor: ComponentDescriptor) -> list[ChannelInstances]:
217
+ component_addresses = self.component_addresses.get(descriptor.name, None)
218
+ if component_addresses is None:
219
+ component_addresses = self.fetch_addresses(descriptor.name)
220
+
221
+ # only cache if non-empty
222
+
223
+ if component_addresses:
224
+ self.component_addresses[descriptor.name] = component_addresses
225
+
226
+ return list(component_addresses.values())
227
+
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
+
235
+ def map_health(self, health: HealthCheckManager.Health) -> int:
236
+ if health.status is HealthStatus.OK:
237
+ return 200
238
+ elif health.status is HealthStatus.WARNING:
239
+ return 429
240
+ else:
241
+ return 500
@@ -0,0 +1,313 @@
1
+ """
2
+ rest channel implementation
3
+ """
4
+ import inspect
5
+ import re
6
+ from dataclasses import is_dataclass
7
+
8
+ from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get_args, Type
9
+ from pydantic import BaseModel
10
+
11
+ from aspyx.reflection import DynamicProxy, Decorators
12
+ from aspyx.util import get_serializer
13
+
14
+ from .channels import HTTPXChannel
15
+ from .service import channel, ServiceCommunicationException
16
+
17
+ T = TypeVar("T")
18
+
19
+ class BodyMarker:
20
+ pass
21
+
22
+ Body = lambda t: Annotated[t, BodyMarker]
23
+
24
+ class QueryParamMarker:
25
+ pass
26
+
27
+ QueryParam = lambda t: Annotated[t, QueryParamMarker]
28
+
29
+ # decorators
30
+
31
+ def rest(url=""):
32
+ """
33
+ mark service interfaces to add a url prefix
34
+
35
+ Args:
36
+ url: prefix that will be added to all urls
37
+ """
38
+ def decorator(cls):
39
+ Decorators.add(cls, rest, url)
40
+
41
+ return cls
42
+ return decorator
43
+
44
+ def get(url: str):
45
+ """
46
+ methods marked with `get` will be executed by calling a http get request.
47
+
48
+ Args:
49
+ url: the url
50
+ """
51
+ def decorator(cls):
52
+ Decorators.add(cls, get, url)
53
+
54
+ return cls
55
+ return decorator
56
+
57
+
58
+ def post(url: str):
59
+ """
60
+ methods marked with `post` will be executed by calling a http get request.
61
+ The body parameter should be marked with `Body(<param>)`
62
+
63
+ Args:
64
+ url: the url
65
+ """
66
+ def decorator(cls):
67
+ Decorators.add(cls, post, url)
68
+
69
+ return cls
70
+
71
+ return decorator
72
+
73
+ def put(url: str):
74
+ """
75
+ methods marked with `put` will be executed by calling a http put request.
76
+
77
+ Args:
78
+ url: the url
79
+ """
80
+ def decorator(cls):
81
+ Decorators.add(cls, put, url)
82
+
83
+ return cls
84
+
85
+ return decorator
86
+
87
+ def delete(url: str):
88
+ """
89
+ methods marked with `delete` will be executed by calling a http delete request.
90
+
91
+ Args:
92
+ url: the url
93
+ """
94
+ def decorator(cls):
95
+ Decorators.add(cls, delete, url)
96
+
97
+ return cls
98
+
99
+ return decorator
100
+
101
+ def patch(url: str):
102
+ """
103
+ methods marked with `patch` will be executed by calling a http patch request.
104
+
105
+ Args:
106
+ url: the url
107
+ """
108
+ def decorator(cls):
109
+ Decorators.add(cls, patch, url)
110
+
111
+ return cls
112
+
113
+ return decorator
114
+
115
+ @channel("rest")
116
+ class RestChannel(HTTPXChannel):
117
+ """
118
+ A rest channel executes http requests as specified by the corresponding decorators and annotations,
119
+ """
120
+ __slots__ = [
121
+ "signature",
122
+ "url_template",
123
+ "type",
124
+ "calls",
125
+ "return_type",
126
+ "path_param_names",
127
+ "query_param_names",
128
+ "body_param_name"
129
+ ]
130
+
131
+ # local class
132
+
133
+ class Call:
134
+ # slots
135
+
136
+ __slots__ = [
137
+ "type",
138
+ "url_template",
139
+ "path_param_names",
140
+ "body_param_name",
141
+ "query_param_names",
142
+ "return_type",
143
+ "signature",
144
+ "body_serializer"
145
+ ]
146
+
147
+ # constructor
148
+
149
+ def __init__(self, type: Type, method : Callable):
150
+ self.signature = inspect.signature(method)
151
+
152
+ type_hints = get_type_hints(method)
153
+
154
+ param_names = list(self.signature.parameters.keys())
155
+ param_names.remove("self")
156
+
157
+ prefix = ""
158
+ if Decorators.has_decorator(type, rest):
159
+ prefix = Decorators.get_decorator(type, rest).args[0]
160
+
161
+ # find decorator
162
+
163
+ self.type = "get"
164
+ self.url_template = ""
165
+
166
+ decorators = Decorators.get_all(method)
167
+
168
+ for decorator in [get, post, put, delete, patch]:
169
+ descriptor = next((descriptor for descriptor in decorators if descriptor.decorator is decorator), None)
170
+ if descriptor is not None:
171
+ self.type = decorator.__name__
172
+ self.url_template = prefix + descriptor.args[0]
173
+
174
+ # parameters
175
+
176
+ self.path_param_names = set(re.findall(r"{(.*?)}", self.url_template))
177
+
178
+ for param_name in self.path_param_names:
179
+ param_names.remove(param_name)
180
+
181
+ hints = get_type_hints(method, include_extras=True)
182
+
183
+ self.body_param_name = None
184
+ self.query_param_names = set()
185
+
186
+ for param_name, hint in hints.items():
187
+ if get_origin(hint) is Annotated:
188
+ metadata = get_args(hint)[1:]
189
+
190
+ if BodyMarker in metadata:
191
+ self.body_param_name = param_name
192
+ self.body_serializer = get_serializer(type_hints[param_name])
193
+ param_names.remove(param_name)
194
+ elif QueryParamMarker in metadata:
195
+ self.query_param_names.add(param_name)
196
+ param_names.remove(param_name)
197
+
198
+ # check if something is missing
199
+
200
+ if param_names:
201
+ # check body params
202
+ if self.type in ("post", "put", "patch"):
203
+ if self.body_param_name is None:
204
+ candidates = [
205
+ (name, hint)
206
+ for name, hint in hints.items()
207
+ if name not in self.path_param_names
208
+ ]
209
+ # find first dataclass or pydantic argument
210
+ for name, hint in candidates:
211
+ typ = hint
212
+ if get_origin(typ) is Annotated:
213
+ typ = get_args(typ)[0]
214
+ if (
215
+ (isinstance(typ, type) and issubclass(typ, BaseModel))
216
+ or is_dataclass(typ)
217
+ ):
218
+ self.body_param_name = name
219
+ self.body_serializer = get_serializer(type_hints[name])
220
+ param_names.remove(name)
221
+ break
222
+
223
+ # the rest are query params
224
+
225
+ for param in param_names:
226
+ self.query_param_names.add(param)
227
+
228
+ # return type
229
+
230
+ self.return_type = type_hints['return']
231
+
232
+ # constructor
233
+
234
+ def __init__(self):
235
+ super().__init__()
236
+
237
+ self.calls : dict[Callable, RestChannel.Call] = {}
238
+
239
+ # internal
240
+
241
+ def get_call(self, type: Type ,method: Callable):
242
+ call = self.calls.get(method, None)
243
+ if call is None:
244
+ call = RestChannel.Call(type, method)
245
+ self.calls[method] = call
246
+
247
+ return call
248
+
249
+ # override
250
+
251
+ async def invoke_async(self, invocation: 'DynamicProxy.Invocation'):
252
+ call = self.get_call(invocation.type, invocation.method)
253
+
254
+ bound = call.signature.bind(self, *invocation.args, **invocation.kwargs)
255
+ bound.apply_defaults()
256
+ arguments = bound.arguments
257
+
258
+ # url
259
+
260
+ url = call.url_template.format(**arguments)
261
+ query_params = {k: arguments[k] for k in call.query_param_names if k in arguments}
262
+ body = {}
263
+ if call.body_param_name is not None:
264
+ body = call.body_serializer(arguments.get(call.body_param_name))#self.to_dict(arguments.get(call.body_param_name))
265
+
266
+ # call
267
+
268
+ try:
269
+ result = None
270
+ if call.type in ["get", "put", "delete"]:
271
+ result = await self.request_async(call.type, self.get_url() + url, params=query_params, timeout=self.timeout)
272
+
273
+ elif call.type == "post":
274
+ result = await self.request_async("post", self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
275
+
276
+ return self.get_deserializer(invocation.type, invocation.method)(result.json())
277
+ except ServiceCommunicationException:
278
+ raise
279
+
280
+ except Exception as e:
281
+ raise ServiceCommunicationException(f"communication exception {e}") from e
282
+
283
+ def invoke(self, invocation: DynamicProxy.Invocation):
284
+ call = self.get_call(invocation.type, invocation.method)
285
+
286
+ bound = call.signature.bind(self,*invocation.args, **invocation.kwargs)
287
+ bound.apply_defaults()
288
+ arguments = bound.arguments
289
+
290
+ # url
291
+
292
+ url = call.url_template.format(**arguments)
293
+ query_params = {k: arguments[k] for k in call.query_param_names if k in arguments}
294
+ body = {}
295
+ if call.body_param_name is not None:
296
+ body = call.body_serializer(arguments.get(call.body_param_name))#self.to_dict(arguments.get(call.body_param_name))
297
+
298
+ # call
299
+
300
+ try:
301
+ result = None
302
+ if call.type in ["get", "put", "delete"]:
303
+ result = self.request(call.type, self.get_url() + url, params=query_params, timeout=self.timeout)
304
+
305
+ elif call.type == "post":
306
+ result = self.request( "post", self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
307
+
308
+ return self.get_deserializer(invocation.type, invocation.method)(result.json())
309
+ except ServiceCommunicationException:
310
+ raise
311
+
312
+ except Exception as e:
313
+ raise ServiceCommunicationException(f"communication exception {e}") from e