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 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 .service import ServiceManager
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.client: Optional[Client] = None
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
- if self.client is None:
98
- self.client = self.make_client()
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 self.client
111
+ return client
101
112
 
102
- def get_async_client(self) -> Client:
103
- if self.async_client is None:
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
- return self.async_client
116
+ if async_client is None:
117
+ async_client = self.make_async_client()
118
+ self.async_client_local.set(async_client)
107
119
 
108
- def make_client(self):
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 th endpoint `ìnvoke` sending a request body containing information on the
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__}", args=invocation.args)
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
- if self.client is not None:
152
- result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=dict, timeout=30000.0).json())
153
- if result.exception is not None:
154
- raise RemoteServiceException(f"server side exception {result.exception}")
155
-
156
- return self.get_deserializer(invocation.type, invocation.method)(result.result)
157
- else:
158
- raise ServiceException(f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
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 ServiceException(f"communication exception {e}") from e
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
- if self.async_client is not None:
170
- data = await self.async_client.post(f"{self.get_url()}/invoke", json=dict, timeout=30000.0)
171
- result = Response(**data.json())
172
- if result.exception is not None:
173
- raise RemoteServiceException(f"server side exception {result.exception}")
174
-
175
- return self.get_deserializer(invocation.type, invocation.method)(result.result)
176
- else:
177
- raise ServiceException(
178
- f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
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 ServiceException(f"communication exception {e}") from e
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=30.0
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=30.0
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
@@ -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="10s",
134
- timeout="3s",
135
- deregister="5m")
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
@@ -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 ServiceException, channel
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
- if self.client is not None:
213
- result = None
214
- if call.type == "get":
215
- result = self.client.get(self.get_url() + url, params=query_params, timeout=30000.0).json()
216
- elif call.type == "put":
217
- result = self.client.put(self.get_url() + url, params=query_params, timeout=30000.0).json()
218
- elif call.type == "delete":
219
- result = self.client.delete(self.get_url() + url, params=query_params, timeout=30000.0).json()
220
- elif call.type == "post":
221
- result = self.client.post(self.get_url() + url, params=query_params, json=body, timeout=30000.0).json()
222
-
223
- return self.get_deserializer(invocation.type, invocation.method)(result)
224
- else:
225
- raise ServiceException(
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 ServiceException(f"communication exception {e}") from e
300
+ raise ServiceCommunicationException(f"communication exception {e}") from e
@@ -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 start(self):
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
- print(f"{route.name}: {route.path} [{route.methods}]")
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.start()
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
- pass
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
- key = TypeAndChannel(type=component_descriptor.type, channel=preferred_channel)
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(key, None)
775
+ proxy = self.proxy_cache.get(proxy_key, None)
759
776
  if proxy is None:
760
- channel_instance = self.channel_cache.get(key, None)
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
- key = TypeAndChannel(type=component_descriptor.type, channel=address.channel)
790
+ channel_key = TypeAndChannel(type=component_descriptor.type, channel=address.channel)
774
791
 
775
- channel_instance = self.channel_cache.get(key, None)
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[key] = channel_instance
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[key] = proxy
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[ChannelAddress]] = {}
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[descriptor] = addresses
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.1
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.0
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
+ ![Pylint](https://github.com/coolsamson7/aspyx/actions/workflows/pylint.yml/badge.svg)
40
+ ![Build Status](https://github.com/coolsamson7/aspyx/actions/workflows/ci.yml/badge.svg)
41
+ ![Python Versions](https://img.shields.io/badge/python-3.9%20|%203.10%20|%203.11%20|%203.12-blue)
42
+ ![License](https://img.shields.io/github/license/coolsamson7/aspyx)
43
+ ![coverage](https://img.shields.io/badge/coverage-94%25-brightgreen)
44
+ [![PyPI](https://img.shields.io/pypi/v/aspyx)](https://pypi.org/project/aspyx/)
45
+ [![Docs](https://img.shields.io/badge/docs-online-blue?logo=github)](https://coolsamson7.github.io/aspyx/index/introduction)
46
+
47
+ ![image](https://github.com/user-attachments/assets/e808210a-b1a4-4fd0-93f1-b5f9845fa520)
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
- !!! info "Service"
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
- !!! info "Component"
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
- !!! info "Component Registry "
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
- !!! info "Channel"
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, "http://localhost:8500") # a consul based registry!
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,,