aspyx-service 0.10.0__py3-none-any.whl → 0.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aspyx-service might be problematic. Click here for more details.

aspyx_service/channels.py CHANGED
@@ -3,14 +3,17 @@ Service management and dependency injection framework.
3
3
  """
4
4
  from __future__ import annotations
5
5
 
6
+ import json
7
+ from dataclasses import is_dataclass, asdict, fields
6
8
  from typing import Type, Optional, Any, Callable
7
9
 
8
10
  import msgpack
9
11
  from httpx import Client, AsyncClient
10
12
  from pydantic import BaseModel
11
13
 
14
+ from aspyx.di.configuration import inject_value
12
15
  from aspyx.reflection import DynamicProxy, TypeDescriptor
13
- from .service import ServiceManager
16
+ from .service import ServiceManager, ServiceCommunicationException
14
17
 
15
18
  from .service import ComponentDescriptor, ChannelInstances, ServiceException, channel, Channel, RemoteServiceException
16
19
  from .serialization import get_deserializer
@@ -21,19 +24,54 @@ class HTTPXChannel(Channel):
21
24
  "client",
22
25
  "async_client",
23
26
  "service_names",
24
- "deserializers"
27
+ "deserializers",
28
+ "timeout"
25
29
  ]
26
30
 
31
+ # class methods
32
+
33
+ @classmethod
34
+ def to_dict(cls, obj: Any) -> Any:
35
+ if isinstance(obj, BaseModel):
36
+ return obj.dict()
37
+
38
+
39
+ elif is_dataclass(obj):
40
+ return {
41
+ f.name: cls.to_dict(getattr(obj, f.name))
42
+
43
+ for f in fields(obj)
44
+ }
45
+
46
+ elif isinstance(obj, (list, tuple)):
47
+ return [cls.to_dict(item) for item in obj]
48
+
49
+ elif isinstance(obj, dict):
50
+ return {key: cls.to_dict(value) for key, value in obj.items()}
51
+
52
+ return obj
53
+
54
+ @classmethod
55
+ def to_json(cls, obj) -> str:
56
+ return json.dumps(cls.to_dict(obj))
57
+
27
58
  # constructor
28
59
 
29
60
  def __init__(self):
30
61
  super().__init__()
31
62
 
63
+ self.timeout = 1000.0
32
64
  self.client: Optional[Client] = None
33
65
  self.async_client: Optional[AsyncClient] = None
34
66
  self.service_names: dict[Type, str] = {}
35
67
  self.deserializers: dict[Callable, Callable] = {}
36
68
 
69
+ # inject
70
+
71
+ @inject_value("http.timeout", default=1000.0)
72
+ def set_timeout(self, timeout: float) -> None:
73
+ self.timeout = timeout
74
+
37
75
  # protected
38
76
 
39
77
  def get_deserializer(self, type: Type, method: Callable) -> Type:
@@ -70,16 +108,16 @@ class HTTPXChannel(Channel):
70
108
 
71
109
  return self.client
72
110
 
73
- def get_async_client(self) -> Client:
111
+ def get_async_client(self) -> AsyncClient:
74
112
  if self.async_client is None:
75
113
  self.async_client = self.make_async_client()
76
114
 
77
115
  return self.async_client
78
116
 
79
- def make_client(self):
117
+ def make_client(self) -> Client:
80
118
  return Client() # base_url=url
81
119
 
82
- def make_async_client(self):
120
+ def make_async_client(self) -> AsyncClient:
83
121
  return AsyncClient() # base_url=url
84
122
 
85
123
  class Request(BaseModel):
@@ -93,7 +131,7 @@ class Response(BaseModel):
93
131
  @channel("dispatch-json")
94
132
  class DispatchJSONChannel(HTTPXChannel):
95
133
  """
96
- A channel that calls a POST on th endpoint `ìnvoke` sending a request body containing information on the
134
+ A channel that calls a POST on the endpoint `ìnvoke` sending a request body containing information on the
97
135
  called component, service and method and the arguments.
98
136
  """
99
137
  # constructor
@@ -115,38 +153,49 @@ class DispatchJSONChannel(HTTPXChannel):
115
153
 
116
154
  def invoke(self, invocation: DynamicProxy.Invocation):
117
155
  service_name = self.service_names[invocation.type] # map type to registered service name
118
- request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}", args=invocation.args)
156
+ request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
157
+ args=invocation.args)
119
158
 
159
+ dict = self.to_dict(request)
120
160
  try:
121
- if self.client is not None:
122
- result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=request.dict(), timeout=30000.0).json())
123
- if result.exception is not None:
124
- raise RemoteServiceException(f"server side exception {result.exception}")
125
-
126
- return self.get_deserializer(invocation.type, invocation.method)(result.result)
127
- else:
128
- raise ServiceException(f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
161
+ result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=dict, timeout=self.timeout).json())
162
+ if result.exception is not None:
163
+ raise RemoteServiceException(f"server side exception {result.exception}")
164
+
165
+ return self.get_deserializer(invocation.type, invocation.method)(result.result)
166
+ except ServiceCommunicationException:
167
+ raise
168
+
169
+ except RemoteServiceException:
170
+ raise
171
+
129
172
  except Exception as e:
130
- raise ServiceException(f"communication exception {e}") from e
173
+ raise ServiceCommunicationException(f"communication exception {e}") from e
174
+
131
175
 
132
176
  async def invoke_async(self, invocation: DynamicProxy.Invocation):
133
177
  service_name = self.service_names[invocation.type] # map type to registered service name
134
178
  request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
135
179
  args=invocation.args)
136
-
180
+ dict = self.to_dict(request)
137
181
  try:
138
- if self.async_client is not None:
139
- data = await self.async_client.post(f"{self.get_url()}/invoke", json=request.dict(), timeout=30000.0)
140
- result = Response(**data.json())
141
- if result.exception is not None:
142
- raise RemoteServiceException(f"server side exception {result.exception}")
143
-
144
- return self.get_deserializer(invocation.type, invocation.method)(result.result)
145
- else:
146
- raise ServiceException(
147
- f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
182
+ data = await self.get_async_client().post(f"{self.get_url()}/invoke", json=dict, timeout=self.timeout)
183
+ result = Response(**data.json())
184
+
185
+ if result.exception is not None:
186
+ raise RemoteServiceException(f"server side exception {result.exception}")
187
+
188
+ return self.get_deserializer(invocation.type, invocation.method)(result.result)
189
+
190
+ except ServiceCommunicationException:
191
+ raise
192
+
193
+ except RemoteServiceException:
194
+ raise
195
+
148
196
  except Exception as e:
149
- raise ServiceException(f"communication exception {e}") from e
197
+ raise ServiceCommunicationException(f"communication exception {e}") from e
198
+
150
199
 
151
200
  @channel("dispatch-msgpack")
152
201
  class DispatchMSPackChannel(HTTPXChannel):
@@ -171,13 +220,13 @@ class DispatchMSPackChannel(HTTPXChannel):
171
220
  args=invocation.args)
172
221
 
173
222
  try:
174
- packed = msgpack.packb(request.dict(), use_bin_type=True)
223
+ packed = msgpack.packb(self.to_dict(request), use_bin_type=True)
175
224
 
176
225
  response = self.get_client().post(
177
226
  f"{self.get_url()}/invoke",
178
227
  content=packed,
179
228
  headers={"Content-Type": "application/msgpack"},
180
- timeout=30.0
229
+ timeout=self.timeout
181
230
  )
182
231
 
183
232
  result = msgpack.unpackb(response.content, raw=False)
@@ -187,6 +236,12 @@ class DispatchMSPackChannel(HTTPXChannel):
187
236
 
188
237
  return self.get_deserializer(invocation.type, invocation.method)(result["result"])
189
238
 
239
+ except ServiceCommunicationException:
240
+ raise
241
+
242
+ except RemoteServiceException:
243
+ raise
244
+
190
245
  except Exception as e:
191
246
  raise ServiceException(f"msgpack exception: {e}") from e
192
247
 
@@ -196,13 +251,13 @@ class DispatchMSPackChannel(HTTPXChannel):
196
251
  args=invocation.args)
197
252
 
198
253
  try:
199
- packed = msgpack.packb(request.dict(), use_bin_type=True)
254
+ packed = msgpack.packb(self.to_dict(request), use_bin_type=True)
200
255
 
201
256
  response = await self.get_async_client().post(
202
257
  f"{self.get_url()}/invoke",
203
258
  content=packed,
204
259
  headers={"Content-Type": "application/msgpack"},
205
- timeout=30.0
260
+ timeout=self.timeout
206
261
  )
207
262
 
208
263
  result = msgpack.unpackb(response.content, raw=False)
@@ -212,5 +267,11 @@ class DispatchMSPackChannel(HTTPXChannel):
212
267
 
213
268
  return self.get_deserializer(invocation.type, invocation.method)(result["result"])
214
269
 
270
+ except ServiceCommunicationException:
271
+ raise
272
+
273
+ except RemoteServiceException:
274
+ raise
275
+
215
276
  except Exception as e:
216
277
  raise ServiceException(f"msgpack exception: {e}") from e
@@ -6,10 +6,10 @@ 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
 
12
+ from aspyx.di.configuration import inject_value
13
13
  from aspyx.util import StringBuilder
14
14
  from aspyx.di import on_init
15
15
  from .healthcheck import HealthCheckManager, HealthStatus
@@ -24,35 +24,38 @@ class ConsulComponentRegistry(ComponentRegistry):
24
24
  """
25
25
  # constructor
26
26
 
27
- def __init__(self, port: int, consul_url: str):
27
+ def __init__(self, port: int, consul: consul.Consul):
28
28
  self.port = port
29
29
  self.ip = Server.get_local_ip()
30
30
  self.running = False
31
- self.consul = None
31
+ self.consul = consul
32
32
  self.watchdog = None
33
33
  self.interval = 5
34
34
  self.last_index = {}
35
35
  self.component_addresses : dict[str, dict[str, ChannelInstances]] = {} # comp -> channel -> address
36
36
  self.watch_channels : list[Channel] = []
37
+ self.watchdog_interval = 5
38
+ self.healthcheck_interval = "10s"
39
+ self.healthcheck_timeout= "5s"
40
+ self.healthcheck_deregister = "5m"
37
41
 
38
- parsed = urlparse(consul_url)
42
+ # injections
39
43
 
40
- self.consul_host = parsed.hostname
41
- self.consul_port = parsed.port
44
+ @inject_value("consul.watchdog.interval", default=5)
45
+ def set_interval(self, interval):
46
+ self.watchdog_interval = interval
42
47
 
43
- def make_consul(self, host="", port="") -> consul.Consul:
44
- """
45
- create and return a consul instance
48
+ @inject_value("consul.healthcheck.interval", default="10s")
49
+ def set_interval(self, interval):
50
+ self.healthcheck_interval = interval
46
51
 
47
- Args:
48
- host: the host
49
- port: the port
52
+ @inject_value("consul.healthcheck.timeout", default="3s")
53
+ def set_interval(self, interval):
54
+ self.healthcheck_timeout = interval
50
55
 
51
- Returns:
52
- a consul instance
53
-
54
- """
55
- return consul.Consul(host=host, port=port)
56
+ @inject_value("consul.healthcheck.deregister", default="5m")
57
+ def set_interval(self, interval):
58
+ self.healthcheck_deregister = interval
56
59
 
57
60
  # lifecycle hooks
58
61
 
@@ -60,7 +63,6 @@ class ConsulComponentRegistry(ComponentRegistry):
60
63
  def setup(self):
61
64
  # create consul client
62
65
 
63
- self.consul = self.make_consul(host=self.consul_host, port=self.consul_port)
64
66
  self.running = True
65
67
 
66
68
  # start thread
@@ -122,14 +124,12 @@ class ConsulComponentRegistry(ComponentRegistry):
122
124
 
123
125
  # time to sleep
124
126
 
125
- time.sleep(5)
127
+ time.sleep(self.watchdog_interval)
126
128
 
127
129
  @abstractmethod
128
130
  def watch(self, channel: Channel) -> None:
129
131
  self.watch_channels.append(channel)
130
132
 
131
- #self.component_addresses[channel.component_descriptor.name] = {}
132
-
133
133
  # public
134
134
 
135
135
  def register_service(self, name, service_id, health: str, tags=None, meta=None) -> None:
@@ -142,9 +142,9 @@ class ConsulComponentRegistry(ComponentRegistry):
142
142
  meta=meta or {},
143
143
  check=consul.Check().http(
144
144
  url=f"http://{self.ip}:{self.port}{health}",
145
- interval="10s",
146
- timeout="3s",
147
- deregister="5m")
145
+ interval=self.healthcheck_interval,
146
+ timeout=self.healthcheck_timeout,
147
+ deregister=self.healthcheck_deregister)
148
148
  )
149
149
 
150
150
  def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
@@ -225,12 +225,13 @@ class ConsulComponentRegistry(ComponentRegistry):
225
225
 
226
226
  return list(component_addresses.values())
227
227
 
228
- #200–299 passing Service is healthy (OK, Created, No Content…)
229
- #429 warning Rate limited or degraded
230
- #300–399 warning Redirects interpreted as potential issues
231
- #400–499 critical Client errors (Bad Request, Unauthorized…)
232
- #500–599 critical Server errors (Internal Error, Timeout…)
233
- #Other / No response critical Timeout, connection refused, etc.
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
+
234
235
  def map_health(self, health: HealthCheckManager.Health) -> int:
235
236
  if health.status is HealthStatus.OK:
236
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
 
@@ -189,21 +227,44 @@ class RestChannel(HTTPXChannel):
189
227
 
190
228
  return call
191
229
 
192
- def to_dict(self, obj):
193
- if obj is None:
194
- return None
195
- if is_dataclass(obj):
196
- return asdict(obj)
197
- elif isinstance(obj, BaseModel):
198
- return obj.dict()
199
- elif hasattr(obj, "__dict__"):
200
- return vars(obj)
201
- else:
202
- # fallback for primitives etc.
203
- return obj
204
-
205
230
  # override
206
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_client().get(self.get_url() + url, params=query_params, timeout=self.timeout).json()
253
+ elif call.type == "put":
254
+ result = await self.get_client().put(self.get_url() + url, params=query_params, timeout=self.timeout).json()
255
+ elif call.type == "delete":
256
+ result = await self.get_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout).json()
257
+ elif call.type == "post":
258
+ result = await self.get_client().post(self.get_url() + url, params=query_params, json=body,
259
+ timeout=self.timeout).json()
260
+
261
+ return self.get_deserializer(invocation.type, invocation.method)(result)
262
+ except ServiceCommunicationException:
263
+ raise
264
+
265
+ except Exception as e:
266
+ raise ServiceCommunicationException(f"communication exception {e}") from e
267
+
207
268
  def invoke(self, invocation: DynamicProxy.Invocation):
208
269
  call = self.get_call(invocation.type, invocation.method)
209
270
 
@@ -222,20 +283,19 @@ class RestChannel(HTTPXChannel):
222
283
  # call
223
284
 
224
285
  try:
225
- if self.client is not None:
226
- result = None
227
- if call.type == "get":
228
- result = self.client.get(self.get_url() + url, params=query_params, timeout=30000.0).json()
229
- elif call.type == "put":
230
- result = self.client.put(self.get_url() + url, params=query_params, timeout=30000.0).json()
231
- elif call.type == "delete":
232
- result = self.client.delete(self.get_url() + url, params=query_params, timeout=30000.0).json()
233
- elif call.type == "post":
234
- result = self.client.post(self.get_url() + url, params=query_params, json=body, timeout=30000.0).json()
235
-
236
- return self.get_deserializer(invocation.type, invocation.method)(result)
237
- else:
238
- raise ServiceException(
239
- f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
286
+ result = None
287
+ if call.type == "get":
288
+ result = self.get_client().get(self.get_url() + url, params=query_params, timeout=self.timeout).json()
289
+ elif call.type == "put":
290
+ result = self.get_client().put(self.get_url() + url, params=query_params, timeout=self.timeout).json()
291
+ elif call.type == "delete":
292
+ result = self.get_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout).json()
293
+ elif call.type == "post":
294
+ result = self.get_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout).json()
295
+
296
+ return self.get_deserializer(invocation.type, invocation.method)(result)
297
+ except ServiceCommunicationException:
298
+ raise
299
+
240
300
  except Exception as e:
241
- raise ServiceException(f"communication exception {e}") from e
301
+ 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
 
@@ -119,6 +58,54 @@ class TypeDeserializer:
119
58
 
120
59
  # Fallback
121
60
  return lambda v: v
61
+
62
+ class TypeSerializer:
63
+ def __init__(self, typ):
64
+ self.typ = typ
65
+ self.serializer = self._build_serializer(typ)
66
+
67
+ def __call__(self, value):
68
+ return self.serializer(value)
69
+
70
+ def _build_serializer(self, typ):
71
+ origin = get_origin(typ)
72
+ args = get_args(typ)
73
+
74
+ if origin is Union:
75
+ serializers = [TypeSerializer(arg) for arg in args if arg is not type(None)]
76
+ def ser_union(value):
77
+ if value is None:
78
+ return None
79
+ for s in serializers:
80
+ try:
81
+ return s(value)
82
+ except Exception:
83
+ continue
84
+ return value
85
+ return ser_union
86
+
87
+ if isinstance(typ, type) and issubclass(typ, BaseModel):
88
+ return lambda v: v.dict() if v is not None else None
89
+
90
+ if is_dataclass(typ):
91
+ field_serializers = {f.name: TypeSerializer(f.type) for f in fields(typ)}
92
+ def ser_dataclass(obj):
93
+ if obj is None:
94
+ return None
95
+ return {k: field_serializers[k](getattr(obj, k)) for k in field_serializers}
96
+ return ser_dataclass
97
+
98
+ if origin is list:
99
+ item_ser = TypeSerializer(args[0]) if args else lambda x: x
100
+ return lambda v: [item_ser(item) for item in v] if v is not None else None
101
+
102
+ if origin is dict:
103
+ key_ser = TypeSerializer(args[0]) if args else lambda x: x
104
+ val_ser = TypeSerializer(args[1]) if len(args) > 1 else lambda x: x
105
+ return lambda v: {key_ser(k): val_ser(val) for k, val in v.items()} if v is not None else None
106
+
107
+ # Fallback: primitive Typen oder unbekannt
108
+ return lambda v: v
122
109
 
123
110
  @lru_cache(maxsize=512)
124
111
  def get_deserializer(typ):
@@ -132,3 +119,16 @@ def get_deserializer(typ):
132
119
 
133
120
  """
134
121
  return TypeDeserializer(typ)
122
+
123
+ @lru_cache(maxsize=512)
124
+ def get_serializer(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
+ """
134
+ 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="info")
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
 
@@ -850,18 +866,19 @@ class LocalChannel(Channel):
850
866
 
851
867
  return getattr(instance, invocation.method.__name__)(*invocation.args, **invocation.kwargs)
852
868
 
853
-
854
- #@injectable()
855
869
  class LocalComponentRegistry(ComponentRegistry):
856
870
  # constructor
857
871
 
858
872
  def __init__(self):
859
- self.component_channels : dict[ComponentDescriptor, list[ChannelAddress]] = {}
873
+ self.component_channels : dict[ComponentDescriptor, list[ChannelInstances]] = {}
860
874
 
861
875
  # implement
862
876
 
863
877
  def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]) -> None:
864
- self.component_channels[descriptor] = addresses
878
+ if self.component_channels.get(descriptor, None) is None:
879
+ self.component_channels[descriptor] = []
880
+
881
+ self.component_channels[descriptor].extend([ChannelInstances(descriptor.name, address.channel, [address.uri]) for address in addresses])
865
882
 
866
883
  def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
867
884
  pass
@@ -0,0 +1,517 @@
1
+ Metadata-Version: 2.4
2
+ Name: aspyx_service
3
+ Version: 0.10.2
4
+ Summary: Aspyx Service framework
5
+ Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Andreas Ernst
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Requires-Python: >=3.9
29
+ Requires-Dist: aspyx>=1.5.1
30
+ Requires-Dist: fastapi~=0.115.13
31
+ Requires-Dist: httpx~=0.28.1
32
+ Requires-Dist: msgpack~=1.1.1
33
+ Requires-Dist: python-consul2~=0.1.5
34
+ Requires-Dist: uvicorn[standard]
35
+ Description-Content-Type: text/markdown
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
+
49
+ # Service
50
+
51
+ - [Introduction](#introduction)
52
+ - [Features](#features)
53
+ - [Service and Component declaration](#service-and-component-declaration)
54
+ - [Service and Component implementation](#service-and-component-implementation)
55
+ - [Health Checks](#health-checks)
56
+ - [Service Manager](#service-manager)
57
+ - [Component Registry](#component-registry)
58
+ - [Channels](#channels)
59
+ - [Performance](#performance)
60
+ - [Rest Calls](#rest-calls)
61
+ - [Intercepting calls](#intercepting-calls)
62
+ - [FastAPI server](#fastapi-server)
63
+ - [Implementing Channels](#implementing-channels)
64
+ - [Version History](#version-history)
65
+
66
+ ## Introduction
67
+
68
+ The Aspyx service library is built on top of the DI core framework and adds a microservice based architecture,
69
+ that lets you deploy, discover and call services with different remoting protocols and pluggable discovery services.
70
+
71
+ The basic design consists of four different concepts:
72
+
73
+ !!! info "Service"
74
+ defines a group of methods that can be called either locally or remotely.
75
+ These methods represent the functional interface exposed to clients — similar to an interface in traditional programming
76
+
77
+ !!! info "Component"
78
+ a component bundles one or more services and declares the channels (protocols) used to expose them.
79
+ Think of a component as a deployment unit or module.
80
+
81
+ !!! info "Component Registry "
82
+ acts as the central directory for managing available components.
83
+ It allows the framework to register, discover, and resolve components and their services.
84
+
85
+ !!! info "Channel"
86
+ is a pluggable transport layer that defines how service method invocations are transmitted and handled.
87
+
88
+ Let's look at the "interface" layer first.
89
+
90
+ **Example**:
91
+ ```python
92
+ @service(name="test-service", description="test service")
93
+ class TestService(Service):
94
+ @abstractmethod
95
+ def hello(self, message: str) -> str:
96
+ pass
97
+
98
+ @component(name="test-component", services =[TestService])
99
+ class TestComponent(Component):
100
+ pass
101
+ ```
102
+
103
+ After booting the DI infrastructure with a main module we could already call a service:
104
+
105
+ **Example**:
106
+ ```python
107
+ @module(imports=[ServiceModule])
108
+ class Module:
109
+ def __init__(self):
110
+ pass
111
+
112
+ @create()
113
+ def create_registry(self) -> ConsulComponentRegistry:
114
+ return ConsulComponentRegistry(Server.port, "http://localhost:8500") # a consul based registry!
115
+
116
+ environment = Environment(Module)
117
+ service_manager = environment.get(ServiceManager)
118
+
119
+ service = service_manager.get_service(TestService)
120
+
121
+ service.hello("world")
122
+ ```
123
+
124
+ The technical details are completely transparent, as a dynamic proxy encapsulates the internals.
125
+
126
+ As we can also host implementations, lets look at this side as well:
127
+
128
+ ```python
129
+ @implementation()
130
+ class TestComponentImpl(AbstractComponent, TestComponent):
131
+ # constructor
132
+
133
+ def __init__(self):
134
+ super().__init__()
135
+
136
+ # implement Component
137
+
138
+ def get_addresses(self, port: int) -> list[ChannelAddress]:
139
+ return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
140
+
141
+ @implementation()
142
+ class TestServiceImpl(TestService):
143
+ def __init__(self):
144
+ pass
145
+
146
+ def hello(self, message: str) -> str:
147
+ return f"hello {message}"
148
+ ```
149
+
150
+ The interesting part if the `get_addresses` method that return a list of channel addresses, that can be used to execute remote calls.
151
+ In this case a channel is used that exposes a single http endpoint, that will dispatch to the correct service method.
152
+ This information is registered with the appropriate component registry and is used by other processes.
153
+
154
+ The required - `FastAPI` - infrastructure to expose those services is started with the call:
155
+
156
+ ```python
157
+ server = FastAPIServer(host="0.0.0.0", port=8000)
158
+
159
+ environment = server.boot(Module)
160
+ ```
161
+
162
+ Of course, service can also be called locally. In case of multiple possible channels, a keyword argument is used to
163
+ determine a specific channel. As a local channel has the name "local", the appropriate call is:
164
+
165
+ ```python
166
+ service = service_manager.get_service(TestService, preferred_channel="local")
167
+ ```
168
+
169
+ ## Features
170
+
171
+ The library offers:
172
+
173
+ - sync and async support
174
+ - multiple - extensible - channel implementations supporting dataclasses and pydantic data models.
175
+ - ability to customize http calls with interceptors ( via the AOP abilities )
176
+ - `fastapi` based channels covering simple rest endpoints including `msgpack` support.
177
+ - `httpx` based clients for dispatching channels and simple rest endpoint with the help of low-level decorators.
178
+ - first registry implementation based on `consul`
179
+ - support for configurable health checks
180
+
181
+ As well as the DI and AOP core, all mechanisms are heavily optimized.
182
+ A simple benchmark resulted in message roundtrips in significanlty under a ms per call.
183
+
184
+ Let's see some details
185
+
186
+ ## Service and Component declaration
187
+
188
+ Every service needs to inherit from the "tagging interface" `Service`
189
+
190
+ ```python
191
+ @service(name="test-service", description="test service")
192
+ class TestService(Service):
193
+ @abstractmethod
194
+ def hello(self, message: str) -> str:
195
+ pass
196
+ ```
197
+
198
+ The decorator can add a name and a description. If `name` is not set, the class name converted to snake case is used.
199
+
200
+ A component needs to derive from `Component`:
201
+
202
+ ```python
203
+ @component(services =[TestService])
204
+ class TestComponent(Component):
205
+ pass
206
+ ```
207
+
208
+ The `services` argument references a list of service interfaces that are managed by this component, meaning that they all are
209
+ exposed by the same channels.
210
+
211
+ `Component` defines the abstract methods:
212
+
213
+ - `def startup(self) -> None`
214
+ called initially after booting the system
215
+
216
+ - `def shutdown(self) -> None:`
217
+ called before shutting fown the system
218
+
219
+ - `def get_addresses(self, port: int) -> list[ChannelAddress]:`
220
+ return a list of available `ChannelAddress`es that this component exposes
221
+
222
+ - `def get_status(self) -> ComponentStatus:`
223
+ return the status of this component ( one of the `ComponentStatus` enums `VIRGIN`, `RUNNING`, and `STOPPED`)
224
+
225
+ - `async def get_health(self) -> HealthCheckManager.Health:`
226
+ return the health status of a component implementation.
227
+
228
+ ## Service and Component implementation
229
+
230
+ Service implementations implement the corresponding interface and are decorated with `@implementation`
231
+
232
+ ```python
233
+ @implementation()
234
+ class TestServiceImpl(TestService):
235
+ def __init__(self):
236
+ pass
237
+ ```
238
+
239
+ The constructor is required since the instances are managed by the DI framework.
240
+
241
+ Component implementations derive from the interface and the abstract base class `AbstractComponent`
242
+
243
+ ```python
244
+ @implementation()
245
+ class TestComponentImpl(AbstractComponent, TestComponent):
246
+ # constructor
247
+
248
+ def __init__(self):
249
+ super().__init__()
250
+
251
+ # implement Component
252
+
253
+ def get_addresses(self, port: int) -> list[ChannelAddress]:
254
+ return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
255
+ ```
256
+
257
+ As a minimum you have to declare the constructor and the `get_addresses` method, that exposes channel addresses
258
+
259
+ ## Health Checks
260
+
261
+ Every component can declare a HTTP health endpoint and the corresponding logic to compute the current status.
262
+
263
+ Two additional things have to be done:
264
+
265
+ - adding a `@health(<endpoint>)` decorator to the class
266
+ - implementing the `get_health()` method that returns a `HealthCheckManager.Health`
267
+
268
+ While you can instantiate the `Health` class directly via
269
+
270
+ ```
271
+ HealthCheckManager.Health(HealtStatus.OK)
272
+ ```
273
+
274
+ it typically makes more sense to let the system execute a number of configured checks and compute the overall result automatically.
275
+
276
+ For this purpose injectable classes can be decorated with `@health_checks()` that contain methods in turn decorated with `@health_check`
277
+
278
+ **Example**:
279
+
280
+ ```python
281
+ @health_checks()
282
+ @injectable()
283
+ class Checks:
284
+ def __init__(self):
285
+ pass
286
+
287
+ @health_check(fail_if_slower_than=1)
288
+ def check_performance(self, result: HealthCheckManager.Result):
289
+ ... # should be done in under a second
290
+
291
+ @health_check(name="check", cache=10)
292
+ def check(self, result: HealthCheckManager.Result):
293
+ ok = ...
294
+ result.set_status(if ok HealthStatus.OK else HealthStatus.ERROR)
295
+ ```
296
+
297
+ The methods are expected to have a single parameter of type `HealthCheckManager.Result` that can be used to set the status including detail information with
298
+
299
+ ```
300
+ set_status(status: HealthStatus, details = "")
301
+ ```
302
+
303
+ When called, the default is already `OK`.
304
+
305
+ The decorator accepts a couple of parameters:
306
+
307
+ - `fail_if_slower_than=0`
308
+ time in `s` that the check is expected to take as a maximum. As soon as the time is exceeded, the status is set to `ERROR`
309
+ - `cache`
310
+ time in 's' that the last result is cached. This is done in order to prevent health-checks putting even more strain on a heavily used system.
311
+
312
+ ## Service Manager
313
+
314
+ `ServiceManager` is the central class used to retrieve service proxies.
315
+
316
+ ```python
317
+ def get_service(self, service_type: Type[T], preferred_channel="") -> T
318
+ ```
319
+
320
+ - `type` is the requested service interface
321
+ - `preferred_channel` the name of the preferred channel.
322
+
323
+ If not specified, the first registered channel is used, which btw. is a local channel - called `local` - in case of implementing services.
324
+
325
+ ## Component Registry
326
+
327
+ The component registry is the place where component implementations are registered and retrieved.
328
+
329
+ In addition to a `LocalComponentRegistry` ( which is used for testing purposes ) the only implementation is
330
+
331
+ `ConsulComponentRegistry`
332
+
333
+ Constructor arguments are
334
+
335
+ - `port: int` the own port
336
+ - `consul: Consul` the consul instance
337
+
338
+ The component registry is also responsible to execute regular health-checks to track component healths.
339
+ As soon as - in our case consul - decides that a component is not alive anymore, it will notify the clients via regular heartbeats about address changes
340
+ which will be propagated to channels talking to the appropriate component.
341
+
342
+ Currently, this only affects the list of possible URLs which are required by the channels!
343
+
344
+ ## Channels
345
+
346
+ Channels implement the possible transport layer protocols. In the sense of a dynamic proxy, they are the invocation handlers!
347
+
348
+ Several channels are implemented:
349
+
350
+ - `dispatch-json`
351
+ channel that dispatches generic `Request` objects via a `invoke` POST-call
352
+ - `dispatch-msgpack`
353
+ channel that dispatches generic `Request` objects via a `invoke` POST-call after packing the json with msgpack
354
+ - `rest`
355
+ channel that executes regular rest-calls as defined by a couple of decorators.
356
+
357
+ All channels react on changed URLs as provided by the component registry.
358
+
359
+ A so called `URLSelector` is used internally to provide URLs for every single call. Two subclasses exist that offer a different logic
360
+
361
+ - `FirstURLSelector` always returns the first URL of the list of possible URLs
362
+ - `RoundRobinURLSelector` switches sequentially between all URLs.
363
+
364
+ To customize the behavior, an around advice can be implemented easily:
365
+
366
+ **Example**:
367
+
368
+ ```python
369
+ @advice
370
+ class ChannelAdvice:
371
+ def __init__(self):
372
+ pass
373
+
374
+ @advice
375
+ class ChannelAdvice:
376
+ def __init__(self):
377
+ pass
378
+
379
+ @around(methods().named("customize").of_type(Channel))
380
+ def customize_channel(self, invocation: Invocation):
381
+ channel = cast(Channel, invocation.args[0])
382
+
383
+ channel.select_round_robin() # or select_first_url()
384
+
385
+ return invocation.proceed()
386
+ ```
387
+
388
+ ### Performance
389
+
390
+ I benchmarked the different implementations with a recursive dataclass as an argument and return value.
391
+ The avg response times - on a local server - where all below 1ms per call.
392
+
393
+ - rest calls are the slowest ( about 0.7ms )
394
+ - dispatching-json 20% faster
395
+ - dispatching-msgpack 30% faster
396
+
397
+ The biggest advantage of the dispatching flavors is, that you don't have to worry about the additional decorators!
398
+
399
+ ### Rest Calls
400
+
401
+ Invoking rest calls requires decorators and some marker annotations.
402
+
403
+ **Example**:
404
+
405
+ ```python
406
+ @service()
407
+ @rest("/api")
408
+ class TestService(Service):
409
+ @get("/hello/{message}")
410
+ def hello(self, message: str) -> str:
411
+ pass
412
+
413
+ @post("/post/")
414
+ def set_data(self, data: Body(Data)) -> Data:
415
+ pass
416
+ ```
417
+
418
+ The decorators `get`, `put`, `post` and `delete` specify the methods.
419
+
420
+ If the class is decorated with `@rest(<prefix>)`, the corresponding prefix will be appended at the beginning.
421
+
422
+ Additional annotations are
423
+ - `Body` the post body
424
+ - `QueryParam`marked for query params
425
+
426
+ ### Intercepting calls
427
+
428
+ The client side HTTP calling is done with `httpx` instances of type `Httpx.Client` or `Httpx.AsyncClient`.
429
+
430
+ To add the possibility to add interceptors - for token handling, etc. - the channel base class `HTTPXChannel` defines
431
+ the methods `make_client()` and `make_async_client` that can be modified with an around advice.
432
+
433
+ **Example**:
434
+
435
+ ```python
436
+ class InterceptingClient(httpx.Client):
437
+ # constructor
438
+
439
+ def __init__(self, *args, **kwargs):
440
+ self.token_provider = ...
441
+ super().__init__(*args, **kwargs)
442
+
443
+ # override
444
+
445
+ def request(self, method, url, *args, **kwargs):
446
+ headers = kwargs.pop("headers", {})
447
+ headers["Authorization"] = f"Bearer {self.token_provider()}"
448
+ kwargs["headers"] = headers
449
+
450
+ return super().request(method, url, *args, **kwargs)
451
+
452
+ @advice
453
+ class ChannelAdvice:
454
+ def __init__(self):
455
+ pass
456
+
457
+ @around(methods().named("make_client").of_type(HTTPXChannel))
458
+ def make_client(self, invocation: Invocation):
459
+ return InterceptingClient()
460
+ ```
461
+
462
+ ## FastAPI server
463
+
464
+ In order to expose components via HTTP, the corresponding infrastructure in form of a FastAPI server needs to be setup.
465
+
466
+
467
+ ```python
468
+ @module()
469
+ class Module():
470
+ def __init__(self):
471
+ pass
472
+
473
+ server = FastAPIServer(host="0.0.0.0", port=8000)
474
+
475
+ environment = server.boot(Module) # will start the http server
476
+ ```
477
+
478
+ This setup will also expose all service interfaces decorated with the corresponding http decorators!
479
+ No need to add any FastAPI decorators, since the mapping is already done internally!
480
+
481
+ ## Implementing Channels
482
+
483
+ To implement a new channel, you only need to derive from one of the possible base classes ( `Channel` or `HTTPXChannel` that already has a `httpx` client)
484
+ and decorate it with `@channel(<name>)`
485
+
486
+ The main methods to implement are `ìnvoke` and `ìnvoke_async`
487
+
488
+ **Example**:
489
+
490
+ ```python
491
+ @channel("fancy")
492
+ class FancyChannel(Channel):
493
+ # constructor
494
+
495
+ def __init__(self):
496
+ super().__init__()
497
+
498
+ # override
499
+
500
+ def invoke(self, invocation: DynamicProxy.Invocation):
501
+ return ...
502
+
503
+ async def invoke_async(self, invocation: DynamicProxy.Invocation):
504
+ return await ...
505
+
506
+ ```
507
+
508
+ # Version History
509
+
510
+ **0.10.0**
511
+
512
+ - first release version
513
+
514
+
515
+
516
+
517
+
@@ -0,0 +1,12 @@
1
+ aspyx_service/__init__.py,sha256=6t24VPrSCG83EAvYlqCKdEcEbyCY3vrSb5GoAx01Ymg,1662
2
+ aspyx_service/channels.py,sha256=9EjnY6DV_toqeQNswYZgmAtirXM_TTLGErs6hb1U9yw,8934
3
+ aspyx_service/healthcheck.py,sha256=8ZPSkAx6ypoYaxDMkJT_MtL2pEN2LcUAishAWPCy-3I,5624
4
+ aspyx_service/registries.py,sha256=JSsD32F8VffZMHyEDuapEWtvmem5SK9kR6bgsFRLFZQ,8002
5
+ aspyx_service/restchannel.py,sha256=OVGyKNtORJEFnWsFB5MPkNldVFfJTGuUa4X658_x9Kg,9169
6
+ aspyx_service/serialization.py,sha256=lEr106yiZ0UzVxGTGdiorZSSSNI7vP4C7RyrBJku-U0,4133
7
+ aspyx_service/server.py,sha256=PHKx3O90jnxm8dA4z331_51znhU-HlRB2Fa1AikmFXA,7052
8
+ aspyx_service/service.py,sha256=-hhxRpEtMn-JP57bHNF521zbGMHUy5YaXkbXyiu7dW4,25927
9
+ aspyx_service-0.10.2.dist-info/METADATA,sha256=KEdh30D63sXs26FVOtCph_LF521bBJa7A6Qpc8d-9iQ,16966
10
+ aspyx_service-0.10.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ aspyx_service-0.10.2.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
12
+ aspyx_service-0.10.2.dist-info/RECORD,,
@@ -1,37 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: aspyx_service
3
- Version: 0.10.0
4
- Summary: Aspyx Service framework
5
- Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
6
- License: MIT License
7
-
8
- Copyright (c) 2025 Andreas Ernst
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
- License-File: LICENSE
28
- Requires-Python: >=3.9
29
- Requires-Dist: aspyx>=1.5.0
30
- Requires-Dist: fastapi~=0.115.13
31
- Requires-Dist: httpx~=0.28.1
32
- Requires-Dist: msgpack~=1.1.1
33
- Requires-Dist: python-consul2~=0.1.5
34
- Requires-Dist: uvicorn[standard]
35
- Description-Content-Type: text/markdown
36
-
37
- aspyx-service
@@ -1,12 +0,0 @@
1
- aspyx_service/__init__.py,sha256=6t24VPrSCG83EAvYlqCKdEcEbyCY3vrSb5GoAx01Ymg,1662
2
- aspyx_service/channels.py,sha256=HZFkbZq0Q1DNpOauuPCMOEh7UlpcisN8_gQhL66zS-0,7718
3
- aspyx_service/healthcheck.py,sha256=8ZPSkAx6ypoYaxDMkJT_MtL2pEN2LcUAishAWPCy-3I,5624
4
- aspyx_service/registries.py,sha256=5sU9eFT_D33sAgrXyYSd9fAa8xNmFRJ6CmZGp8cQztY,7712
5
- aspyx_service/restchannel.py,sha256=IbspE-hrzkpPLgBzuNncagjjGZnLVPX9jtT1PKRNenw,6453
6
- aspyx_service/serialization.py,sha256=T6JDURwYd7V4240j1BnNsFKdyiMgePCsCwKa9rf8hq4,3779
7
- aspyx_service/server.py,sha256=DA4bfTzYbpPrfqgddbyvTqr80Ehk9tuoqFfuo59ppN4,7118
8
- aspyx_service/service.py,sha256=T7rZ8EPEzOusP_xOk-7KwTQfX_iJQ291XDL_ieKa_d8,25148
9
- aspyx_service-0.10.0.dist-info/METADATA,sha256=9bVUDpfMPvHUr_NfWmOw4EYHA0rw8m84niRWxnhmslk,1674
10
- aspyx_service-0.10.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
- aspyx_service-0.10.0.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
12
- aspyx_service-0.10.0.dist-info/RECORD,,