aspyx-service 0.10.0__tar.gz → 0.10.1__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.

@@ -0,0 +1,505 @@
1
+ Metadata-Version: 2.4
2
+ Name: aspyx_service
3
+ Version: 0.10.1
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
+ # Service
38
+
39
+ - [Introduction](#introduction)
40
+ - [Features](#features)
41
+ - [Service and Component declaration](#service-and-component-declaration)
42
+ - [Service and Component implementation](#service-and-component-implementation)
43
+ - [Health Checks](#health-checks)
44
+ - [Service Manager](#service-manager)
45
+ - [Component Registry](#component-registry)
46
+ - [Channels](#channels)
47
+ - [Performance](#performance)
48
+ - [Rest Calls](#rest-calls)
49
+ - [Intercepting calls](#intercepting-calls)
50
+ - [FastAPI server](#fastapi-server)
51
+ - [Implementing Channels](#implementing-channels)
52
+ - [Version History](#version-history)
53
+
54
+ ## Introduction
55
+
56
+ The Aspyx service library is built on top of the DI core framework and adds a microservice based architecture,
57
+ that lets you deploy, discover and call services with different remoting protocols and pluggable discovery services.
58
+
59
+ The basic design consists of four different concepts:
60
+
61
+ !!! info "Service"
62
+ defines a group of methods that can be called either locally or remotely.
63
+ These methods represent the functional interface exposed to clients — similar to an interface in traditional programming
64
+
65
+ !!! info "Component"
66
+ a component bundles one or more services and declares the channels (protocols) used to expose them.
67
+ Think of a component as a deployment unit or module.
68
+
69
+ !!! info "Component Registry "
70
+ acts as the central directory for managing available components.
71
+ It allows the framework to register, discover, and resolve components and their services.
72
+
73
+ !!! info "Channel"
74
+ is a pluggable transport layer that defines how service method invocations are transmitted and handled.
75
+
76
+ Let's look at the "interface" layer first.
77
+
78
+ **Example**:
79
+ ```python
80
+ @service(name="test-service", description="test service")
81
+ class TestService(Service):
82
+ @abstractmethod
83
+ def hello(self, message: str) -> str:
84
+ pass
85
+
86
+ @component(name="test-component", services =[TestService])
87
+ class TestComponent(Component):
88
+ pass
89
+ ```
90
+
91
+ After booting the DI infrastructure with a main module we could already call a service:
92
+
93
+ **Example**:
94
+ ```python
95
+ @module(imports=[ServiceModule])
96
+ class Module:
97
+ def __init__(self):
98
+ pass
99
+
100
+ @create()
101
+ def create_registry(self) -> ConsulComponentRegistry:
102
+ return ConsulComponentRegistry(Server.port, "http://localhost:8500") # a consul based registry!
103
+
104
+ environment = Environment(Module)
105
+ service_manager = environment.get(ServiceManager)
106
+
107
+ service = service_manager.get_service(TestService)
108
+
109
+ service.hello("world")
110
+ ```
111
+
112
+ The technical details are completely transparent, as a dynamic proxy encapsulates the internals.
113
+
114
+ As we can also host implementations, lets look at this side as well:
115
+
116
+ ```python
117
+ @implementation()
118
+ class TestComponentImpl(AbstractComponent, TestComponent):
119
+ # constructor
120
+
121
+ def __init__(self):
122
+ super().__init__()
123
+
124
+ # implement Component
125
+
126
+ def get_addresses(self, port: int) -> list[ChannelAddress]:
127
+ return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
128
+
129
+ @implementation()
130
+ class TestServiceImpl(TestService):
131
+ def __init__(self):
132
+ pass
133
+
134
+ def hello(self, message: str) -> str:
135
+ return f"hello {message}"
136
+ ```
137
+
138
+ The interesting part if the `get_addresses` method that return a list of channel addresses, that can be used to execute remote calls.
139
+ In this case a channel is used that exposes a single http endpoint, that will dispatch to the correct service method.
140
+ This information is registered with the appropriate component registry and is used by other processes.
141
+
142
+ The required - `FastAPI` - infrastructure to expose those services is started with the call:
143
+
144
+ ```python
145
+ server = FastAPIServer(host="0.0.0.0", port=8000)
146
+
147
+ environment = server.boot(Module)
148
+ ```
149
+
150
+ Of course, service can also be called locally. In case of multiple possible channels, a keyword argument is used to
151
+ determine a specific channel. As a local channel has the name "local", the appropriate call is:
152
+
153
+ ```python
154
+ service = service_manager.get_service(TestService, preferred_channel="local")
155
+ ```
156
+
157
+ ## Features
158
+
159
+ The library offers:
160
+
161
+ - sync and async support
162
+ - multiple - extensible - channel implementations supporting dataclasses and pydantic data models.
163
+ - ability to customize http calls with interceptors ( via the AOP abilities )
164
+ - `fastapi` based channels covering simple rest endpoints including `msgpack` support.
165
+ - `httpx` based clients for dispatching channels and simple rest endpoint with the help of low-level decorators.
166
+ - first registry implementation based on `consul`
167
+ - support for configurable health checks
168
+
169
+ As well as the DI and AOP core, all mechanisms are heavily optimized.
170
+ A simple benchmark resulted in message roundtrips in significanlty under a ms per call.
171
+
172
+ Let's see some details
173
+
174
+ ## Service and Component declaration
175
+
176
+ Every service needs to inherit from the "tagging interface" `Service`
177
+
178
+ ```python
179
+ @service(name="test-service", description="test service")
180
+ class TestService(Service):
181
+ @abstractmethod
182
+ def hello(self, message: str) -> str:
183
+ pass
184
+ ```
185
+
186
+ The decorator can add a name and a description. If `name` is not set, the class name converted to snake case is used.
187
+
188
+ A component needs to derive from `Component`:
189
+
190
+ ```python
191
+ @component(services =[TestService])
192
+ class TestComponent(Component):
193
+ pass
194
+ ```
195
+
196
+ The `services` argument references a list of service interfaces that are managed by this component, meaning that they all are
197
+ exposed by the same channels.
198
+
199
+ `Component` defines the abstract methods:
200
+
201
+ - `def startup(self) -> None`
202
+ called initially after booting the system
203
+
204
+ - `def shutdown(self) -> None:`
205
+ called before shutting fown the system
206
+
207
+ - `def get_addresses(self, port: int) -> list[ChannelAddress]:`
208
+ return a list of available `ChannelAddress`es that this component exposes
209
+
210
+ - `def get_status(self) -> ComponentStatus:`
211
+ return the status of this component ( one of the `ComponentStatus` enums `VIRGIN`, `RUNNING`, and `STOPPED`)
212
+
213
+ - `async def get_health(self) -> HealthCheckManager.Health:`
214
+ return the health status of a component implementation.
215
+
216
+ ## Service and Component implementation
217
+
218
+ Service implementations implement the corresponding interface and are decorated with `@implementation`
219
+
220
+ ```python
221
+ @implementation()
222
+ class TestServiceImpl(TestService):
223
+ def __init__(self):
224
+ pass
225
+ ```
226
+
227
+ The constructor is required since the instances are managed by the DI framework.
228
+
229
+ Component implementations derive from the interface and the abstract base class `AbstractComponent`
230
+
231
+ ```python
232
+ @implementation()
233
+ class TestComponentImpl(AbstractComponent, TestComponent):
234
+ # constructor
235
+
236
+ def __init__(self):
237
+ super().__init__()
238
+
239
+ # implement Component
240
+
241
+ def get_addresses(self, port: int) -> list[ChannelAddress]:
242
+ return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
243
+ ```
244
+
245
+ As a minimum you have to declare the constructor and the `get_addresses` method, that exposes channel addresses
246
+
247
+ ## Health Checks
248
+
249
+ Every component can declare a HTTP health endpoint and the corresponding logic to compute the current status.
250
+
251
+ Two additional things have to be done:
252
+
253
+ - adding a `@health(<endpoint>)` decorator to the class
254
+ - implementing the `get_health()` method that returns a `HealthCheckManager.Health`
255
+
256
+ While you can instantiate the `Health` class directly via
257
+
258
+ ```
259
+ HealthCheckManager.Health(HealtStatus.OK)
260
+ ```
261
+
262
+ it typically makes more sense to let the system execute a number of configured checks and compute the overall result automatically.
263
+
264
+ For this purpose injectable classes can be decorated with `@health_checks()` that contain methods in turn decorated with `@health_check`
265
+
266
+ **Example**:
267
+
268
+ ```python
269
+ @health_checks()
270
+ @injectable()
271
+ class Checks:
272
+ def __init__(self):
273
+ pass
274
+
275
+ @health_check(fail_if_slower_than=1)
276
+ def check_performance(self, result: HealthCheckManager.Result):
277
+ ... # should be done in under a second
278
+
279
+ @health_check(name="check", cache=10)
280
+ def check(self, result: HealthCheckManager.Result):
281
+ ok = ...
282
+ result.set_status(if ok HealthStatus.OK else HealthStatus.ERROR)
283
+ ```
284
+
285
+ 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
286
+
287
+ ```
288
+ set_status(status: HealthStatus, details = "")
289
+ ```
290
+
291
+ When called, the default is already `OK`.
292
+
293
+ The decorator accepts a couple of parameters:
294
+
295
+ - `fail_if_slower_than=0`
296
+ 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`
297
+ - `cache`
298
+ 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.
299
+
300
+ ## Service Manager
301
+
302
+ `ServiceManager` is the central class used to retrieve service proxies.
303
+
304
+ ```python
305
+ def get_service(self, service_type: Type[T], preferred_channel="") -> T
306
+ ```
307
+
308
+ - `type` is the requested service interface
309
+ - `preferred_channel` the name of the preferred channel.
310
+
311
+ If not specified, the first registered channel is used, which btw. is a local channel - called `local` - in case of implementing services.
312
+
313
+ ## Component Registry
314
+
315
+ The component registry is the place where component implementations are registered and retrieved.
316
+
317
+ In addition to a `LocalComponentRegistry` ( which is used for testing purposes ) the only implementation is
318
+
319
+ `ConsulComponentRegistry`
320
+
321
+ Constructor arguments are
322
+
323
+ - `port: int` the own port
324
+ - `consul: Consul` the consul instance
325
+
326
+ The component registry is also responsible to execute regular health-checks to track component healths.
327
+ 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
328
+ which will be propagated to channels talking to the appropriate component.
329
+
330
+ Currently, this only affects the list of possible URLs which are required by the channels!
331
+
332
+ ## Channels
333
+
334
+ Channels implement the possible transport layer protocols. In the sense of a dynamic proxy, they are the invocation handlers!
335
+
336
+ Several channels are implemented:
337
+
338
+ - `dispatch-json`
339
+ channel that dispatches generic `Request` objects via a `invoke` POST-call
340
+ - `dispatch-msgpack`
341
+ channel that dispatches generic `Request` objects via a `invoke` POST-call after packing the json with msgpack
342
+ - `rest`
343
+ channel that executes regular rest-calls as defined by a couple of decorators.
344
+
345
+ All channels react on changed URLs as provided by the component registry.
346
+
347
+ A so called `URLSelector` is used internally to provide URLs for every single call. Two subclasses exist that offer a different logic
348
+
349
+ - `FirstURLSelector` always returns the first URL of the list of possible URLs
350
+ - `RoundRobinURLSelector` switches sequentially between all URLs.
351
+
352
+ To customize the behavior, an around advice can be implemented easily:
353
+
354
+ **Example**:
355
+
356
+ ```python
357
+ @advice
358
+ class ChannelAdvice:
359
+ def __init__(self):
360
+ pass
361
+
362
+ @advice
363
+ class ChannelAdvice:
364
+ def __init__(self):
365
+ pass
366
+
367
+ @around(methods().named("customize").of_type(Channel))
368
+ def customize_channel(self, invocation: Invocation):
369
+ channel = cast(Channel, invocation.args[0])
370
+
371
+ channel.select_round_robin() # or select_first_url()
372
+
373
+ return invocation.proceed()
374
+ ```
375
+
376
+ ### Performance
377
+
378
+ I benchmarked the different implementations with a recursive dataclass as an argument and return value.
379
+ The avg response times - on a local server - where all below 1ms per call.
380
+
381
+ - rest calls are the slowest ( about 0.7ms )
382
+ - dispatching-json 20% faster
383
+ - dispatching-msgpack 30% faster
384
+
385
+ The biggest advantage of the dispatching flavors is, that you don't have to worry about the additional decorators!
386
+
387
+ ### Rest Calls
388
+
389
+ Invoking rest calls requires decorators and some marker annotations.
390
+
391
+ **Example**:
392
+
393
+ ```python
394
+ @service()
395
+ @rest("/api")
396
+ class TestService(Service):
397
+ @get("/hello/{message}")
398
+ def hello(self, message: str) -> str:
399
+ pass
400
+
401
+ @post("/post/")
402
+ def set_data(self, data: Body(Data)) -> Data:
403
+ pass
404
+ ```
405
+
406
+ The decorators `get`, `put`, `post` and `delete` specify the methods.
407
+
408
+ If the class is decorated with `@rest(<prefix>)`, the corresponding prefix will be appended at the beginning.
409
+
410
+ Additional annotations are
411
+ - `Body` the post body
412
+ - `QueryParam`marked for query params
413
+
414
+ ### Intercepting calls
415
+
416
+ The client side HTTP calling is done with `httpx` instances of type `Httpx.Client` or `Httpx.AsyncClient`.
417
+
418
+ To add the possibility to add interceptors - for token handling, etc. - the channel base class `HTTPXChannel` defines
419
+ the methods `make_client()` and `make_async_client` that can be modified with an around advice.
420
+
421
+ **Example**:
422
+
423
+ ```python
424
+ class InterceptingClient(httpx.Client):
425
+ # constructor
426
+
427
+ def __init__(self, *args, **kwargs):
428
+ self.token_provider = ...
429
+ super().__init__(*args, **kwargs)
430
+
431
+ # override
432
+
433
+ def request(self, method, url, *args, **kwargs):
434
+ headers = kwargs.pop("headers", {})
435
+ headers["Authorization"] = f"Bearer {self.token_provider()}"
436
+ kwargs["headers"] = headers
437
+
438
+ return super().request(method, url, *args, **kwargs)
439
+
440
+ @advice
441
+ class ChannelAdvice:
442
+ def __init__(self):
443
+ pass
444
+
445
+ @around(methods().named("make_client").of_type(HTTPXChannel))
446
+ def make_client(self, invocation: Invocation):
447
+ return InterceptingClient()
448
+ ```
449
+
450
+ ## FastAPI server
451
+
452
+ In order to expose components via HTTP, the corresponding infrastructure in form of a FastAPI server needs to be setup.
453
+
454
+
455
+ ```python
456
+ @module()
457
+ class Module():
458
+ def __init__(self):
459
+ pass
460
+
461
+ server = FastAPIServer(host="0.0.0.0", port=8000)
462
+
463
+ environment = server.boot(Module) # will start the http server
464
+ ```
465
+
466
+ This setup will also expose all service interfaces decorated with the corresponding http decorators!
467
+ No need to add any FastAPI decorators, since the mapping is already done internally!
468
+
469
+ ## Implementing Channels
470
+
471
+ 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)
472
+ and decorate it with `@channel(<name>)`
473
+
474
+ The main methods to implement are `ìnvoke` and `ìnvoke_async`
475
+
476
+ **Example**:
477
+
478
+ ```python
479
+ @channel("fancy")
480
+ class FancyChannel(Channel):
481
+ # constructor
482
+
483
+ def __init__(self):
484
+ super().__init__()
485
+
486
+ # override
487
+
488
+ def invoke(self, invocation: DynamicProxy.Invocation):
489
+ return ...
490
+
491
+ async def invoke_async(self, invocation: DynamicProxy.Invocation):
492
+ return await ...
493
+
494
+ ```
495
+
496
+ # Version History
497
+
498
+ **0.10.0**
499
+
500
+ - first release version
501
+
502
+
503
+
504
+
505
+
@@ -0,0 +1,469 @@
1
+ # Service
2
+
3
+ - [Introduction](#introduction)
4
+ - [Features](#features)
5
+ - [Service and Component declaration](#service-and-component-declaration)
6
+ - [Service and Component implementation](#service-and-component-implementation)
7
+ - [Health Checks](#health-checks)
8
+ - [Service Manager](#service-manager)
9
+ - [Component Registry](#component-registry)
10
+ - [Channels](#channels)
11
+ - [Performance](#performance)
12
+ - [Rest Calls](#rest-calls)
13
+ - [Intercepting calls](#intercepting-calls)
14
+ - [FastAPI server](#fastapi-server)
15
+ - [Implementing Channels](#implementing-channels)
16
+ - [Version History](#version-history)
17
+
18
+ ## Introduction
19
+
20
+ The Aspyx service library is built on top of the DI core framework and adds a microservice based architecture,
21
+ that lets you deploy, discover and call services with different remoting protocols and pluggable discovery services.
22
+
23
+ The basic design consists of four different concepts:
24
+
25
+ !!! info "Service"
26
+ defines a group of methods that can be called either locally or remotely.
27
+ These methods represent the functional interface exposed to clients — similar to an interface in traditional programming
28
+
29
+ !!! info "Component"
30
+ a component bundles one or more services and declares the channels (protocols) used to expose them.
31
+ Think of a component as a deployment unit or module.
32
+
33
+ !!! info "Component Registry "
34
+ acts as the central directory for managing available components.
35
+ It allows the framework to register, discover, and resolve components and their services.
36
+
37
+ !!! info "Channel"
38
+ is a pluggable transport layer that defines how service method invocations are transmitted and handled.
39
+
40
+ Let's look at the "interface" layer first.
41
+
42
+ **Example**:
43
+ ```python
44
+ @service(name="test-service", description="test service")
45
+ class TestService(Service):
46
+ @abstractmethod
47
+ def hello(self, message: str) -> str:
48
+ pass
49
+
50
+ @component(name="test-component", services =[TestService])
51
+ class TestComponent(Component):
52
+ pass
53
+ ```
54
+
55
+ After booting the DI infrastructure with a main module we could already call a service:
56
+
57
+ **Example**:
58
+ ```python
59
+ @module(imports=[ServiceModule])
60
+ class Module:
61
+ def __init__(self):
62
+ pass
63
+
64
+ @create()
65
+ def create_registry(self) -> ConsulComponentRegistry:
66
+ return ConsulComponentRegistry(Server.port, "http://localhost:8500") # a consul based registry!
67
+
68
+ environment = Environment(Module)
69
+ service_manager = environment.get(ServiceManager)
70
+
71
+ service = service_manager.get_service(TestService)
72
+
73
+ service.hello("world")
74
+ ```
75
+
76
+ The technical details are completely transparent, as a dynamic proxy encapsulates the internals.
77
+
78
+ As we can also host implementations, lets look at this side as well:
79
+
80
+ ```python
81
+ @implementation()
82
+ class TestComponentImpl(AbstractComponent, TestComponent):
83
+ # constructor
84
+
85
+ def __init__(self):
86
+ super().__init__()
87
+
88
+ # implement Component
89
+
90
+ def get_addresses(self, port: int) -> list[ChannelAddress]:
91
+ return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
92
+
93
+ @implementation()
94
+ class TestServiceImpl(TestService):
95
+ def __init__(self):
96
+ pass
97
+
98
+ def hello(self, message: str) -> str:
99
+ return f"hello {message}"
100
+ ```
101
+
102
+ The interesting part if the `get_addresses` method that return a list of channel addresses, that can be used to execute remote calls.
103
+ In this case a channel is used that exposes a single http endpoint, that will dispatch to the correct service method.
104
+ This information is registered with the appropriate component registry and is used by other processes.
105
+
106
+ The required - `FastAPI` - infrastructure to expose those services is started with the call:
107
+
108
+ ```python
109
+ server = FastAPIServer(host="0.0.0.0", port=8000)
110
+
111
+ environment = server.boot(Module)
112
+ ```
113
+
114
+ Of course, service can also be called locally. In case of multiple possible channels, a keyword argument is used to
115
+ determine a specific channel. As a local channel has the name "local", the appropriate call is:
116
+
117
+ ```python
118
+ service = service_manager.get_service(TestService, preferred_channel="local")
119
+ ```
120
+
121
+ ## Features
122
+
123
+ The library offers:
124
+
125
+ - sync and async support
126
+ - multiple - extensible - channel implementations supporting dataclasses and pydantic data models.
127
+ - ability to customize http calls with interceptors ( via the AOP abilities )
128
+ - `fastapi` based channels covering simple rest endpoints including `msgpack` support.
129
+ - `httpx` based clients for dispatching channels and simple rest endpoint with the help of low-level decorators.
130
+ - first registry implementation based on `consul`
131
+ - support for configurable health checks
132
+
133
+ As well as the DI and AOP core, all mechanisms are heavily optimized.
134
+ A simple benchmark resulted in message roundtrips in significanlty under a ms per call.
135
+
136
+ Let's see some details
137
+
138
+ ## Service and Component declaration
139
+
140
+ Every service needs to inherit from the "tagging interface" `Service`
141
+
142
+ ```python
143
+ @service(name="test-service", description="test service")
144
+ class TestService(Service):
145
+ @abstractmethod
146
+ def hello(self, message: str) -> str:
147
+ pass
148
+ ```
149
+
150
+ The decorator can add a name and a description. If `name` is not set, the class name converted to snake case is used.
151
+
152
+ A component needs to derive from `Component`:
153
+
154
+ ```python
155
+ @component(services =[TestService])
156
+ class TestComponent(Component):
157
+ pass
158
+ ```
159
+
160
+ The `services` argument references a list of service interfaces that are managed by this component, meaning that they all are
161
+ exposed by the same channels.
162
+
163
+ `Component` defines the abstract methods:
164
+
165
+ - `def startup(self) -> None`
166
+ called initially after booting the system
167
+
168
+ - `def shutdown(self) -> None:`
169
+ called before shutting fown the system
170
+
171
+ - `def get_addresses(self, port: int) -> list[ChannelAddress]:`
172
+ return a list of available `ChannelAddress`es that this component exposes
173
+
174
+ - `def get_status(self) -> ComponentStatus:`
175
+ return the status of this component ( one of the `ComponentStatus` enums `VIRGIN`, `RUNNING`, and `STOPPED`)
176
+
177
+ - `async def get_health(self) -> HealthCheckManager.Health:`
178
+ return the health status of a component implementation.
179
+
180
+ ## Service and Component implementation
181
+
182
+ Service implementations implement the corresponding interface and are decorated with `@implementation`
183
+
184
+ ```python
185
+ @implementation()
186
+ class TestServiceImpl(TestService):
187
+ def __init__(self):
188
+ pass
189
+ ```
190
+
191
+ The constructor is required since the instances are managed by the DI framework.
192
+
193
+ Component implementations derive from the interface and the abstract base class `AbstractComponent`
194
+
195
+ ```python
196
+ @implementation()
197
+ class TestComponentImpl(AbstractComponent, TestComponent):
198
+ # constructor
199
+
200
+ def __init__(self):
201
+ super().__init__()
202
+
203
+ # implement Component
204
+
205
+ def get_addresses(self, port: int) -> list[ChannelAddress]:
206
+ return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
207
+ ```
208
+
209
+ As a minimum you have to declare the constructor and the `get_addresses` method, that exposes channel addresses
210
+
211
+ ## Health Checks
212
+
213
+ Every component can declare a HTTP health endpoint and the corresponding logic to compute the current status.
214
+
215
+ Two additional things have to be done:
216
+
217
+ - adding a `@health(<endpoint>)` decorator to the class
218
+ - implementing the `get_health()` method that returns a `HealthCheckManager.Health`
219
+
220
+ While you can instantiate the `Health` class directly via
221
+
222
+ ```
223
+ HealthCheckManager.Health(HealtStatus.OK)
224
+ ```
225
+
226
+ it typically makes more sense to let the system execute a number of configured checks and compute the overall result automatically.
227
+
228
+ For this purpose injectable classes can be decorated with `@health_checks()` that contain methods in turn decorated with `@health_check`
229
+
230
+ **Example**:
231
+
232
+ ```python
233
+ @health_checks()
234
+ @injectable()
235
+ class Checks:
236
+ def __init__(self):
237
+ pass
238
+
239
+ @health_check(fail_if_slower_than=1)
240
+ def check_performance(self, result: HealthCheckManager.Result):
241
+ ... # should be done in under a second
242
+
243
+ @health_check(name="check", cache=10)
244
+ def check(self, result: HealthCheckManager.Result):
245
+ ok = ...
246
+ result.set_status(if ok HealthStatus.OK else HealthStatus.ERROR)
247
+ ```
248
+
249
+ 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
250
+
251
+ ```
252
+ set_status(status: HealthStatus, details = "")
253
+ ```
254
+
255
+ When called, the default is already `OK`.
256
+
257
+ The decorator accepts a couple of parameters:
258
+
259
+ - `fail_if_slower_than=0`
260
+ 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`
261
+ - `cache`
262
+ 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.
263
+
264
+ ## Service Manager
265
+
266
+ `ServiceManager` is the central class used to retrieve service proxies.
267
+
268
+ ```python
269
+ def get_service(self, service_type: Type[T], preferred_channel="") -> T
270
+ ```
271
+
272
+ - `type` is the requested service interface
273
+ - `preferred_channel` the name of the preferred channel.
274
+
275
+ If not specified, the first registered channel is used, which btw. is a local channel - called `local` - in case of implementing services.
276
+
277
+ ## Component Registry
278
+
279
+ The component registry is the place where component implementations are registered and retrieved.
280
+
281
+ In addition to a `LocalComponentRegistry` ( which is used for testing purposes ) the only implementation is
282
+
283
+ `ConsulComponentRegistry`
284
+
285
+ Constructor arguments are
286
+
287
+ - `port: int` the own port
288
+ - `consul: Consul` the consul instance
289
+
290
+ The component registry is also responsible to execute regular health-checks to track component healths.
291
+ 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
292
+ which will be propagated to channels talking to the appropriate component.
293
+
294
+ Currently, this only affects the list of possible URLs which are required by the channels!
295
+
296
+ ## Channels
297
+
298
+ Channels implement the possible transport layer protocols. In the sense of a dynamic proxy, they are the invocation handlers!
299
+
300
+ Several channels are implemented:
301
+
302
+ - `dispatch-json`
303
+ channel that dispatches generic `Request` objects via a `invoke` POST-call
304
+ - `dispatch-msgpack`
305
+ channel that dispatches generic `Request` objects via a `invoke` POST-call after packing the json with msgpack
306
+ - `rest`
307
+ channel that executes regular rest-calls as defined by a couple of decorators.
308
+
309
+ All channels react on changed URLs as provided by the component registry.
310
+
311
+ A so called `URLSelector` is used internally to provide URLs for every single call. Two subclasses exist that offer a different logic
312
+
313
+ - `FirstURLSelector` always returns the first URL of the list of possible URLs
314
+ - `RoundRobinURLSelector` switches sequentially between all URLs.
315
+
316
+ To customize the behavior, an around advice can be implemented easily:
317
+
318
+ **Example**:
319
+
320
+ ```python
321
+ @advice
322
+ class ChannelAdvice:
323
+ def __init__(self):
324
+ pass
325
+
326
+ @advice
327
+ class ChannelAdvice:
328
+ def __init__(self):
329
+ pass
330
+
331
+ @around(methods().named("customize").of_type(Channel))
332
+ def customize_channel(self, invocation: Invocation):
333
+ channel = cast(Channel, invocation.args[0])
334
+
335
+ channel.select_round_robin() # or select_first_url()
336
+
337
+ return invocation.proceed()
338
+ ```
339
+
340
+ ### Performance
341
+
342
+ I benchmarked the different implementations with a recursive dataclass as an argument and return value.
343
+ The avg response times - on a local server - where all below 1ms per call.
344
+
345
+ - rest calls are the slowest ( about 0.7ms )
346
+ - dispatching-json 20% faster
347
+ - dispatching-msgpack 30% faster
348
+
349
+ The biggest advantage of the dispatching flavors is, that you don't have to worry about the additional decorators!
350
+
351
+ ### Rest Calls
352
+
353
+ Invoking rest calls requires decorators and some marker annotations.
354
+
355
+ **Example**:
356
+
357
+ ```python
358
+ @service()
359
+ @rest("/api")
360
+ class TestService(Service):
361
+ @get("/hello/{message}")
362
+ def hello(self, message: str) -> str:
363
+ pass
364
+
365
+ @post("/post/")
366
+ def set_data(self, data: Body(Data)) -> Data:
367
+ pass
368
+ ```
369
+
370
+ The decorators `get`, `put`, `post` and `delete` specify the methods.
371
+
372
+ If the class is decorated with `@rest(<prefix>)`, the corresponding prefix will be appended at the beginning.
373
+
374
+ Additional annotations are
375
+ - `Body` the post body
376
+ - `QueryParam`marked for query params
377
+
378
+ ### Intercepting calls
379
+
380
+ The client side HTTP calling is done with `httpx` instances of type `Httpx.Client` or `Httpx.AsyncClient`.
381
+
382
+ To add the possibility to add interceptors - for token handling, etc. - the channel base class `HTTPXChannel` defines
383
+ the methods `make_client()` and `make_async_client` that can be modified with an around advice.
384
+
385
+ **Example**:
386
+
387
+ ```python
388
+ class InterceptingClient(httpx.Client):
389
+ # constructor
390
+
391
+ def __init__(self, *args, **kwargs):
392
+ self.token_provider = ...
393
+ super().__init__(*args, **kwargs)
394
+
395
+ # override
396
+
397
+ def request(self, method, url, *args, **kwargs):
398
+ headers = kwargs.pop("headers", {})
399
+ headers["Authorization"] = f"Bearer {self.token_provider()}"
400
+ kwargs["headers"] = headers
401
+
402
+ return super().request(method, url, *args, **kwargs)
403
+
404
+ @advice
405
+ class ChannelAdvice:
406
+ def __init__(self):
407
+ pass
408
+
409
+ @around(methods().named("make_client").of_type(HTTPXChannel))
410
+ def make_client(self, invocation: Invocation):
411
+ return InterceptingClient()
412
+ ```
413
+
414
+ ## FastAPI server
415
+
416
+ In order to expose components via HTTP, the corresponding infrastructure in form of a FastAPI server needs to be setup.
417
+
418
+
419
+ ```python
420
+ @module()
421
+ class Module():
422
+ def __init__(self):
423
+ pass
424
+
425
+ server = FastAPIServer(host="0.0.0.0", port=8000)
426
+
427
+ environment = server.boot(Module) # will start the http server
428
+ ```
429
+
430
+ This setup will also expose all service interfaces decorated with the corresponding http decorators!
431
+ No need to add any FastAPI decorators, since the mapping is already done internally!
432
+
433
+ ## Implementing Channels
434
+
435
+ 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)
436
+ and decorate it with `@channel(<name>)`
437
+
438
+ The main methods to implement are `ìnvoke` and `ìnvoke_async`
439
+
440
+ **Example**:
441
+
442
+ ```python
443
+ @channel("fancy")
444
+ class FancyChannel(Channel):
445
+ # constructor
446
+
447
+ def __init__(self):
448
+ super().__init__()
449
+
450
+ # override
451
+
452
+ def invoke(self, invocation: DynamicProxy.Invocation):
453
+ return ...
454
+
455
+ async def invoke_async(self, invocation: DynamicProxy.Invocation):
456
+ return await ...
457
+
458
+ ```
459
+
460
+ # Version History
461
+
462
+ **0.10.0**
463
+
464
+ - first release version
465
+
466
+
467
+
468
+
469
+
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "aspyx_service"
5
- version = "0.10.0"
5
+ version = "0.10.1"
6
6
  description = "Aspyx Service framework"
7
7
  authors = [{ name = "Andreas Ernst", email = "andreas.ernst7@gmail.com" }]
8
8
  readme = "README.md"
@@ -3,6 +3,8 @@ 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
@@ -24,6 +26,33 @@ class HTTPXChannel(Channel):
24
26
  "deserializers"
25
27
  ]
26
28
 
29
+ # class methods
30
+
31
+ @classmethod
32
+ def to_dict(cls, obj: Any) -> Any:
33
+ if isinstance(obj, BaseModel):
34
+ return obj.dict()
35
+
36
+
37
+ elif is_dataclass(obj):
38
+ return {
39
+ f.name: cls.to_dict(getattr(obj, f.name))
40
+
41
+ for f in fields(obj)
42
+ }
43
+
44
+ elif isinstance(obj, (list, tuple)):
45
+ return [cls.to_dict(item) for item in obj]
46
+
47
+ elif isinstance(obj, dict):
48
+ return {key: cls.to_dict(value) for key, value in obj.items()}
49
+
50
+ return obj
51
+
52
+ @classmethod
53
+ def to_json(cls, obj) -> str:
54
+ return json.dumps(cls.to_dict(obj))
55
+
27
56
  # constructor
28
57
 
29
58
  def __init__(self):
@@ -117,9 +146,10 @@ class DispatchJSONChannel(HTTPXChannel):
117
146
  service_name = self.service_names[invocation.type] # map type to registered service name
118
147
  request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}", args=invocation.args)
119
148
 
149
+ dict = self.to_dict(request)
120
150
  try:
121
151
  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())
152
+ result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=dict, timeout=30000.0).json())
123
153
  if result.exception is not None:
124
154
  raise RemoteServiceException(f"server side exception {result.exception}")
125
155
 
@@ -134,9 +164,10 @@ class DispatchJSONChannel(HTTPXChannel):
134
164
  request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
135
165
  args=invocation.args)
136
166
 
167
+ dict = self.to_dict(request)
137
168
  try:
138
169
  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)
170
+ data = await self.async_client.post(f"{self.get_url()}/invoke", json=dict, timeout=30000.0)
140
171
  result = Response(**data.json())
141
172
  if result.exception is not None:
142
173
  raise RemoteServiceException(f"server side exception {result.exception}")
@@ -171,7 +202,7 @@ class DispatchMSPackChannel(HTTPXChannel):
171
202
  args=invocation.args)
172
203
 
173
204
  try:
174
- packed = msgpack.packb(request.dict(), use_bin_type=True)
205
+ packed = msgpack.packb(self.to_dict(request), use_bin_type=True)
175
206
 
176
207
  response = self.get_client().post(
177
208
  f"{self.get_url()}/invoke",
@@ -196,7 +227,7 @@ class DispatchMSPackChannel(HTTPXChannel):
196
227
  args=invocation.args)
197
228
 
198
229
  try:
199
- packed = msgpack.packb(request.dict(), use_bin_type=True)
230
+ packed = msgpack.packb(self.to_dict(request), use_bin_type=True)
200
231
 
201
232
  response = await self.get_async_client().post(
202
233
  f"{self.get_url()}/invoke",
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
10
10
 
11
11
  import consul
12
12
 
13
+ from aspyx.di.configuration import inject_value
13
14
  from aspyx.util import StringBuilder
14
15
  from aspyx.di import on_init
15
16
  from .healthcheck import HealthCheckManager, HealthStatus
@@ -24,35 +25,23 @@ class ConsulComponentRegistry(ComponentRegistry):
24
25
  """
25
26
  # constructor
26
27
 
27
- def __init__(self, port: int, consul_url: str):
28
+ def __init__(self, port: int, consul: consul.Consul):
28
29
  self.port = port
29
30
  self.ip = Server.get_local_ip()
30
31
  self.running = False
31
- self.consul = None
32
+ self.consul = consul
32
33
  self.watchdog = None
33
34
  self.interval = 5
34
35
  self.last_index = {}
35
36
  self.component_addresses : dict[str, dict[str, ChannelInstances]] = {} # comp -> channel -> address
36
37
  self.watch_channels : list[Channel] = []
38
+ self.watchdog_interval = 5
37
39
 
38
- parsed = urlparse(consul_url)
40
+ # injections
39
41
 
40
- self.consul_host = parsed.hostname
41
- self.consul_port = parsed.port
42
-
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
- """
55
- return consul.Consul(host=host, port=port)
42
+ @inject_value("consul.watchdog.interval", default=5)
43
+ def set_interval(self, interval):
44
+ self.watchdog_interval = interval
56
45
 
57
46
  # lifecycle hooks
58
47
 
@@ -60,7 +49,6 @@ class ConsulComponentRegistry(ComponentRegistry):
60
49
  def setup(self):
61
50
  # create consul client
62
51
 
63
- self.consul = self.make_consul(host=self.consul_host, port=self.consul_port)
64
52
  self.running = True
65
53
 
66
54
  # start thread
@@ -122,7 +110,7 @@ class ConsulComponentRegistry(ComponentRegistry):
122
110
 
123
111
  # time to sleep
124
112
 
125
- time.sleep(5)
113
+ time.sleep(self.watchdog_interval)
126
114
 
127
115
  @abstractmethod
128
116
  def watch(self, channel: Channel) -> None:
@@ -189,19 +189,6 @@ class RestChannel(HTTPXChannel):
189
189
 
190
190
  return call
191
191
 
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
192
  # override
206
193
 
207
194
  def invoke(self, invocation: DynamicProxy.Invocation):
@@ -88,7 +88,7 @@ class FastAPIServer(Server):
88
88
  start the fastapi server in a thread
89
89
  """
90
90
 
91
- config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, log_level="info")
91
+ config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, log_level="debug")
92
92
  server = uvicorn.Server(config)
93
93
 
94
94
  thread = threading.Thread(target=server.run, daemon=True)
@@ -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 +0,0 @@
1
- aspyx-service
File without changes