aspyx-service 0.9.0__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.

@@ -0,0 +1,71 @@
1
+ """
2
+ This module provides the core Aspyx service management framework allowing for service discovery and transparent remoting including multiple possible transport protocols.
3
+ """
4
+
5
+ from aspyx.di import module
6
+
7
+ from .service import ServiceException, Server, Channel, ComponentDescriptor, inject_service, ChannelAddress, ServiceAddress, ServiceManager, Component, Service, AbstractComponent, ComponentStatus, ComponentRegistry, implementation, health, component, service
8
+ from .channels import HTTPXChannel, DispatchJSONChannel
9
+ from .registries import ConsulComponentRegistry
10
+ from .server import FastAPIServer
11
+ from .healthcheck import health_checks, health_check, HealthCheckManager, HealthStatus
12
+ from .restchannel import RestChannel, post, get, put, delete, QueryParam, Body, rest
13
+
14
+
15
+ @module()
16
+ class ServiceModule:
17
+ def __init__(self):
18
+ pass
19
+
20
+ __all__ = [
21
+ # service
22
+
23
+ "ServiceManager",
24
+ "ServiceModule",
25
+ "ServiceException",
26
+ "Server",
27
+ "Component",
28
+ "Service",
29
+ "Channel",
30
+ "AbstractComponent",
31
+ "ComponentStatus",
32
+ "ComponentDescriptor",
33
+ "ComponentRegistry",
34
+ "ChannelAddress",
35
+ "ServiceAddress",
36
+ "health",
37
+ "component",
38
+ "service",
39
+ "implementation",
40
+ "inject_service",
41
+
42
+ # healthcheck
43
+
44
+ # serialization
45
+
46
+ # "deserialize",
47
+
48
+ # channel
49
+
50
+ "HTTPXChannel",
51
+ "DispatchJSONChannel",
52
+
53
+ # rest
54
+
55
+ "RestChannel",
56
+ "post",
57
+ "get",
58
+ "put",
59
+ "delete",
60
+ "rest",
61
+ "QueryParam",
62
+ "Body",
63
+
64
+ # registries
65
+
66
+ "ConsulComponentRegistry",
67
+
68
+ # server
69
+
70
+ "FastAPIServer"
71
+ ]
@@ -0,0 +1,210 @@
1
+ """
2
+ Service management and dependency injection framework.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Type, Optional, Any, Callable
7
+
8
+ import msgpack
9
+ from httpx import Client, AsyncClient
10
+ from pydantic import BaseModel
11
+
12
+ from aspyx.reflection import DynamicProxy, TypeDescriptor
13
+ from .service import ServiceManager
14
+
15
+ from .service import ComponentDescriptor, ServiceAddress, ServiceException, channel, Channel, RemoteServiceException
16
+ from .serialization import get_deserializer
17
+
18
+
19
+ class HTTPXChannel(Channel):
20
+ __slots__ = [
21
+ "client",
22
+ "async_client",
23
+ "service_names",
24
+ "deserializers"
25
+ ]
26
+
27
+ # constructor
28
+
29
+ def __init__(self, name):
30
+ super().__init__(name)
31
+
32
+ self.client: Optional[Client] = None
33
+ self.async_client: Optional[AsyncClient] = None
34
+ self.service_names: dict[Type, str] = {}
35
+ self.deserializers: dict[Callable, Callable] = {}
36
+
37
+ # protected
38
+
39
+ def get_deserializer(self, type: Type, method: Callable) -> Type:
40
+ deserializer = self.deserializers.get(method, None)
41
+ if deserializer is None:
42
+ return_type = TypeDescriptor.for_type(type).get_method(method.__name__).return_type
43
+
44
+ deserializer = get_deserializer(return_type)
45
+
46
+ self.deserializers[method] = deserializer
47
+
48
+ return deserializer
49
+
50
+ # override
51
+
52
+ def setup(self, component_descriptor: ComponentDescriptor, address: ServiceAddress):
53
+ super().setup(component_descriptor, address)
54
+
55
+ # remember service names
56
+
57
+ for service in component_descriptor.services:
58
+ self.service_names[service.type] = service.name
59
+
60
+ # make client
61
+
62
+ self.client = self.make_client()
63
+ self.async_client = self.make_async_client()
64
+
65
+ # public
66
+
67
+ def get_client(self) -> Client:
68
+ if self.client is None:
69
+ self.client = self.make_client()
70
+
71
+ return self.client
72
+
73
+ def get_async_client(self) -> Client:
74
+ if self.async_client is None:
75
+ self.async_client = self.make_async_client()
76
+
77
+ return self.async_client
78
+
79
+ def make_client(self):
80
+ return Client() # base_url=url
81
+
82
+ def make_async_client(self):
83
+ return AsyncClient() # base_url=url
84
+
85
+ class Request(BaseModel):
86
+ method: str # component:service:method
87
+ args: tuple[Any, ...]
88
+
89
+ class Response(BaseModel):
90
+ result: Optional[Any]
91
+ exception: Optional[Any]
92
+
93
+ @channel("dispatch-json")
94
+ class DispatchJSONChannel(HTTPXChannel):
95
+ # constructor
96
+
97
+ def __init__(self):
98
+ super().__init__("dispatch-json")
99
+
100
+ # internal
101
+
102
+ # implement Channel
103
+
104
+ def set_address(self, address: Optional[ServiceAddress]):
105
+ ServiceManager.logger.info("channel %s got an address %s", self.name, address)
106
+
107
+ super().set_address(address)
108
+
109
+ def setup(self, component_descriptor: ComponentDescriptor, address: ServiceAddress):
110
+ super().setup(component_descriptor, address)
111
+
112
+ def invoke(self, invocation: DynamicProxy.Invocation):
113
+ service_name = self.service_names[invocation.type] # map type to registered service name
114
+ request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}", args=invocation.args)
115
+
116
+ try:
117
+ if self.client is not None:
118
+ result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=request.dict(), timeout=30000.0).json())
119
+ if result.exception is not None:
120
+ raise RemoteServiceException(f"server side exception {result.exception}")
121
+
122
+ return self.get_deserializer(invocation.type, invocation.method)(result.result)
123
+ else:
124
+ raise ServiceException(f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
125
+ except Exception as e:
126
+ raise ServiceException(f"communication exception {e}") from e
127
+
128
+ async def invoke_async(self, invocation: DynamicProxy.Invocation):
129
+ service_name = self.service_names[invocation.type] # map type to registered service name
130
+ request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
131
+ args=invocation.args)
132
+
133
+ try:
134
+ if self.async_client is not None:
135
+ data = await self.async_client.post(f"{self.get_url()}/invoke", json=request.dict(), timeout=30000.0)
136
+ result = Response(**data.json())
137
+ if result.exception is not None:
138
+ raise RemoteServiceException(f"server side exception {result.exception}")
139
+
140
+ return self.get_deserializer(invocation.type, invocation.method)(result.result)
141
+ else:
142
+ raise ServiceException(
143
+ f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
144
+ except Exception as e:
145
+ raise ServiceException(f"communication exception {e}") from e
146
+
147
+ @channel("dispatch-msgpack")
148
+ class DispatchMSPackChannel(HTTPXChannel):
149
+ # constructor
150
+
151
+ def __init__(self):
152
+ super().__init__("dispatch-msgpack")
153
+
154
+ # override
155
+
156
+ def set_address(self, address: Optional[ServiceAddress]):
157
+ ServiceManager.logger.info("channel %s got an address %s", self.name, address)
158
+
159
+ super().set_address(address)
160
+
161
+ def invoke(self, invocation: DynamicProxy.Invocation):
162
+ service_name = self.service_names[invocation.type] # map type to registered service name
163
+ request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
164
+ args=invocation.args)
165
+
166
+ try:
167
+ packed = msgpack.packb(request.dict(), use_bin_type=True)
168
+
169
+ response = self.get_client().post(
170
+ f"{self.get_url()}/invoke",
171
+ content=packed,
172
+ headers={"Content-Type": "application/msgpack"},
173
+ timeout=30.0
174
+ )
175
+
176
+ result = msgpack.unpackb(response.content, raw=False)
177
+
178
+ if result.get("exception", None):
179
+ raise RemoteServiceException(f"server-side: {result['exception']}")
180
+
181
+ return self.get_deserializer(invocation.type, invocation.method)(result["result"])
182
+
183
+ except Exception as e:
184
+ raise ServiceException(f"msgpack exception: {e}") from e
185
+
186
+ async def invoke_async(self, invocation: DynamicProxy.Invocation):
187
+ service_name = self.service_names[invocation.type] # map type to registered service name
188
+ request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
189
+ args=invocation.args)
190
+
191
+ try:
192
+ packed = msgpack.packb(request.dict(), use_bin_type=True)
193
+
194
+ response = await self.get_async_client().post(
195
+ f"{self.get_url()}/invoke",
196
+ content=packed,
197
+ headers={"Content-Type": "application/msgpack"},
198
+ timeout=30.0
199
+ )
200
+
201
+ result = msgpack.unpackb(response.content, raw=False)
202
+
203
+ if result.get("exception", None):
204
+ raise RemoteServiceException(f"server-side: {result['exception']}")
205
+
206
+ return self.get_deserializer(invocation.type, invocation.method)(result["result"])
207
+
208
+ except Exception as e:
209
+ raise ServiceException(f"msgpack exception: {e}") from e
210
+
@@ -0,0 +1,178 @@
1
+ """
2
+ health checks
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ import time
9
+ from enum import Enum
10
+ from typing import Any, Callable, Type, Optional
11
+
12
+ from aspyx.di import Providers, ClassInstanceProvider, injectable, Environment, inject_environment, on_init
13
+ from aspyx.reflection import Decorators, TypeDescriptor
14
+
15
+
16
+ def health_checks():
17
+ """
18
+ Instances of classes that are annotated with @injectable can be created by an Environment.
19
+ """
20
+ def decorator(cls):
21
+ Decorators.add(cls, health_checks)
22
+
23
+ #if not Providers.is_registered(cls):
24
+ # Providers.register(ClassInstanceProvider(cls, True, "singleton"))
25
+
26
+ HealthCheckManager.types.append(cls)
27
+
28
+ return cls
29
+
30
+ return decorator
31
+
32
+ def health_check(name="", cache = 0, fail_if_slower_than = 0):
33
+ """
34
+ Methods annotated with `@on_init` will be called when the instance is created."""
35
+ def decorator(func):
36
+ Decorators.add(func, health_check, name, cache, fail_if_slower_than)
37
+ return func
38
+
39
+ return decorator
40
+
41
+ class HealthStatus(Enum):
42
+ OK = 1
43
+ WARNING = 2
44
+ ERROR = 3
45
+
46
+ def __str__(self):
47
+ return self.name
48
+
49
+
50
+ @injectable()
51
+ class HealthCheckManager:
52
+ logger = logging.getLogger("aspyx.service.health")
53
+
54
+ # local classes
55
+
56
+ class Check:
57
+ def __init__(self, name: str, cache: int, fail_if_slower_than: int, instance: Any, callable: Callable):
58
+ self.name = name
59
+ self.cache = cache
60
+ self.callable = callable
61
+ self.instance = instance
62
+ self.fail_if_slower_than = fail_if_slower_than
63
+ self.last_check = 0
64
+
65
+ self.last_value : Optional[HealthCheckManager.Result] = None
66
+
67
+ async def run(self, result: HealthCheckManager.Result):
68
+ now = time.time()
69
+
70
+ if self.cache > 0 and self.last_check is not None and now - self.last_check < self.cache:
71
+ result.copy_from(self.last_value)
72
+ return
73
+
74
+ self.last_check = now
75
+ self.last_value = result
76
+
77
+ if asyncio.iscoroutinefunction(self.callable):
78
+ await self.callable(self.instance, result)
79
+ else:
80
+ await asyncio.to_thread(self.callable, self.instance, result)
81
+
82
+ spent = time.time() - now
83
+
84
+ if result.status == HealthStatus.OK and 0 < self.fail_if_slower_than < spent:
85
+ result.status = HealthStatus.ERROR
86
+ result.details = f"spent {spent:.3f}s"
87
+
88
+
89
+ class Result:
90
+ def __init__(self, name: str):
91
+ self.status = HealthStatus.OK
92
+ self.name = name
93
+ self.details = ""
94
+
95
+ def copy_from(self, value: HealthCheckManager.Result):
96
+ self.status = value.status
97
+ self.details = value.details
98
+
99
+ def set_status(self, status: HealthStatus, details =""):
100
+ self.status = status
101
+ self.details = details
102
+
103
+ def to_dict(self):
104
+ result = {
105
+ "name": self.name,
106
+ "status": str(self.status),
107
+ }
108
+
109
+ if len(self.details) > 0:
110
+ result["details"] = self.details
111
+
112
+ return result
113
+
114
+ class Health:
115
+ def __init__(self):
116
+ self.status = HealthStatus.OK
117
+ self.results : list[HealthCheckManager.Result] = []
118
+
119
+ def to_dict(self):
120
+ return {
121
+ "status": str(self.status),
122
+ "checks": [result.to_dict() for result in self.results]
123
+ }
124
+
125
+ # class data
126
+
127
+ types : list[Type] = []
128
+
129
+ # constructor
130
+
131
+ def __init__(self):
132
+ self.environment : Optional[Environment] = None
133
+ self.checks: list[HealthCheckManager.Check] = []
134
+
135
+ # check
136
+
137
+ async def check(self) -> HealthCheckManager.Health:
138
+ self.logger.info("Checking health...")
139
+
140
+ health = HealthCheckManager.Health()
141
+
142
+ tasks = []
143
+ for check in self.checks:
144
+ result = HealthCheckManager.Result(check.name)
145
+ health.results.append(result)
146
+ tasks.append(check.run(result))
147
+
148
+ await asyncio.gather(*tasks)
149
+
150
+ for result in health.results:
151
+ if result.status.value > health.status.value:
152
+ health.status = result.status
153
+
154
+ return health
155
+
156
+ # public
157
+
158
+ @inject_environment()
159
+ def set_environment(self, environment: Environment):
160
+ self.environment = environment
161
+
162
+ @on_init()
163
+ def setup(self):
164
+ for type in self.types:
165
+ descriptor = TypeDescriptor(type).for_type(type)
166
+ instance = self.environment.get(type)
167
+
168
+ for method in descriptor.get_methods():
169
+ if method.has_decorator(health_check):
170
+ decorator = method.get_decorator(health_check)
171
+
172
+ name = decorator.args[0]
173
+ cache = decorator.args[1]
174
+ fail_if_slower_than = decorator.args[2]
175
+ if len(name) == 0:
176
+ name = method.get_name()
177
+
178
+ self.checks.append(HealthCheckManager.Check(name, cache, fail_if_slower_than, instance, method.method))
@@ -0,0 +1,227 @@
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
+ from urllib.parse import urlparse
10
+
11
+ import consul
12
+
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, ServiceAddress, ServiceManager, ComponentDescriptor, Component, ChannelAddress
19
+
20
+ class ConsulComponentRegistry(ComponentRegistry):
21
+ # constructor
22
+
23
+ def __init__(self, port: int, consul_url: str):
24
+ self.port = port
25
+ self.ip = Server.get_local_ip()
26
+ self.running = False
27
+ self.consul = None
28
+ self.watchdog = None
29
+ self.interval = 5
30
+ self.last_index = {}
31
+ self.component_addresses : dict[str, dict[str, ServiceAddress]] = {} # comp -> channel -> address
32
+ self.watch_channels : list[Channel] = []
33
+
34
+ parsed = urlparse(consul_url)
35
+
36
+ self.consul_host = parsed.hostname
37
+ self.consul_port = parsed.port
38
+
39
+ def make_consul(self, host: str, port: int) -> consul.Consul:
40
+ return consul.Consul(host=host, port=port)
41
+
42
+ # lifecycle hooks
43
+
44
+ @on_init()
45
+ def setup(self):
46
+ # create consul client
47
+
48
+ self.consul = self.make_consul(host=self.consul_host, port=self.consul_port)
49
+ self.running = True
50
+
51
+ # start thread
52
+
53
+ self.watchdog = threading.Thread(target=self.watch_consul, daemon=True)
54
+ self.watchdog.start()
55
+
56
+ def inform_channels(self, old_address: ServiceAddress, new_address: Optional[ServiceAddress]):
57
+ for channel in self.watch_channels:
58
+ if channel.address is old_address:
59
+ channel.set_address(new_address)
60
+
61
+
62
+ def watch_consul(self):
63
+ while self.running:
64
+ # check services
65
+
66
+ for component, old_addresses in self.component_addresses.items():
67
+ old_addresses = dict(old_addresses) # we will modify it...
68
+ new_addresses = self.fetch_addresses(component, wait="1s")
69
+
70
+ # compare
71
+
72
+ changed = False
73
+ for channel_name, address in new_addresses.items():
74
+ service_address = old_addresses.get(channel_name, None)
75
+ if service_address is None:
76
+ ServiceManager.logger.info("new %s address for %s", channel_name, component)
77
+ changed = True
78
+ else:
79
+ if service_address != address:
80
+ changed = True
81
+
82
+ ServiceManager.logger.info("%s address for %s changed", channel_name, component)
83
+
84
+ # inform channels
85
+
86
+ self.inform_channels(service_address, address)
87
+
88
+ # delete
89
+
90
+ del old_addresses[channel_name]
91
+
92
+ # watchout for deleted addresses
93
+
94
+ for channel_name, address in old_addresses.items():
95
+ ServiceManager.logger.info("deleted %s address for %s", channel_name, component)
96
+
97
+ changed = True
98
+
99
+ # inform channel
100
+
101
+ self.inform_channels(address, None)
102
+
103
+ # replace ( does that work while iterating )
104
+
105
+ if changed:
106
+ self.component_addresses[component] = new_addresses
107
+
108
+ # time to sleep
109
+
110
+ time.sleep(5)
111
+
112
+ @abstractmethod
113
+ def watch(self, channel: Channel):
114
+ self.watch_channels.append(channel)
115
+
116
+ #self.component_addresses[channel.component_descriptor.name] = {}
117
+
118
+ # public
119
+
120
+ def register_service(self, name, service_id, health: str, tags=None, meta=None):
121
+ ip = "host.docker.internal" # TODO
122
+
123
+ self.consul.agent.service.register(
124
+ name=name,
125
+ service_id=service_id,
126
+ address=self.ip,
127
+ port=self.port,
128
+ tags=tags or [],
129
+ meta=meta or {},
130
+ check=consul.Check().http(
131
+ url=f"http://{ip}:{self.port}{health}",
132
+ interval="10s",
133
+ timeout="3s",
134
+ deregister="5m")
135
+ )
136
+
137
+ def deregister(self, descriptor: ComponentDescriptor[Component]):
138
+ name = descriptor.name
139
+
140
+ service_id = f"{self.ip}:{self.port}:{name}"
141
+
142
+ self.consul.agent.service.deregister(service_id)
143
+
144
+ def stop(self):
145
+ self.running = False
146
+ if self.watchdog is not None:
147
+ self.watchdog.join()
148
+ self.watchdog = None
149
+
150
+ # private
151
+
152
+ def fetch_addresses(self, component: str, wait=None) -> dict[str, ServiceAddress]:
153
+ addresses : dict[str, ServiceAddress] = {} # channel name -> ServiceAddress
154
+
155
+ index, nodes = self.consul.health.service(component, passing=True, index=self.last_index.get(component, None), wait=wait)
156
+ self.last_index[component] = index
157
+
158
+ for node in nodes:
159
+ service = node["Service"]
160
+
161
+ meta = service.get('Meta')
162
+
163
+ channels = meta.get("channels").split(",")
164
+
165
+ for channel in channels:
166
+ match = re.search(r"([\w-]+)\((.*)\)", channel)
167
+
168
+ channel_name = match.group(1)
169
+ url = match.group(2)
170
+
171
+ address = addresses.get(channel, None)
172
+ if address is None:
173
+ address = ServiceAddress(component=component, channel=channel_name)
174
+ addresses[channel] = address
175
+
176
+ address.urls.append(url)
177
+
178
+ # done
179
+
180
+ return addresses
181
+
182
+ # implement ComponentRegistry
183
+
184
+ def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]):
185
+ name = descriptor.name
186
+
187
+ id = f"{self.ip}:{self.port}:{name}"
188
+
189
+ builder = StringBuilder()
190
+ first = True
191
+ for address in addresses:
192
+ if not first:
193
+ builder.append(",")
194
+
195
+ builder.append(address.channel).append("(").append(address.uri).append(")")
196
+
197
+ first = False
198
+
199
+ addresses = str(builder)
200
+
201
+ self.register_service(name, id, descriptor.health, tags =["component"], meta={"channels": addresses})
202
+
203
+ def get_addresses(self, descriptor: ComponentDescriptor) -> list[ServiceAddress]:
204
+ component_addresses = self.component_addresses.get(descriptor.name, None)
205
+ if component_addresses is None:
206
+ component_addresses = self.fetch_addresses(descriptor.name)
207
+
208
+ # only cache if non-empty
209
+
210
+ if len(component_addresses) > 0:
211
+ self.component_addresses[descriptor.name] = component_addresses
212
+
213
+ return list(component_addresses.values())
214
+
215
+ #200–299 passing Service is healthy (OK, Created, No Content…)
216
+ #429 warning Rate limited or degraded
217
+ #300–399 warning Redirects interpreted as potential issues
218
+ #400–499 critical Client errors (Bad Request, Unauthorized…)
219
+ #500–599 critical Server errors (Internal Error, Timeout…)
220
+ #Other / No response critical Timeout, connection refused, etc.
221
+ def map_health(self, health: HealthCheckManager.Health) -> int:
222
+ if health.status is HealthStatus.OK:
223
+ return 200
224
+ elif health.status is HealthStatus.WARNING:
225
+ return 429
226
+ else:
227
+ return 500