aspyx-service 0.9.0__py3-none-any.whl

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

Potentially problematic release.


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

@@ -0,0 +1,199 @@
1
+ import inspect
2
+ import re
3
+ from dataclasses import is_dataclass, asdict
4
+
5
+ from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get_args, Type
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from aspyx.reflection import DynamicProxy, Decorators
10
+ from .service import RemoteServiceException, ServiceException, channel
11
+
12
+ T = TypeVar("T")
13
+
14
+ from .channels import HTTPXChannel
15
+
16
+ class BodyMarker:
17
+ pass
18
+
19
+ Body = lambda t: Annotated[t, BodyMarker]
20
+
21
+ class QueryParamMarker:
22
+ pass
23
+
24
+ QueryParam = lambda t: Annotated[t, QueryParamMarker]
25
+
26
+ # decorators
27
+
28
+ def rest(url):
29
+ def decorator(cls):
30
+ Decorators.add(cls, rest, url)
31
+
32
+ return cls
33
+ return decorator
34
+
35
+ def get(url):
36
+ def decorator(cls):
37
+ Decorators.add(cls, get, url)
38
+
39
+ return cls
40
+ return decorator
41
+
42
+
43
+ def post(url):
44
+ def decorator(cls):
45
+ Decorators.add(cls, post, url)
46
+
47
+ return cls
48
+
49
+ return decorator
50
+
51
+ def put(url):
52
+ def decorator(cls):
53
+ Decorators.add(cls, put, url)
54
+
55
+ return cls
56
+
57
+ return decorator
58
+
59
+ def delete(url):
60
+ def decorator(cls):
61
+ Decorators.add(cls, delete, url)
62
+
63
+ return cls
64
+
65
+ return decorator
66
+
67
+ def patch(url):
68
+ def decorator(cls):
69
+ Decorators.add(cls, patch, url)
70
+
71
+ return cls
72
+
73
+ return decorator
74
+
75
+
76
+
77
+
78
+ @channel("rest")
79
+ class RestChannel(HTTPXChannel):
80
+ __slots__ = [
81
+ "signature",
82
+ "url_template",
83
+ "type",
84
+ "return_type",
85
+ "path_param_names",
86
+ "query_param_names",
87
+ "body_param_name"
88
+ ]
89
+ # local class
90
+
91
+ class Call:
92
+ def __init__(self, type: Type, method : Callable):
93
+ self.signature = inspect.signature(method)
94
+
95
+ prefix = ""
96
+ if Decorators.has_decorator(type, rest):
97
+ prefix = Decorators.get_decorator(type, rest).args[0]
98
+
99
+ # find decorator
100
+
101
+ self.type = "get"
102
+ self.url_template = ""
103
+
104
+ decorators = Decorators.get_all(method)
105
+
106
+ for decorator in [get, post, put, delete, patch]:
107
+ descriptor = next((descriptor for descriptor in decorators if descriptor.decorator is decorator), None)
108
+ if descriptor is not None:
109
+ self.type = decorator.__name__
110
+ self.url_template = prefix + descriptor.args[0]
111
+
112
+ # parameters
113
+
114
+ self.path_param_names = set(re.findall(r"{(.*?)}", self.url_template))
115
+
116
+ hints = get_type_hints(method, include_extras=True)
117
+
118
+ self.body_param_name = None
119
+ self.query_param_names = set()
120
+
121
+ for param_name, hint in hints.items():
122
+ if get_origin(hint) is Annotated:
123
+ metadata = get_args(hint)[1:]
124
+ if BodyMarker in metadata:
125
+ self.body_param_name = param_name
126
+ elif QueryParamMarker in metadata:
127
+ self.query_param_names.add(param_name)
128
+
129
+ # return type
130
+
131
+ self.return_type = get_type_hints(method)['return']
132
+
133
+ # constructor
134
+
135
+ def __init__(self):
136
+ super().__init__("rest")
137
+
138
+ self.calls : dict[Callable, RestChannel.Call] = {}
139
+
140
+ # internal
141
+
142
+ def get_call(self, type: Type ,method: Callable):
143
+ call = self.calls.get(method, None)
144
+ if call is None:
145
+ call = RestChannel.Call(type, method)
146
+ self.calls[method] = call
147
+
148
+ return call
149
+
150
+ def to_dict(self, obj):
151
+ if obj is None:
152
+ return None
153
+ if is_dataclass(obj):
154
+ return asdict(obj)
155
+ elif isinstance(obj, BaseModel):
156
+ return obj.dict()
157
+ elif hasattr(obj, "__dict__"):
158
+ return vars(obj)
159
+ else:
160
+ # fallback for primitives etc.
161
+ return obj
162
+
163
+ # override
164
+
165
+ def invoke(self, invocation: DynamicProxy.Invocation):
166
+ call = self.get_call(invocation.type, invocation.method)
167
+
168
+ bound = call.signature.bind(self,*invocation.args, **invocation.kwargs)
169
+ bound.apply_defaults()
170
+ arguments = bound.arguments
171
+
172
+ # url
173
+
174
+ url = call.url_template.format(**arguments)
175
+ query_params = {k: arguments[k] for k in call.query_param_names if k in arguments}
176
+ body = {}
177
+ if call.body_param_name is not None:
178
+ body = self.to_dict(arguments.get(call.body_param_name))
179
+
180
+ # call
181
+
182
+ try:
183
+ if self.client is not None:
184
+ result = None
185
+ if call.type == "get":
186
+ result = self.client.get(self.get_url() + url, params=query_params, timeout=30000.0).json()
187
+ elif call.type == "put":
188
+ result = self.client.put(self.get_url() + url, params=query_params, timeout=30000.0).json()
189
+ elif call.type == "delete":
190
+ result = self.client.delete(self.get_url() + url, params=query_params, timeout=30000.0).json()
191
+ elif call.type == "post":
192
+ result = self.client.post(self.get_url() + url, params=query_params, json=body, timeout=30000.0).json()
193
+
194
+ return self.get_deserializer(invocation.type, invocation.method)(result)
195
+ else:
196
+ raise ServiceException(
197
+ f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
198
+ except Exception as e:
199
+ raise ServiceException(f"communication exception {e}") from e
@@ -0,0 +1,125 @@
1
+ """
2
+ deserialization functions
3
+ """
4
+ from dataclasses import is_dataclass, fields
5
+ from functools import lru_cache
6
+ from typing import get_origin, get_args, Union
7
+
8
+ from pydantic import BaseModel
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
+ class TypeDeserializer:
72
+ # constructor
73
+
74
+ def __init__(self, typ):
75
+ self.typ = typ
76
+ self.deserializer = self._build_deserializer(typ)
77
+
78
+ def __call__(self, value):
79
+ return self.deserializer(value)
80
+
81
+ # internal
82
+
83
+ def _build_deserializer(self, typ):
84
+ origin = get_origin(typ)
85
+ args = get_args(typ)
86
+
87
+ if origin is Union:
88
+ deserializers = [TypeDeserializer(arg) for arg in args if arg is not type(None)]
89
+ def deser_union(value):
90
+ if value is None:
91
+ return None
92
+ for d in deserializers:
93
+ try:
94
+ return d(value)
95
+ except Exception:
96
+ continue
97
+ return value
98
+ return deser_union
99
+
100
+ if isinstance(typ, type) and issubclass(typ, BaseModel):
101
+ return lambda v: typ.parse_obj(v)
102
+
103
+ if is_dataclass(typ):
104
+ field_deserializers = {f.name: TypeDeserializer(f.type) for f in fields(typ)}
105
+ def deser_dataclass(value):
106
+ return typ(**{
107
+ k: field_deserializers[k](v) for k, v in value.items()
108
+ })
109
+ return deser_dataclass
110
+
111
+ if origin is list:
112
+ item_deser = TypeDeserializer(args[0]) if args else lambda x: x
113
+ return lambda v: [item_deser(item) for item in v]
114
+
115
+ if origin is dict:
116
+ key_deser = TypeDeserializer(args[0]) if args else lambda x: x
117
+ val_deser = TypeDeserializer(args[1]) if len(args) > 1 else lambda x: x
118
+ return lambda v: {key_deser(k): val_deser(val) for k, val in v.items()}
119
+
120
+ # Fallback
121
+ return lambda v: v
122
+
123
+ @lru_cache(maxsize=512)
124
+ def get_deserializer(typ):
125
+ return TypeDeserializer(typ)
@@ -0,0 +1,213 @@
1
+ """
2
+ FastAPI server implementation for the aspyx service framework.
3
+ """
4
+ import atexit
5
+ import functools
6
+ import inspect
7
+ import threading
8
+ from typing import Type, Optional, Callable
9
+
10
+ from fastapi.responses import JSONResponse
11
+ import msgpack
12
+ import uvicorn
13
+
14
+ from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse
15
+
16
+ from aspyx.di import Environment
17
+ from aspyx.reflection import TypeDescriptor, Decorators
18
+
19
+ from .service import ComponentRegistry
20
+ from .healthcheck import HealthCheckManager
21
+
22
+ from .serialization import get_deserializer
23
+
24
+ from .service import Server, ServiceManager
25
+ from .channels import Request, Response
26
+
27
+ from .restchannel import get, post, put, delete, rest
28
+
29
+
30
+ class FastAPIServer(Server):
31
+ # constructor
32
+
33
+ def __init__(self, host="0.0.0.0", port=8000, **kwargs):
34
+ super().__init__()
35
+
36
+ self.host = host
37
+ Server.port = port
38
+ self.server_thread = None
39
+ self.environment : Optional[Environment] = None
40
+ self.service_manager : Optional[ServiceManager] = None
41
+ self.component_registry: Optional[ComponentRegistry] = None
42
+
43
+ self.router = APIRouter()
44
+ self.fast_api = FastAPI(host=self.host, port=Server.port, debug=True)
45
+
46
+ # cache
47
+
48
+ self.deserializers: dict[str, list[Callable]] = {}
49
+
50
+ # that's the overall dispatcher
51
+
52
+ self.router.post("/invoke")(self.invoke)
53
+
54
+ # private
55
+
56
+ def add_routes(self):
57
+ """
58
+ add everything that looks like a http endpoint
59
+ """
60
+
61
+ # go
62
+
63
+ for descriptor in self.service_manager.descriptors.values():
64
+ if not descriptor.is_component() and descriptor.is_local():
65
+ prefix = ""
66
+
67
+ type_descriptor = TypeDescriptor.for_type(descriptor.type)
68
+ instance = self.environment.get(descriptor.implementation)
69
+
70
+ if type_descriptor.has_decorator(rest):
71
+ prefix = type_descriptor.get_decorator(rest).args[0]
72
+
73
+ for method in type_descriptor.get_methods():
74
+ decorator = next((decorator for decorator in Decorators.get(method.method) if decorator.decorator in [get, put, post, delete]), None)
75
+ if decorator is not None:
76
+ self.router.add_api_route(
77
+ path=prefix + decorator.args[0],
78
+ endpoint=getattr(instance, method.get_name()),
79
+ methods=[decorator.decorator.__name__],
80
+ name=f"{descriptor.get_component_descriptor().name}.{descriptor.name}.{method.get_name()}",
81
+ response_model=method.return_type,
82
+ )
83
+
84
+ def start(self):
85
+ def run():
86
+ uvicorn.run(self.fast_api, host=self.host, port=self.port, access_log=False)
87
+
88
+ self.server_thread = threading.Thread(target=run, daemon=True)
89
+ self.server_thread.start()
90
+
91
+ print(f"server started on {self.host}:{self.port}")
92
+
93
+ def get_deserializers(self, service: Type, method):
94
+ deserializers = self.deserializers.get(method, None)
95
+ if deserializers is None:
96
+ descriptor = TypeDescriptor.for_type(service).get_method(method.__name__)
97
+
98
+ deserializers = [get_deserializer(type) for type in descriptor.param_types]
99
+ self.deserializers[method] = deserializers
100
+
101
+ return deserializers
102
+
103
+ def deserialize_args(self, request: Request, type: Type, method: Callable) -> list:
104
+ args = list(request.args)
105
+
106
+ deserializers = self.get_deserializers(type, method)
107
+
108
+ for i in range(0, len(args)):
109
+ args[i] = deserializers[i](args[i])
110
+
111
+ return args
112
+
113
+ async def invoke(self, http_request: HttpRequest):
114
+ content_type = http_request.headers.get("content-type", "")
115
+
116
+ content = "json"
117
+ if "application/msgpack" in content_type:
118
+ content = "msgpack"
119
+ raw_data = await http_request.body()
120
+ data = msgpack.unpackb(raw_data, raw=False)
121
+ elif "application/json" in content_type:
122
+ data = await http_request.json()
123
+ else:
124
+ return HttpResponse(
125
+ content="Unsupported Content-Type",
126
+ status_code=415,
127
+ media_type="text/plain"
128
+ )
129
+
130
+ request = Request(**data)
131
+
132
+ if content == "json":
133
+ return await self.dispatch(request)
134
+ else:
135
+ return HttpResponse(
136
+ content=msgpack.packb(await self.dispatch(request), use_bin_type=True),
137
+ media_type="application/msgpack"
138
+ )
139
+
140
+ async def dispatch(self, request: Request) :
141
+ ServiceManager.logger.debug("dispatch request %s", request.method)
142
+
143
+ # <comp>:<service>:<method>
144
+
145
+ parts = request.method.split(":")
146
+
147
+ #component = parts[0]
148
+ service_name = parts[1]
149
+ method_name = parts[2]
150
+
151
+ service_descriptor = ServiceManager.descriptors_by_name[service_name]
152
+ service = self.service_manager.get_service(service_descriptor.type, preferred_channel="local")
153
+
154
+ method = getattr(service, method_name)
155
+
156
+ args = self.deserialize_args(request, service_descriptor.type, method)
157
+ try:
158
+ if inspect.iscoroutinefunction(method):
159
+ result = await method(*args)
160
+ else:
161
+ result = method(*args)
162
+
163
+ return Response(result=result, exception=None).dict()
164
+ except Exception as e:
165
+ return Response(result=None, exception=str(e)).dict()
166
+
167
+ # override
168
+
169
+ def route(self, url: str, callable: Callable):
170
+ self.router.get(url)(callable)
171
+
172
+ def route_health(self, url: str, callable: Callable):
173
+ async def get_health_response():
174
+ health : HealthCheckManager.Health = await callable()
175
+
176
+ return JSONResponse(
177
+ status_code= self.component_registry.map_health(health),
178
+ content = health.to_dict()
179
+ )
180
+
181
+ self.router.get(url)(get_health_response)
182
+
183
+ def boot(self, module_type: Type) -> Environment:
184
+ # setup environment
185
+
186
+ self.environment = Environment(module_type)
187
+ self.service_manager = self.environment.get(ServiceManager)
188
+ self.component_registry = self.environment.get(ComponentRegistry)
189
+
190
+ self.service_manager.startup(self)
191
+
192
+ # add routes
193
+
194
+ self.add_routes()
195
+ self.fast_api.include_router(self.router)
196
+
197
+ #for route in self.fast_api.routes:
198
+ # print(f"{route.name}: {route.path} [{route.methods}]")
199
+
200
+ # start server thread
201
+
202
+ self.start()
203
+
204
+ # shutdown
205
+
206
+ def cleanup():
207
+ self.service_manager.shutdown()
208
+
209
+ atexit.register(cleanup)
210
+
211
+ # done
212
+
213
+ return self.environment