aspyx-service 0.9.0__tar.gz → 0.10.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.
Potentially problematic release.
This version of aspyx-service might be problematic. Click here for more details.
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/PKG-INFO +4 -3
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/pyproject.toml +5 -6
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/__init__.py +7 -2
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/channels.py +16 -10
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/healthcheck.py +20 -4
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/registries.py +27 -14
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/restchannel.py +54 -12
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/serialization.py +10 -1
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/server.py +28 -10
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/src/aspyx_service/service.py +196 -53
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/tests/test-service.py +65 -37
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/.gitignore +0 -0
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/LICENSE +0 -0
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/README.md +0 -0
- {aspyx_service-0.9.0 → aspyx_service-0.10.0}/tests/config.yaml +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aspyx_service
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Service framework
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: Aspyx Service framework
|
|
5
5
|
Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
|
|
6
6
|
License: MIT License
|
|
7
7
|
|
|
@@ -26,11 +26,12 @@ License: MIT License
|
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Python: >=3.9
|
|
29
|
-
Requires-Dist: aspyx>=1.
|
|
29
|
+
Requires-Dist: aspyx>=1.5.0
|
|
30
30
|
Requires-Dist: fastapi~=0.115.13
|
|
31
31
|
Requires-Dist: httpx~=0.28.1
|
|
32
32
|
Requires-Dist: msgpack~=1.1.1
|
|
33
33
|
Requires-Dist: python-consul2~=0.1.5
|
|
34
|
+
Requires-Dist: uvicorn[standard]
|
|
34
35
|
Description-Content-Type: text/markdown
|
|
35
36
|
|
|
36
37
|
aspyx-service
|
|
@@ -2,22 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
[project]
|
|
4
4
|
name = "aspyx_service"
|
|
5
|
-
version = "0.
|
|
6
|
-
description = "Service framework
|
|
5
|
+
version = "0.10.0"
|
|
6
|
+
description = "Aspyx Service framework"
|
|
7
7
|
authors = [{ name = "Andreas Ernst", email = "andreas.ernst7@gmail.com" }]
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
license = { file = "LICENSE" }
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"aspyx>=1.
|
|
12
|
+
"aspyx>=1.5.0",
|
|
13
13
|
"python-consul2~=0.1.5",
|
|
14
14
|
"fastapi~=0.115.13",
|
|
15
15
|
"httpx~=0.28.1",
|
|
16
|
-
"msgpack~=1.1.1"
|
|
16
|
+
"msgpack~=1.1.1",
|
|
17
|
+
"uvicorn[standard]"
|
|
17
18
|
]
|
|
18
19
|
|
|
19
|
-
#"aspyx @ file://../aspyx", # Local editable ref
|
|
20
|
-
|
|
21
20
|
[build-system]
|
|
22
21
|
requires = ["hatchling"]
|
|
23
22
|
build-backend = "hatchling.build"
|
|
@@ -4,7 +4,7 @@ This module provides the core Aspyx service management framework allowing for se
|
|
|
4
4
|
|
|
5
5
|
from aspyx.di import module
|
|
6
6
|
|
|
7
|
-
from .service import ServiceException, Server, Channel, ComponentDescriptor, inject_service, ChannelAddress,
|
|
7
|
+
from .service import ServiceException, Server, Channel, ComponentDescriptor, inject_service, ChannelAddress, ChannelInstances, ServiceManager, Component, Service, AbstractComponent, ComponentStatus, ComponentRegistry, implementation, health, component, service
|
|
8
8
|
from .channels import HTTPXChannel, DispatchJSONChannel
|
|
9
9
|
from .registries import ConsulComponentRegistry
|
|
10
10
|
from .server import FastAPIServer
|
|
@@ -32,7 +32,7 @@ __all__ = [
|
|
|
32
32
|
"ComponentDescriptor",
|
|
33
33
|
"ComponentRegistry",
|
|
34
34
|
"ChannelAddress",
|
|
35
|
-
"
|
|
35
|
+
"ChannelInstances",
|
|
36
36
|
"health",
|
|
37
37
|
"component",
|
|
38
38
|
"service",
|
|
@@ -41,6 +41,11 @@ __all__ = [
|
|
|
41
41
|
|
|
42
42
|
# healthcheck
|
|
43
43
|
|
|
44
|
+
"health_checks",
|
|
45
|
+
"health_check",
|
|
46
|
+
"HealthStatus",
|
|
47
|
+
"HealthCheckManager",
|
|
48
|
+
|
|
44
49
|
# serialization
|
|
45
50
|
|
|
46
51
|
# "deserialize",
|
|
@@ -12,7 +12,7 @@ from pydantic import BaseModel
|
|
|
12
12
|
from aspyx.reflection import DynamicProxy, TypeDescriptor
|
|
13
13
|
from .service import ServiceManager
|
|
14
14
|
|
|
15
|
-
from .service import ComponentDescriptor,
|
|
15
|
+
from .service import ComponentDescriptor, ChannelInstances, ServiceException, channel, Channel, RemoteServiceException
|
|
16
16
|
from .serialization import get_deserializer
|
|
17
17
|
|
|
18
18
|
|
|
@@ -26,8 +26,8 @@ class HTTPXChannel(Channel):
|
|
|
26
26
|
|
|
27
27
|
# constructor
|
|
28
28
|
|
|
29
|
-
def __init__(self
|
|
30
|
-
super().__init__(
|
|
29
|
+
def __init__(self):
|
|
30
|
+
super().__init__()
|
|
31
31
|
|
|
32
32
|
self.client: Optional[Client] = None
|
|
33
33
|
self.async_client: Optional[AsyncClient] = None
|
|
@@ -49,7 +49,7 @@ class HTTPXChannel(Channel):
|
|
|
49
49
|
|
|
50
50
|
# override
|
|
51
51
|
|
|
52
|
-
def setup(self, component_descriptor: ComponentDescriptor, address:
|
|
52
|
+
def setup(self, component_descriptor: ComponentDescriptor, address: ChannelInstances):
|
|
53
53
|
super().setup(component_descriptor, address)
|
|
54
54
|
|
|
55
55
|
# remember service names
|
|
@@ -92,21 +92,25 @@ class Response(BaseModel):
|
|
|
92
92
|
|
|
93
93
|
@channel("dispatch-json")
|
|
94
94
|
class DispatchJSONChannel(HTTPXChannel):
|
|
95
|
+
"""
|
|
96
|
+
A channel that calls a POST on th endpoint `ìnvoke` sending a request body containing information on the
|
|
97
|
+
called component, service and method and the arguments.
|
|
98
|
+
"""
|
|
95
99
|
# constructor
|
|
96
100
|
|
|
97
101
|
def __init__(self):
|
|
98
|
-
super().__init__(
|
|
102
|
+
super().__init__()
|
|
99
103
|
|
|
100
104
|
# internal
|
|
101
105
|
|
|
102
106
|
# implement Channel
|
|
103
107
|
|
|
104
|
-
def set_address(self, address: Optional[
|
|
108
|
+
def set_address(self, address: Optional[ChannelInstances]):
|
|
105
109
|
ServiceManager.logger.info("channel %s got an address %s", self.name, address)
|
|
106
110
|
|
|
107
111
|
super().set_address(address)
|
|
108
112
|
|
|
109
|
-
def setup(self, component_descriptor: ComponentDescriptor, address:
|
|
113
|
+
def setup(self, component_descriptor: ComponentDescriptor, address: ChannelInstances):
|
|
110
114
|
super().setup(component_descriptor, address)
|
|
111
115
|
|
|
112
116
|
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
@@ -146,14 +150,17 @@ class DispatchJSONChannel(HTTPXChannel):
|
|
|
146
150
|
|
|
147
151
|
@channel("dispatch-msgpack")
|
|
148
152
|
class DispatchMSPackChannel(HTTPXChannel):
|
|
153
|
+
"""
|
|
154
|
+
A channel that sends a POST on the ìnvoke `endpoint`with an msgpack encoded request body.
|
|
155
|
+
"""
|
|
149
156
|
# constructor
|
|
150
157
|
|
|
151
158
|
def __init__(self):
|
|
152
|
-
super().__init__(
|
|
159
|
+
super().__init__()
|
|
153
160
|
|
|
154
161
|
# override
|
|
155
162
|
|
|
156
|
-
def set_address(self, address: Optional[
|
|
163
|
+
def set_address(self, address: Optional[ChannelInstances]):
|
|
157
164
|
ServiceManager.logger.info("channel %s got an address %s", self.name, address)
|
|
158
165
|
|
|
159
166
|
super().set_address(address)
|
|
@@ -207,4 +214,3 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
207
214
|
|
|
208
215
|
except Exception as e:
|
|
209
216
|
raise ServiceException(f"msgpack exception: {e}") from e
|
|
210
|
-
|
|
@@ -9,7 +9,7 @@ import time
|
|
|
9
9
|
from enum import Enum
|
|
10
10
|
from typing import Any, Callable, Type, Optional
|
|
11
11
|
|
|
12
|
-
from aspyx.di import
|
|
12
|
+
from aspyx.di import injectable, Environment, inject_environment, on_init
|
|
13
13
|
from aspyx.reflection import Decorators, TypeDescriptor
|
|
14
14
|
|
|
15
15
|
|
|
@@ -31,7 +31,8 @@ def health_checks():
|
|
|
31
31
|
|
|
32
32
|
def health_check(name="", cache = 0, fail_if_slower_than = 0):
|
|
33
33
|
"""
|
|
34
|
-
Methods annotated with `@on_init` will be called when the instance is created.
|
|
34
|
+
Methods annotated with `@on_init` will be called when the instance is created.
|
|
35
|
+
"""
|
|
35
36
|
def decorator(func):
|
|
36
37
|
Decorators.add(func, health_check, name, cache, fail_if_slower_than)
|
|
37
38
|
return func
|
|
@@ -39,6 +40,13 @@ def health_check(name="", cache = 0, fail_if_slower_than = 0):
|
|
|
39
40
|
return decorator
|
|
40
41
|
|
|
41
42
|
class HealthStatus(Enum):
|
|
43
|
+
"""
|
|
44
|
+
A enum specifying the health status of a service. The values are:
|
|
45
|
+
|
|
46
|
+
- `OK` service is healthy
|
|
47
|
+
- `WARNING` service has some problems
|
|
48
|
+
- `CRITICAL` service is unhealthy
|
|
49
|
+
"""
|
|
42
50
|
OK = 1
|
|
43
51
|
WARNING = 2
|
|
44
52
|
ERROR = 3
|
|
@@ -49,6 +57,9 @@ class HealthStatus(Enum):
|
|
|
49
57
|
|
|
50
58
|
@injectable()
|
|
51
59
|
class HealthCheckManager:
|
|
60
|
+
"""
|
|
61
|
+
The health manager is able to run all registered health checks and is able to return an overall status.
|
|
62
|
+
"""
|
|
52
63
|
logger = logging.getLogger("aspyx.service.health")
|
|
53
64
|
|
|
54
65
|
# local classes
|
|
@@ -112,8 +123,8 @@ class HealthCheckManager:
|
|
|
112
123
|
return result
|
|
113
124
|
|
|
114
125
|
class Health:
|
|
115
|
-
def __init__(self):
|
|
116
|
-
self.status =
|
|
126
|
+
def __init__(self, status: HealthStatus = HealthStatus.OK):
|
|
127
|
+
self.status = status
|
|
117
128
|
self.results : list[HealthCheckManager.Result] = []
|
|
118
129
|
|
|
119
130
|
def to_dict(self):
|
|
@@ -135,6 +146,11 @@ class HealthCheckManager:
|
|
|
135
146
|
# check
|
|
136
147
|
|
|
137
148
|
async def check(self) -> HealthCheckManager.Health:
|
|
149
|
+
"""
|
|
150
|
+
run all registered health checks and return an overall result.
|
|
151
|
+
Returns: the overall result.
|
|
152
|
+
|
|
153
|
+
"""
|
|
138
154
|
self.logger.info("Checking health...")
|
|
139
155
|
|
|
140
156
|
health = HealthCheckManager.Health()
|
|
@@ -15,9 +15,13 @@ from aspyx.di import on_init
|
|
|
15
15
|
from .healthcheck import HealthCheckManager, HealthStatus
|
|
16
16
|
|
|
17
17
|
from .server import Server
|
|
18
|
-
from .service import ComponentRegistry, Channel,
|
|
18
|
+
from .service import ComponentRegistry, Channel, ChannelInstances, ServiceManager, ComponentDescriptor, Component, ChannelAddress
|
|
19
19
|
|
|
20
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
|
+
"""
|
|
21
25
|
# constructor
|
|
22
26
|
|
|
23
27
|
def __init__(self, port: int, consul_url: str):
|
|
@@ -28,7 +32,7 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
28
32
|
self.watchdog = None
|
|
29
33
|
self.interval = 5
|
|
30
34
|
self.last_index = {}
|
|
31
|
-
self.component_addresses : dict[str, dict[str,
|
|
35
|
+
self.component_addresses : dict[str, dict[str, ChannelInstances]] = {} # comp -> channel -> address
|
|
32
36
|
self.watch_channels : list[Channel] = []
|
|
33
37
|
|
|
34
38
|
parsed = urlparse(consul_url)
|
|
@@ -36,7 +40,18 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
36
40
|
self.consul_host = parsed.hostname
|
|
37
41
|
self.consul_port = parsed.port
|
|
38
42
|
|
|
39
|
-
def make_consul(self, host
|
|
43
|
+
def make_consul(self, host="", port="") -> consul.Consul:
|
|
44
|
+
"""
|
|
45
|
+
create and return a consul instance
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
host: the host
|
|
49
|
+
port: the port
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
a consul instance
|
|
53
|
+
|
|
54
|
+
"""
|
|
40
55
|
return consul.Consul(host=host, port=port)
|
|
41
56
|
|
|
42
57
|
# lifecycle hooks
|
|
@@ -53,7 +68,7 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
53
68
|
self.watchdog = threading.Thread(target=self.watch_consul, daemon=True)
|
|
54
69
|
self.watchdog.start()
|
|
55
70
|
|
|
56
|
-
def inform_channels(self, old_address:
|
|
71
|
+
def inform_channels(self, old_address: ChannelInstances, new_address: Optional[ChannelInstances]):
|
|
57
72
|
for channel in self.watch_channels:
|
|
58
73
|
if channel.address is old_address:
|
|
59
74
|
channel.set_address(new_address)
|
|
@@ -110,16 +125,14 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
110
125
|
time.sleep(5)
|
|
111
126
|
|
|
112
127
|
@abstractmethod
|
|
113
|
-
def watch(self, channel: Channel):
|
|
128
|
+
def watch(self, channel: Channel) -> None:
|
|
114
129
|
self.watch_channels.append(channel)
|
|
115
130
|
|
|
116
131
|
#self.component_addresses[channel.component_descriptor.name] = {}
|
|
117
132
|
|
|
118
133
|
# public
|
|
119
134
|
|
|
120
|
-
def register_service(self, name, service_id, health: str, tags=None, meta=None):
|
|
121
|
-
ip = "host.docker.internal" # TODO
|
|
122
|
-
|
|
135
|
+
def register_service(self, name, service_id, health: str, tags=None, meta=None) -> None:
|
|
123
136
|
self.consul.agent.service.register(
|
|
124
137
|
name=name,
|
|
125
138
|
service_id=service_id,
|
|
@@ -128,13 +141,13 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
128
141
|
tags=tags or [],
|
|
129
142
|
meta=meta or {},
|
|
130
143
|
check=consul.Check().http(
|
|
131
|
-
url=f"http://{ip}:{self.port}{health}",
|
|
144
|
+
url=f"http://{self.ip}:{self.port}{health}",
|
|
132
145
|
interval="10s",
|
|
133
146
|
timeout="3s",
|
|
134
147
|
deregister="5m")
|
|
135
148
|
)
|
|
136
149
|
|
|
137
|
-
def deregister(self, descriptor: ComponentDescriptor[Component]):
|
|
150
|
+
def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
|
|
138
151
|
name = descriptor.name
|
|
139
152
|
|
|
140
153
|
service_id = f"{self.ip}:{self.port}:{name}"
|
|
@@ -149,8 +162,8 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
149
162
|
|
|
150
163
|
# private
|
|
151
164
|
|
|
152
|
-
def fetch_addresses(self, component: str, wait=None) -> dict[str,
|
|
153
|
-
addresses : dict[str,
|
|
165
|
+
def fetch_addresses(self, component: str, wait=None) -> dict[str, ChannelInstances]:
|
|
166
|
+
addresses : dict[str, ChannelInstances] = {} # channel name -> ServiceAddress
|
|
154
167
|
|
|
155
168
|
index, nodes = self.consul.health.service(component, passing=True, index=self.last_index.get(component, None), wait=wait)
|
|
156
169
|
self.last_index[component] = index
|
|
@@ -170,7 +183,7 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
170
183
|
|
|
171
184
|
address = addresses.get(channel, None)
|
|
172
185
|
if address is None:
|
|
173
|
-
address =
|
|
186
|
+
address = ChannelInstances(component=component, channel=channel_name)
|
|
174
187
|
addresses[channel] = address
|
|
175
188
|
|
|
176
189
|
address.urls.append(url)
|
|
@@ -200,7 +213,7 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
200
213
|
|
|
201
214
|
self.register_service(name, id, descriptor.health, tags =["component"], meta={"channels": addresses})
|
|
202
215
|
|
|
203
|
-
def get_addresses(self, descriptor: ComponentDescriptor) -> list[
|
|
216
|
+
def get_addresses(self, descriptor: ComponentDescriptor) -> list[ChannelInstances]:
|
|
204
217
|
component_addresses = self.component_addresses.get(descriptor.name, None)
|
|
205
218
|
if component_addresses is None:
|
|
206
219
|
component_addresses = self.fetch_addresses(descriptor.name)
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rest channel implementation
|
|
3
|
+
"""
|
|
1
4
|
import inspect
|
|
2
5
|
import re
|
|
3
6
|
from dataclasses import is_dataclass, asdict
|
|
@@ -7,7 +10,7 @@ from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get
|
|
|
7
10
|
from pydantic import BaseModel
|
|
8
11
|
|
|
9
12
|
from aspyx.reflection import DynamicProxy, Decorators
|
|
10
|
-
from .service import
|
|
13
|
+
from .service import ServiceException, channel
|
|
11
14
|
|
|
12
15
|
T = TypeVar("T")
|
|
13
16
|
|
|
@@ -25,14 +28,26 @@ QueryParam = lambda t: Annotated[t, QueryParamMarker]
|
|
|
25
28
|
|
|
26
29
|
# decorators
|
|
27
30
|
|
|
28
|
-
def rest(url):
|
|
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
|
+
"""
|
|
29
38
|
def decorator(cls):
|
|
30
39
|
Decorators.add(cls, rest, url)
|
|
31
40
|
|
|
32
41
|
return cls
|
|
33
42
|
return decorator
|
|
34
43
|
|
|
35
|
-
def get(url):
|
|
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
|
+
"""
|
|
36
51
|
def decorator(cls):
|
|
37
52
|
Decorators.add(cls, get, url)
|
|
38
53
|
|
|
@@ -40,7 +55,14 @@ def get(url):
|
|
|
40
55
|
return decorator
|
|
41
56
|
|
|
42
57
|
|
|
43
|
-
def post(url):
|
|
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
|
+
"""
|
|
44
66
|
def decorator(cls):
|
|
45
67
|
Decorators.add(cls, post, url)
|
|
46
68
|
|
|
@@ -48,7 +70,13 @@ def post(url):
|
|
|
48
70
|
|
|
49
71
|
return decorator
|
|
50
72
|
|
|
51
|
-
def put(url):
|
|
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
|
+
"""
|
|
52
80
|
def decorator(cls):
|
|
53
81
|
Decorators.add(cls, put, url)
|
|
54
82
|
|
|
@@ -56,7 +84,13 @@ def put(url):
|
|
|
56
84
|
|
|
57
85
|
return decorator
|
|
58
86
|
|
|
59
|
-
def delete(url):
|
|
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
|
+
"""
|
|
60
94
|
def decorator(cls):
|
|
61
95
|
Decorators.add(cls, delete, url)
|
|
62
96
|
|
|
@@ -64,7 +98,13 @@ def delete(url):
|
|
|
64
98
|
|
|
65
99
|
return decorator
|
|
66
100
|
|
|
67
|
-
def patch(url):
|
|
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
|
+
"""
|
|
68
108
|
def decorator(cls):
|
|
69
109
|
Decorators.add(cls, patch, url)
|
|
70
110
|
|
|
@@ -72,20 +112,22 @@ def patch(url):
|
|
|
72
112
|
|
|
73
113
|
return decorator
|
|
74
114
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
115
|
@channel("rest")
|
|
79
116
|
class RestChannel(HTTPXChannel):
|
|
117
|
+
"""
|
|
118
|
+
A rest channel executes http requests as specified by the corresponding decorators and annotations,
|
|
119
|
+
"""
|
|
80
120
|
__slots__ = [
|
|
81
121
|
"signature",
|
|
82
122
|
"url_template",
|
|
83
123
|
"type",
|
|
124
|
+
"calls",
|
|
84
125
|
"return_type",
|
|
85
126
|
"path_param_names",
|
|
86
127
|
"query_param_names",
|
|
87
128
|
"body_param_name"
|
|
88
129
|
]
|
|
130
|
+
|
|
89
131
|
# local class
|
|
90
132
|
|
|
91
133
|
class Call:
|
|
@@ -133,7 +175,7 @@ class RestChannel(HTTPXChannel):
|
|
|
133
175
|
# constructor
|
|
134
176
|
|
|
135
177
|
def __init__(self):
|
|
136
|
-
super().__init__(
|
|
178
|
+
super().__init__()
|
|
137
179
|
|
|
138
180
|
self.calls : dict[Callable, RestChannel.Call] = {}
|
|
139
181
|
|
|
@@ -196,4 +238,4 @@ class RestChannel(HTTPXChannel):
|
|
|
196
238
|
raise ServiceException(
|
|
197
239
|
f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
|
|
198
240
|
except Exception as e:
|
|
199
|
-
raise ServiceException(f"communication exception {e}") from e
|
|
241
|
+
raise ServiceException(f"communication exception {e}") from e
|
|
@@ -98,7 +98,7 @@ class TypeDeserializer:
|
|
|
98
98
|
return deser_union
|
|
99
99
|
|
|
100
100
|
if isinstance(typ, type) and issubclass(typ, BaseModel):
|
|
101
|
-
return
|
|
101
|
+
return typ.parse_obj
|
|
102
102
|
|
|
103
103
|
if is_dataclass(typ):
|
|
104
104
|
field_deserializers = {f.name: TypeDeserializer(f.type) for f in fields(typ)}
|
|
@@ -122,4 +122,13 @@ class TypeDeserializer:
|
|
|
122
122
|
|
|
123
123
|
@lru_cache(maxsize=512)
|
|
124
124
|
def get_deserializer(typ):
|
|
125
|
+
"""
|
|
126
|
+
return a function that is able to deserialize a value of the specified type
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
typ: the type
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
|
|
133
|
+
"""
|
|
125
134
|
return TypeDeserializer(typ)
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
FastAPI server implementation for the aspyx service framework.
|
|
3
3
|
"""
|
|
4
4
|
import atexit
|
|
5
|
-
import functools
|
|
6
5
|
import inspect
|
|
7
6
|
import threading
|
|
8
7
|
from typing import Type, Optional, Callable
|
|
@@ -28,6 +27,9 @@ from .restchannel import get, post, put, delete, rest
|
|
|
28
27
|
|
|
29
28
|
|
|
30
29
|
class FastAPIServer(Server):
|
|
30
|
+
"""
|
|
31
|
+
A server utilizing fastapi framework.
|
|
32
|
+
"""
|
|
31
33
|
# constructor
|
|
32
34
|
|
|
33
35
|
def __init__(self, host="0.0.0.0", port=8000, **kwargs):
|
|
@@ -41,7 +43,7 @@ class FastAPIServer(Server):
|
|
|
41
43
|
self.component_registry: Optional[ComponentRegistry] = None
|
|
42
44
|
|
|
43
45
|
self.router = APIRouter()
|
|
44
|
-
self.fast_api = FastAPI(
|
|
46
|
+
self.fast_api = FastAPI()
|
|
45
47
|
|
|
46
48
|
# cache
|
|
47
49
|
|
|
@@ -82,14 +84,21 @@ class FastAPIServer(Server):
|
|
|
82
84
|
)
|
|
83
85
|
|
|
84
86
|
def start(self):
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
"""
|
|
88
|
+
start the fastapi server in a thread
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, log_level="info")
|
|
92
|
+
server = uvicorn.Server(config)
|
|
87
93
|
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
thread = threading.Thread(target=server.run, daemon=True)
|
|
95
|
+
thread.start()
|
|
90
96
|
|
|
91
97
|
print(f"server started on {self.host}:{self.port}")
|
|
92
98
|
|
|
99
|
+
return thread
|
|
100
|
+
|
|
101
|
+
|
|
93
102
|
def get_deserializers(self, service: Type, method):
|
|
94
103
|
deserializers = self.deserializers.get(method, None)
|
|
95
104
|
if deserializers is None:
|
|
@@ -105,8 +114,8 @@ class FastAPIServer(Server):
|
|
|
105
114
|
|
|
106
115
|
deserializers = self.get_deserializers(type, method)
|
|
107
116
|
|
|
108
|
-
for i in
|
|
109
|
-
args[i] = deserializers[i](
|
|
117
|
+
for i, arg in enumerate(args):
|
|
118
|
+
args[i] = deserializers[i](arg)
|
|
110
119
|
|
|
111
120
|
return args
|
|
112
121
|
|
|
@@ -181,6 +190,15 @@ class FastAPIServer(Server):
|
|
|
181
190
|
self.router.get(url)(get_health_response)
|
|
182
191
|
|
|
183
192
|
def boot(self, module_type: Type) -> Environment:
|
|
193
|
+
"""
|
|
194
|
+
startup the service manager, DI framework and the fastapi server based on the supplied module
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
module_type: the module
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
|
|
201
|
+
"""
|
|
184
202
|
# setup environment
|
|
185
203
|
|
|
186
204
|
self.environment = Environment(module_type)
|
|
@@ -194,8 +212,8 @@ class FastAPIServer(Server):
|
|
|
194
212
|
self.add_routes()
|
|
195
213
|
self.fast_api.include_router(self.router)
|
|
196
214
|
|
|
197
|
-
|
|
198
|
-
|
|
215
|
+
for route in self.fast_api.routes:
|
|
216
|
+
print(f"{route.name}: {route.path} [{route.methods}]")
|
|
199
217
|
|
|
200
218
|
# start server thread
|
|
201
219
|
|
|
@@ -3,6 +3,7 @@ service management framework allowing for service discovery and transparent remo
|
|
|
3
3
|
"""
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import re
|
|
6
7
|
import socket
|
|
7
8
|
import logging
|
|
8
9
|
import threading
|
|
@@ -24,11 +25,10 @@ class Service:
|
|
|
24
25
|
"""
|
|
25
26
|
This is something like a 'tagging interface' for services.
|
|
26
27
|
"""
|
|
27
|
-
pass
|
|
28
28
|
|
|
29
29
|
class ComponentStatus(Enum):
|
|
30
30
|
"""
|
|
31
|
-
A component is
|
|
31
|
+
A component is in one of the following statuses:
|
|
32
32
|
|
|
33
33
|
- VIRGIN: just constructed
|
|
34
34
|
- RUNNING: registered and up and running
|
|
@@ -41,7 +41,7 @@ class ComponentStatus(Enum):
|
|
|
41
41
|
class Server(ABC):
|
|
42
42
|
"""
|
|
43
43
|
A server is a central entity that boots a main module and initializes the ServiceManager.
|
|
44
|
-
It also is the place where http servers get initialized
|
|
44
|
+
It also is the place where http servers get initialized.
|
|
45
45
|
"""
|
|
46
46
|
port = 0
|
|
47
47
|
|
|
@@ -64,6 +64,11 @@ class Server(ABC):
|
|
|
64
64
|
|
|
65
65
|
@classmethod
|
|
66
66
|
def get_local_ip(cls):
|
|
67
|
+
"""
|
|
68
|
+
return the local ip address
|
|
69
|
+
|
|
70
|
+
Returns: the local ip address
|
|
71
|
+
"""
|
|
67
72
|
try:
|
|
68
73
|
# create a dummy socket to an external address
|
|
69
74
|
|
|
@@ -72,8 +77,7 @@ class Server(ABC):
|
|
|
72
77
|
ip = s.getsockname()[0]
|
|
73
78
|
s.close()
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
#return ip
|
|
80
|
+
return ip
|
|
77
81
|
except Exception:
|
|
78
82
|
return "127.0.0.1" # Fallback
|
|
79
83
|
|
|
@@ -98,24 +102,44 @@ class Component(Service):
|
|
|
98
102
|
This is the base class for components.
|
|
99
103
|
"""
|
|
100
104
|
@abstractmethod
|
|
101
|
-
def startup(self):
|
|
102
|
-
|
|
105
|
+
def startup(self) -> None:
|
|
106
|
+
"""
|
|
107
|
+
startup callback
|
|
108
|
+
"""
|
|
103
109
|
|
|
104
110
|
@abstractmethod
|
|
105
|
-
def shutdown(self):
|
|
106
|
-
|
|
111
|
+
def shutdown(self)-> None:
|
|
112
|
+
"""
|
|
113
|
+
shutdown callback
|
|
114
|
+
"""
|
|
107
115
|
|
|
108
116
|
@abstractmethod
|
|
109
117
|
def get_addresses(self, port: int) -> list[ChannelAddress]:
|
|
110
|
-
|
|
118
|
+
"""
|
|
119
|
+
returns a list of channel addresses that expose this components services.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
port: the port of a server hosting this component
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
list of channel addresses
|
|
126
|
+
"""
|
|
111
127
|
|
|
112
128
|
@abstractmethod
|
|
113
129
|
def get_status(self) -> ComponentStatus:
|
|
114
|
-
|
|
130
|
+
"""
|
|
131
|
+
return the component status callback
|
|
132
|
+
|
|
133
|
+
Returns: the component status
|
|
134
|
+
"""
|
|
115
135
|
|
|
116
136
|
@abstractmethod
|
|
117
137
|
async def get_health(self) -> HealthCheckManager.Health:
|
|
118
|
-
|
|
138
|
+
"""
|
|
139
|
+
return the component health
|
|
140
|
+
|
|
141
|
+
Returns: the component health
|
|
142
|
+
"""
|
|
119
143
|
|
|
120
144
|
class AbstractComponent(Component, ABC):
|
|
121
145
|
"""
|
|
@@ -126,18 +150,33 @@ class AbstractComponent(Component, ABC):
|
|
|
126
150
|
def __init__(self):
|
|
127
151
|
self.status = ComponentStatus.VIRGIN
|
|
128
152
|
|
|
129
|
-
def startup(self):
|
|
153
|
+
def startup(self) -> None:
|
|
130
154
|
self.status = ComponentStatus.RUNNING
|
|
131
155
|
|
|
132
|
-
def shutdown(self):
|
|
156
|
+
def shutdown(self) -> None:
|
|
133
157
|
self.status = ComponentStatus.STOPPED
|
|
134
158
|
|
|
135
|
-
def get_status(self):
|
|
159
|
+
def get_status(self) -> ComponentStatus:
|
|
136
160
|
return self.status
|
|
137
161
|
|
|
162
|
+
def to_snake_case(name: str) -> str:
|
|
163
|
+
return re.sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
|
|
164
|
+
|
|
138
165
|
def component(name = "", description="", services: list[Type] = []):
|
|
166
|
+
"""
|
|
167
|
+
decorates component interfaces
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
name: the component name. If empty the class name converted to snake-case is used
|
|
171
|
+
description: optional description
|
|
172
|
+
services: the list of hosted services
|
|
173
|
+
"""
|
|
139
174
|
def decorator(cls):
|
|
140
|
-
|
|
175
|
+
component_name = name
|
|
176
|
+
if component_name == "":
|
|
177
|
+
component_name = to_snake_case(cls.__name__)
|
|
178
|
+
|
|
179
|
+
Decorators.add(cls, component, component_name, description, services)
|
|
141
180
|
|
|
142
181
|
ServiceManager.register_component(cls, services)
|
|
143
182
|
|
|
@@ -148,8 +187,19 @@ def component(name = "", description="", services: list[Type] = []):
|
|
|
148
187
|
return decorator
|
|
149
188
|
|
|
150
189
|
def service(name = "", description = ""):
|
|
190
|
+
"""
|
|
191
|
+
decorates service interfaces
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
name: the service name. If empty the class name converted to snake case is used
|
|
195
|
+
description: optional description
|
|
196
|
+
"""
|
|
151
197
|
def decorator(cls):
|
|
152
|
-
|
|
198
|
+
service_name = name
|
|
199
|
+
if service_name == "":
|
|
200
|
+
service_name = to_snake_case(cls.__name__)
|
|
201
|
+
|
|
202
|
+
Decorators.add(cls, service, service_name, description)
|
|
153
203
|
|
|
154
204
|
Providers.register(ServiceInstanceProvider(cls))
|
|
155
205
|
|
|
@@ -157,16 +207,24 @@ def service(name = "", description = ""):
|
|
|
157
207
|
|
|
158
208
|
return decorator
|
|
159
209
|
|
|
210
|
+
def health(endpoint = ""):
|
|
211
|
+
"""
|
|
212
|
+
specifies the health endpoint that will return the component health
|
|
160
213
|
|
|
161
|
-
|
|
214
|
+
Args:
|
|
215
|
+
endpoint: the health endpoint
|
|
216
|
+
"""
|
|
162
217
|
def decorator(cls):
|
|
163
|
-
Decorators.add(cls, health,
|
|
218
|
+
Decorators.add(cls, health, endpoint)
|
|
164
219
|
|
|
165
220
|
return cls
|
|
166
221
|
|
|
167
222
|
return decorator
|
|
168
223
|
|
|
169
224
|
def implementation():
|
|
225
|
+
"""
|
|
226
|
+
decorates service or component implementations.
|
|
227
|
+
"""
|
|
170
228
|
def decorator(cls):
|
|
171
229
|
Decorators.add(cls, implementation)
|
|
172
230
|
|
|
@@ -299,7 +357,14 @@ class ComponentDescriptor(BaseDescriptor[T]):
|
|
|
299
357
|
# a resolved channel address
|
|
300
358
|
|
|
301
359
|
@dataclass()
|
|
302
|
-
class
|
|
360
|
+
class ChannelInstances:
|
|
361
|
+
"""
|
|
362
|
+
a resolved channel address containing:
|
|
363
|
+
|
|
364
|
+
- component: the component name
|
|
365
|
+
- channel: the channel name
|
|
366
|
+
- urls: list of URLs
|
|
367
|
+
"""
|
|
303
368
|
component: str
|
|
304
369
|
channel: str
|
|
305
370
|
urls: list[str]
|
|
@@ -312,18 +377,29 @@ class ServiceAddress:
|
|
|
312
377
|
self.urls : list[str] = sorted(urls)
|
|
313
378
|
|
|
314
379
|
class ServiceException(Exception):
|
|
315
|
-
|
|
380
|
+
"""
|
|
381
|
+
base class for service exceptions
|
|
382
|
+
"""
|
|
316
383
|
|
|
317
384
|
class LocalServiceException(ServiceException):
|
|
318
|
-
|
|
385
|
+
"""
|
|
386
|
+
base class for service exceptions occurring locally
|
|
387
|
+
"""
|
|
319
388
|
|
|
320
389
|
class ServiceCommunicationException(ServiceException):
|
|
321
|
-
|
|
390
|
+
"""
|
|
391
|
+
base class for service exceptions thrown by remoting errors
|
|
392
|
+
"""
|
|
322
393
|
|
|
323
394
|
class RemoteServiceException(ServiceException):
|
|
324
|
-
|
|
395
|
+
"""
|
|
396
|
+
base class for service exceptions occurring on the server side
|
|
397
|
+
"""
|
|
325
398
|
|
|
326
399
|
class Channel(DynamicProxy.InvocationHandler, ABC):
|
|
400
|
+
"""
|
|
401
|
+
A channel is a dynamic proxy invocation handler and transparently takes care of remoting.
|
|
402
|
+
"""
|
|
327
403
|
__slots__ = [
|
|
328
404
|
"name",
|
|
329
405
|
"component_descriptor",
|
|
@@ -331,11 +407,25 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
|
|
|
331
407
|
]
|
|
332
408
|
|
|
333
409
|
class URLSelector:
|
|
410
|
+
"""
|
|
411
|
+
a url selector retrieves a URL for the next remoting call.
|
|
412
|
+
"""
|
|
334
413
|
@abstractmethod
|
|
335
414
|
def get(self, urls: list[str]) -> str:
|
|
336
|
-
|
|
415
|
+
"""
|
|
416
|
+
return the next URL given a list of possible URLS
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
urls: list of possible URLS
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
a URL
|
|
423
|
+
"""
|
|
337
424
|
|
|
338
425
|
class FirstURLSelector(URLSelector):
|
|
426
|
+
"""
|
|
427
|
+
a url selector always retrieving the first URL given a list of possible URLS
|
|
428
|
+
"""
|
|
339
429
|
def get(self, urls: list[str]) -> str:
|
|
340
430
|
if len(urls) == 0:
|
|
341
431
|
raise ServiceCommunicationException("no known url")
|
|
@@ -343,6 +433,9 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
|
|
|
343
433
|
return urls[0]
|
|
344
434
|
|
|
345
435
|
class RoundRobinURLSelector(URLSelector):
|
|
436
|
+
"""
|
|
437
|
+
a url selector that picks urls sequentially given a list of possible URLS
|
|
438
|
+
"""
|
|
346
439
|
def __init__(self):
|
|
347
440
|
self.index = 0
|
|
348
441
|
|
|
@@ -358,59 +451,90 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
|
|
|
358
451
|
|
|
359
452
|
# constructor
|
|
360
453
|
|
|
361
|
-
def __init__(self
|
|
362
|
-
self.name =
|
|
454
|
+
def __init__(self):
|
|
455
|
+
self.name = Decorators.get_decorator(type(self), channel).args[0]
|
|
363
456
|
self.component_descriptor : Optional[ComponentDescriptor] = None
|
|
364
|
-
self.address: Optional[
|
|
365
|
-
self.
|
|
457
|
+
self.address: Optional[ChannelInstances] = None
|
|
458
|
+
self.url_selector : Channel.URLSelector = Channel.FirstURLSelector()
|
|
366
459
|
|
|
367
460
|
# public
|
|
368
461
|
|
|
369
462
|
def customize(self):
|
|
370
463
|
pass
|
|
371
464
|
|
|
372
|
-
def select_round_robin(self):
|
|
373
|
-
|
|
465
|
+
def select_round_robin(self) -> None:
|
|
466
|
+
"""
|
|
467
|
+
enable round robin
|
|
468
|
+
"""
|
|
469
|
+
self.url_selector = Channel.RoundRobinURLSelector()
|
|
374
470
|
|
|
375
471
|
def select_first_url(self):
|
|
376
|
-
|
|
472
|
+
"""
|
|
473
|
+
pick the first URL
|
|
474
|
+
"""
|
|
475
|
+
self.url_selector = Channel.FirstURLSelector()
|
|
377
476
|
|
|
378
477
|
def get_url(self) -> str:
|
|
379
|
-
return self.
|
|
478
|
+
return self.url_selector.get(self.address.urls)
|
|
380
479
|
|
|
381
|
-
def set_address(self, address: Optional[
|
|
480
|
+
def set_address(self, address: Optional[ChannelInstances]):
|
|
382
481
|
self.address = address
|
|
383
482
|
|
|
384
|
-
def setup(self, component_descriptor: ComponentDescriptor, address:
|
|
483
|
+
def setup(self, component_descriptor: ComponentDescriptor, address: ChannelInstances):
|
|
385
484
|
self.component_descriptor = component_descriptor
|
|
386
485
|
self.address = address
|
|
387
486
|
|
|
388
487
|
|
|
389
488
|
class ComponentRegistry:
|
|
489
|
+
"""
|
|
490
|
+
A component registry keeps track of components including their health
|
|
491
|
+
"""
|
|
390
492
|
@abstractmethod
|
|
391
|
-
def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]):
|
|
392
|
-
|
|
493
|
+
def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]) -> None:
|
|
494
|
+
"""
|
|
495
|
+
register a component to the registry
|
|
496
|
+
Args:
|
|
497
|
+
descriptor: the descriptor
|
|
498
|
+
addresses: list of addresses
|
|
499
|
+
"""
|
|
393
500
|
|
|
394
501
|
@abstractmethod
|
|
395
|
-
def deregister(self, descriptor: ComponentDescriptor[Component]):
|
|
396
|
-
|
|
502
|
+
def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
|
|
503
|
+
"""
|
|
504
|
+
deregister a component from the registry
|
|
505
|
+
Args:
|
|
506
|
+
descriptor: the component descriptor
|
|
507
|
+
"""
|
|
397
508
|
|
|
398
509
|
@abstractmethod
|
|
399
|
-
def watch(self, channel: Channel):
|
|
400
|
-
|
|
510
|
+
def watch(self, channel: Channel) -> None:
|
|
511
|
+
"""
|
|
512
|
+
remember the passed channel and keep it informed about address changes
|
|
513
|
+
Args:
|
|
514
|
+
channel: a channel
|
|
515
|
+
"""
|
|
401
516
|
|
|
402
517
|
@abstractmethod
|
|
403
|
-
def get_addresses(self, descriptor: ComponentDescriptor) -> list[
|
|
404
|
-
|
|
518
|
+
def get_addresses(self, descriptor: ComponentDescriptor) -> list[ChannelInstances]:
|
|
519
|
+
"""
|
|
520
|
+
return a list of addresses that can be used to call services belonging to this component
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
descriptor: the component descriptor
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
list of channel instances
|
|
527
|
+
"""
|
|
405
528
|
|
|
406
529
|
def map_health(self, health: HealthCheckManager.Health) -> int:
|
|
407
530
|
return 200
|
|
408
531
|
|
|
409
|
-
def shutdown(self):
|
|
410
|
-
pass
|
|
411
532
|
|
|
412
533
|
@injectable()
|
|
413
534
|
class ChannelManager:
|
|
535
|
+
"""
|
|
536
|
+
Internal factory for channels.
|
|
537
|
+
"""
|
|
414
538
|
factories: dict[str, Type] = {}
|
|
415
539
|
|
|
416
540
|
@classmethod
|
|
@@ -432,7 +556,7 @@ class ChannelManager:
|
|
|
432
556
|
|
|
433
557
|
# public
|
|
434
558
|
|
|
435
|
-
def make(self, name: str, descriptor: ComponentDescriptor, address:
|
|
559
|
+
def make(self, name: str, descriptor: ComponentDescriptor, address: ChannelInstances) -> Channel:
|
|
436
560
|
ServiceManager.logger.info("create channel %s: %s", name, self.factories.get(name).__name__)
|
|
437
561
|
|
|
438
562
|
result = self.environment.get(self.factories.get(name))
|
|
@@ -441,7 +565,13 @@ class ChannelManager:
|
|
|
441
565
|
|
|
442
566
|
return result
|
|
443
567
|
|
|
444
|
-
def channel(name):
|
|
568
|
+
def channel(name: str):
|
|
569
|
+
"""
|
|
570
|
+
this decorator is used to mark channel implementations.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
name: the channel name
|
|
574
|
+
"""
|
|
445
575
|
def decorator(cls):
|
|
446
576
|
Decorators.add(cls, channel, name)
|
|
447
577
|
|
|
@@ -460,6 +590,9 @@ class TypeAndChannel:
|
|
|
460
590
|
|
|
461
591
|
@injectable()
|
|
462
592
|
class ServiceManager:
|
|
593
|
+
"""
|
|
594
|
+
Central class that manages services and components and is able to return proxies.
|
|
595
|
+
"""
|
|
463
596
|
# class property
|
|
464
597
|
|
|
465
598
|
logger = logging.getLogger("aspyx.service") # __name__ = module name
|
|
@@ -587,7 +720,7 @@ class ServiceManager:
|
|
|
587
720
|
|
|
588
721
|
# public
|
|
589
722
|
|
|
590
|
-
def find_service_address(self, component_descriptor: ComponentDescriptor, preferred_channel="") ->
|
|
723
|
+
def find_service_address(self, component_descriptor: ComponentDescriptor, preferred_channel="") -> ChannelInstances:
|
|
591
724
|
addresses = self.component_registry.get_addresses(component_descriptor) # component, channel + urls
|
|
592
725
|
address = next((address for address in addresses if address.channel == preferred_channel), None)
|
|
593
726
|
if address is None:
|
|
@@ -600,6 +733,16 @@ class ServiceManager:
|
|
|
600
733
|
return address
|
|
601
734
|
|
|
602
735
|
def get_service(self, service_type: Type[T], preferred_channel="") -> T:
|
|
736
|
+
"""
|
|
737
|
+
return a service proxy given a service type and preferred channel name
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
service_type: the service type
|
|
741
|
+
preferred_channel: the preferred channel name
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
the proxy
|
|
745
|
+
"""
|
|
603
746
|
service_descriptor = ServiceManager.get_descriptor(service_type)
|
|
604
747
|
component_descriptor = service_descriptor.get_component_descriptor()
|
|
605
748
|
|
|
@@ -688,7 +831,7 @@ class LocalChannel(Channel):
|
|
|
688
831
|
# constructor
|
|
689
832
|
|
|
690
833
|
def __init__(self, manager: ServiceManager):
|
|
691
|
-
super().__init__(
|
|
834
|
+
super().__init__()
|
|
692
835
|
|
|
693
836
|
self.manager = manager
|
|
694
837
|
self.component = component
|
|
@@ -717,16 +860,16 @@ class LocalComponentRegistry(ComponentRegistry):
|
|
|
717
860
|
|
|
718
861
|
# implement
|
|
719
862
|
|
|
720
|
-
def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]):
|
|
863
|
+
def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]) -> None:
|
|
721
864
|
self.component_channels[descriptor] = addresses
|
|
722
865
|
|
|
723
|
-
def deregister(self, descriptor: ComponentDescriptor[Component]):
|
|
866
|
+
def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
|
|
724
867
|
pass
|
|
725
868
|
|
|
726
|
-
def watch(self, channel: Channel):
|
|
869
|
+
def watch(self, channel: Channel) -> None:
|
|
727
870
|
pass
|
|
728
871
|
|
|
729
|
-
def get_addresses(self, descriptor: ComponentDescriptor) -> list[
|
|
872
|
+
def get_addresses(self, descriptor: ComponentDescriptor) -> list[ChannelInstances]:
|
|
730
873
|
return self.component_channels.get(descriptor, [])
|
|
731
874
|
|
|
732
875
|
def inject_service(preferred_channel=""):
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests
|
|
3
|
+
"""
|
|
1
4
|
import asyncio
|
|
2
5
|
import logging
|
|
3
6
|
import threading
|
|
@@ -5,8 +8,7 @@ import time
|
|
|
5
8
|
from abc import abstractmethod
|
|
6
9
|
from dataclasses import dataclass
|
|
7
10
|
from pathlib import Path
|
|
8
|
-
from typing import Dict, cast, Optional
|
|
9
|
-
import atexit
|
|
11
|
+
from typing import Dict, cast, Optional
|
|
10
12
|
|
|
11
13
|
import consul
|
|
12
14
|
import httpx
|
|
@@ -18,7 +20,7 @@ from aspyx.di.configuration import YamlConfigurationSource
|
|
|
18
20
|
from aspyx_service import ConsulComponentRegistry, service, Service, component, Component, \
|
|
19
21
|
implementation, health, AbstractComponent, ChannelAddress, inject_service, \
|
|
20
22
|
FastAPIServer, Server, ServiceModule, DispatchJSONChannel, ServiceManager, ComponentDescriptor, health_checks, \
|
|
21
|
-
|
|
23
|
+
health_check, HealthCheckManager, get, post, Body, rest
|
|
22
24
|
|
|
23
25
|
# configure logging
|
|
24
26
|
|
|
@@ -27,15 +29,15 @@ logging.basicConfig(
|
|
|
27
29
|
format='[%(asctime)s] %(levelname)s in %(filename)s:%(lineno)d - %(message)s'
|
|
28
30
|
)
|
|
29
31
|
|
|
30
|
-
logging.getLogger("httpx").setLevel(logging.
|
|
32
|
+
logging.getLogger("httpx").setLevel(logging.ERROR)
|
|
31
33
|
|
|
32
34
|
def configure_logging(levels: Dict[str, int]) -> None:
|
|
33
35
|
for name in levels:
|
|
34
36
|
logging.getLogger(name).setLevel(levels[name])
|
|
35
37
|
|
|
36
38
|
configure_logging({
|
|
37
|
-
"aspyx.di": logging.
|
|
38
|
-
"aspyx.service": logging.
|
|
39
|
+
"aspyx.di": logging.INFO,
|
|
40
|
+
"aspyx.service": logging.INFO
|
|
39
41
|
})
|
|
40
42
|
|
|
41
43
|
# just a test
|
|
@@ -65,7 +67,7 @@ class ChannelAdvice:
|
|
|
65
67
|
def make_client(self, invocation: Invocation):
|
|
66
68
|
rest_channel = cast(DispatchJSONChannel, invocation.args[0])
|
|
67
69
|
|
|
68
|
-
rest_channel.
|
|
70
|
+
rest_channel.select_round_robin()
|
|
69
71
|
|
|
70
72
|
return InterceptingClient()
|
|
71
73
|
|
|
@@ -117,8 +119,10 @@ data = Data("data-0", "data-1", "data-2", "data-3", "data-4", "data-5", "data-6"
|
|
|
117
119
|
)
|
|
118
120
|
|
|
119
121
|
@service(name="test-service", description="cool")
|
|
122
|
+
@rest("/api")
|
|
120
123
|
class TestService(Service):
|
|
121
124
|
@abstractmethod
|
|
125
|
+
@get("/hello/{message}")
|
|
122
126
|
def hello(self, message: str) -> str:
|
|
123
127
|
pass
|
|
124
128
|
|
|
@@ -126,14 +130,15 @@ class TestService(Service):
|
|
|
126
130
|
async def hello_async(self, message: str) -> str:
|
|
127
131
|
pass
|
|
128
132
|
|
|
129
|
-
|
|
133
|
+
@post("/data/")
|
|
134
|
+
def data(self, data: Body(Data)) -> Data:
|
|
130
135
|
pass
|
|
131
136
|
|
|
132
137
|
async def data_async(self, data: Data) -> Data:
|
|
133
138
|
pass
|
|
134
139
|
|
|
135
|
-
@component(
|
|
136
|
-
class TestComponent(Component):
|
|
140
|
+
@component(services =[TestService])
|
|
141
|
+
class TestComponent(Component): # pylint: disable=abstract-method
|
|
137
142
|
pass
|
|
138
143
|
|
|
139
144
|
# implementation classes
|
|
@@ -144,17 +149,15 @@ class TestServiceImpl(TestService):
|
|
|
144
149
|
pass
|
|
145
150
|
|
|
146
151
|
def hello(self, message: str) -> str:
|
|
147
|
-
#print(f"hello {message}")
|
|
148
152
|
return f"hello {message}"
|
|
149
153
|
|
|
150
154
|
async def hello_async(self, message: str) -> str:
|
|
151
|
-
#print(f"hello {message}")
|
|
152
155
|
return f"hello {message}"
|
|
153
156
|
|
|
154
157
|
def data(self, data: Data) -> Data:
|
|
155
158
|
return data
|
|
156
159
|
|
|
157
|
-
def data_async(self, data: Data) -> Data:
|
|
160
|
+
async def data_async(self, data: Data) -> Data:
|
|
158
161
|
return data
|
|
159
162
|
|
|
160
163
|
@implementation()
|
|
@@ -175,19 +178,20 @@ class TestComponentImpl(AbstractComponent, TestComponent):
|
|
|
175
178
|
|
|
176
179
|
# implement
|
|
177
180
|
|
|
178
|
-
def get_health(self):
|
|
179
|
-
return self.health_check_manager.check()
|
|
181
|
+
async def get_health(self) -> HealthCheckManager.Health:
|
|
182
|
+
return await self.health_check_manager.check()
|
|
180
183
|
|
|
181
184
|
def get_addresses(self, port: int) -> list[ChannelAddress]:
|
|
182
185
|
return [
|
|
186
|
+
ChannelAddress("rest", f"http://{Server.get_local_ip()}:{port}"),
|
|
183
187
|
ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}"),
|
|
184
188
|
ChannelAddress("dispatch-msgpack", f"http://{Server.get_local_ip()}:{port}")
|
|
185
189
|
]
|
|
186
190
|
|
|
187
|
-
def startup(self):
|
|
191
|
+
def startup(self) -> None:
|
|
188
192
|
print("### startup")
|
|
189
193
|
|
|
190
|
-
def shutdown(self):
|
|
194
|
+
def shutdown(self) -> None:
|
|
191
195
|
print("### shutdown")
|
|
192
196
|
|
|
193
197
|
@injectable()
|
|
@@ -195,28 +199,29 @@ class Bar:
|
|
|
195
199
|
def __init__(self):
|
|
196
200
|
pass
|
|
197
201
|
|
|
198
|
-
@injectable()
|
|
202
|
+
@injectable(eager=False)
|
|
199
203
|
class Test:
|
|
200
204
|
def __init__(self):
|
|
201
205
|
self.service = None
|
|
202
206
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
@inject_service(preferred_channel="")
|
|
208
|
+
def set_service(self, service: TestService, bla: str):
|
|
209
|
+
self.service = service
|
|
210
|
+
print(service)
|
|
207
211
|
|
|
208
212
|
@health_checks()
|
|
213
|
+
@injectable()
|
|
209
214
|
class Checks:
|
|
210
215
|
def __init__(self):
|
|
211
216
|
pass
|
|
212
217
|
|
|
213
|
-
@
|
|
218
|
+
@health_check(fail_if_slower_than=1)
|
|
214
219
|
def check_1(self, result: HealthCheckManager.Result):
|
|
215
220
|
#result.set_status(HealthStatus.OK)
|
|
216
221
|
#time.sleep(2)
|
|
217
222
|
pass
|
|
218
223
|
|
|
219
|
-
@
|
|
224
|
+
@health_check(name="check-2", cache=10)
|
|
220
225
|
def check_2(self, result: HealthCheckManager.Result):
|
|
221
226
|
pass # result.set_status(HealthStatus.OK)
|
|
222
227
|
|
|
@@ -247,11 +252,6 @@ async def main():
|
|
|
247
252
|
|
|
248
253
|
service_manager = environment.get(ServiceManager)
|
|
249
254
|
|
|
250
|
-
def cleanup():
|
|
251
|
-
service_manager.shutdown()
|
|
252
|
-
|
|
253
|
-
atexit.register(cleanup)
|
|
254
|
-
|
|
255
255
|
descriptor: ComponentDescriptor = cast(ComponentDescriptor, service_manager.get_descriptor(TestComponent))
|
|
256
256
|
|
|
257
257
|
while True:
|
|
@@ -262,6 +262,10 @@ async def main():
|
|
|
262
262
|
print("zzz...")
|
|
263
263
|
time.sleep(1)
|
|
264
264
|
|
|
265
|
+
test_service = service_manager.get_service(TestService, preferred_channel="rest")
|
|
266
|
+
|
|
267
|
+
r = test_service.hello("world")
|
|
268
|
+
|
|
265
269
|
test_service = service_manager.get_service(TestService, preferred_channel="dispatch-json")
|
|
266
270
|
msgpack_test_service = service_manager.get_service(TestService, preferred_channel="dispatch-msgpack")
|
|
267
271
|
|
|
@@ -327,8 +331,38 @@ async def main():
|
|
|
327
331
|
print(f"{title} {iterations} in {n_threads} threads: {took} ms, avg: {avg_ms}ms")
|
|
328
332
|
|
|
329
333
|
|
|
330
|
-
test_thread_test(title="sync call", n_threads=1, iterations=1000)
|
|
331
|
-
test_async_thread_test(title="async call",n_threads=1, iterations=1000)
|
|
334
|
+
#test_thread_test(title="sync call", n_threads=1, iterations=1000)
|
|
335
|
+
#test_async_thread_test(title="async call",n_threads=1, iterations=1000)
|
|
336
|
+
|
|
337
|
+
#####
|
|
338
|
+
|
|
339
|
+
test_service = service_manager.get_service(TestService, preferred_channel="rest")
|
|
340
|
+
|
|
341
|
+
loops = 1000
|
|
342
|
+
start = time.perf_counter()
|
|
343
|
+
for _ in range(loops):
|
|
344
|
+
test_service.data(data)
|
|
345
|
+
#test_service.hello("world")
|
|
346
|
+
|
|
347
|
+
end = time.perf_counter()
|
|
348
|
+
|
|
349
|
+
avg_ms = ((end - start) / loops) * 1000
|
|
350
|
+
print(f"Average time per rest call: {avg_ms:.3f} ms")
|
|
351
|
+
|
|
352
|
+
test_service = service_manager.get_service(TestService, preferred_channel="dispatch-msgpack")
|
|
353
|
+
|
|
354
|
+
loops = 1000
|
|
355
|
+
start = time.perf_counter()
|
|
356
|
+
for _ in range(loops):
|
|
357
|
+
#test_service.hello("andi")
|
|
358
|
+
test_service.data(data)
|
|
359
|
+
|
|
360
|
+
end = time.perf_counter()
|
|
361
|
+
|
|
362
|
+
avg_ms = ((end - start) / loops) * 1000
|
|
363
|
+
print(f"Average time per dispatch call: {avg_ms:.3f} ms")
|
|
364
|
+
|
|
365
|
+
return
|
|
332
366
|
|
|
333
367
|
#####
|
|
334
368
|
|
|
@@ -387,9 +421,3 @@ async def main():
|
|
|
387
421
|
if __name__ == "__main__":
|
|
388
422
|
asyncio.run(main())
|
|
389
423
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|