aspyx-service 0.10.3__py3-none-any.whl → 0.10.4__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.

@@ -3,18 +3,21 @@ rest channel implementation
3
3
  """
4
4
  import inspect
5
5
  import re
6
- from dataclasses import is_dataclass, asdict
6
+ from dataclasses import is_dataclass
7
7
 
8
8
  from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get_args, Type
9
9
 
10
+
10
11
  from pydantic import BaseModel
11
12
 
13
+ from .channels import HTTPXChannel
14
+
12
15
  from aspyx.reflection import DynamicProxy, Decorators
13
16
  from .service import channel, ServiceCommunicationException
14
17
 
15
18
  T = TypeVar("T")
16
19
 
17
- from .channels import HTTPXChannel
20
+
18
21
 
19
22
  class BodyMarker:
20
23
  pass
@@ -169,7 +172,7 @@ class RestChannel(HTTPXChannel):
169
172
  for param_name, hint in hints.items():
170
173
  if get_origin(hint) is Annotated:
171
174
  metadata = get_args(hint)[1:]
172
-
175
+
173
176
  if BodyMarker in metadata:
174
177
  self.body_param_name = param_name
175
178
  param_names.remove(param_name)
@@ -179,7 +182,7 @@ class RestChannel(HTTPXChannel):
179
182
 
180
183
  # check if something is missing
181
184
 
182
- if len(param_names) > 0:
185
+ if param_names:
183
186
  # check body params
184
187
  if self.type in ("post", "put", "patch"):
185
188
  if self.body_param_name is None:
@@ -248,14 +251,11 @@ class RestChannel(HTTPXChannel):
248
251
 
249
252
  try:
250
253
  result = None
251
- if call.type == "get":
252
- result = await self.get_async_client().get(self.get_url() + url, params=query_params, timeout=self.timeout)
253
- elif call.type == "put":
254
- result = await self.get_async_client().put(self.get_url() + url, params=query_params, timeout=self.timeout)
255
- elif call.type == "delete":
256
- result = await self.get_async_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout)
254
+ if call.type in ["get", "put", "delete"]:
255
+ result = await self.request_async(call.type, self.get_url() + url, params=query_params, timeout=self.timeout)
256
+
257
257
  elif call.type == "post":
258
- result = await self.get_async_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
258
+ result = await self.request_async("post", self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
259
259
 
260
260
  return self.get_deserializer(invocation.type, invocation.method)(result.json())
261
261
  except ServiceCommunicationException:
@@ -283,14 +283,11 @@ class RestChannel(HTTPXChannel):
283
283
 
284
284
  try:
285
285
  result = None
286
- if call.type == "get":
287
- result = self.get_client().get(self.get_url() + url, params=query_params, timeout=self.timeout)
288
- elif call.type == "put":
289
- result = self.get_client().put(self.get_url() + url, params=query_params, timeout=self.timeout)
290
- elif call.type == "delete":
291
- result = self.get_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout)
286
+ if call.type in ["get", "put", "delete"]:
287
+ result = self.request("get", self.get_url() + url, params=query_params, timeout=self.timeout)
288
+
292
289
  elif call.type == "post":
293
- result = self.get_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
290
+ result = self.request( "post", self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
294
291
 
295
292
  return self.get_deserializer(invocation.type, invocation.method)(result.json())
296
293
  except ServiceCommunicationException:
@@ -37,7 +37,7 @@ class TypeDeserializer:
37
37
  return deser_union
38
38
 
39
39
  if isinstance(typ, type) and issubclass(typ, BaseModel):
40
- return typ.parse_obj
40
+ return typ.model_validate
41
41
 
42
42
  if is_dataclass(typ):
43
43
  field_deserializers = {f.name: TypeDeserializer(f.type) for f in fields(typ)}
@@ -61,7 +61,7 @@ class TypeDeserializer:
61
61
 
62
62
  # Fallback
63
63
  return lambda v: v
64
-
64
+
65
65
  class TypeSerializer:
66
66
  def __init__(self, typ):
67
67
  self.typ = typ
@@ -88,7 +88,7 @@ class TypeSerializer:
88
88
  return ser_union
89
89
 
90
90
  if isinstance(typ, type) and issubclass(typ, BaseModel):
91
- return lambda v: v.dict() if v is not None else None
91
+ return lambda v: v.model_dump() if v is not None else None
92
92
 
93
93
  if is_dataclass(typ):
94
94
  field_serializers = {f.name: TypeSerializer(f.type) for f in fields(typ)}
aspyx_service/server.py CHANGED
@@ -4,15 +4,18 @@ FastAPI server implementation for the aspyx service framework.
4
4
  import atexit
5
5
  import inspect
6
6
  import threading
7
- from typing import Type, Optional, Callable
7
+ from typing import Type, Optional, Callable, Any
8
8
 
9
9
  from fastapi.responses import JSONResponse
10
10
  import msgpack
11
11
  import uvicorn
12
12
 
13
- from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse
13
+ from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse, HTTPException
14
+ import contextvars
14
15
 
15
- from aspyx.di import Environment
16
+ from starlette.middleware.base import BaseHTTPMiddleware
17
+
18
+ from aspyx.di import Environment, injectable, on_init, inject_environment
16
19
  from aspyx.reflection import TypeDescriptor, Decorators
17
20
 
18
21
  from .service import ComponentRegistry
@@ -21,28 +24,110 @@ from .healthcheck import HealthCheckManager
21
24
  from .serialization import get_deserializer
22
25
 
23
26
  from .service import Server, ServiceManager
24
- from .channels import Request, Response
27
+ from .channels import Request, Response, TokenContext
25
28
 
26
29
  from .restchannel import get, post, put, delete, rest
27
30
 
31
+ class RequestContext:
32
+ """
33
+ A request context is used to remember the current http request in the current thread
34
+ """
35
+ request_var = contextvars.ContextVar("request")
36
+
37
+ @classmethod
38
+ def get_request(cls) -> Request:
39
+ """
40
+ Return the current http request
41
+
42
+ Returns:
43
+ the current http request
44
+ """
45
+ return cls.request_var.get()
46
+
47
+ # constructor
48
+
49
+ def __init__(self, app):
50
+ self.app = app
51
+
52
+ async def __call__(self, scope, receive, send):
53
+ if scope["type"] != "http":
54
+ await self.app(scope, receive, send)
55
+ return
28
56
 
57
+ request = HttpRequest(scope)
58
+ token = self.request_var.set(request)
59
+ try:
60
+ await self.app(scope, receive, send)
61
+ finally:
62
+ self.request_var.reset(token)
63
+
64
+ class TokenMiddleware(BaseHTTPMiddleware):
65
+ async def dispatch(self, request: Request, call_next):
66
+ access_token = request.cookies.get("access_token") or request.headers.get("Authorization")
67
+ #refresh_token = request.cookies.get("refresh_token")
68
+
69
+ if access_token:
70
+ TokenContext.set(access_token)#, refresh_token)
71
+
72
+ try:
73
+ return await call_next(request)
74
+ finally:
75
+ TokenContext.clear()
76
+
77
+ def create_server() -> FastAPI:
78
+ server = FastAPI()
79
+
80
+ server.add_middleware(RequestContext)
81
+ server.add_middleware(TokenMiddleware)
82
+
83
+ return server
84
+
85
+ @injectable()
29
86
  class FastAPIServer(Server):
30
87
  """
31
88
  A server utilizing fastapi framework.
32
89
  """
90
+
91
+ fast_api = create_server()
92
+
93
+ # class methods
94
+
95
+ @classmethod
96
+ def boot(cls, module: Type, host="0.0.0.0", port=8000, start = True) -> 'FastAPIServer':
97
+ """
98
+ boot the DI infrastructure of the supplied module and optionally start a fastapi thread given the url
99
+ Args:
100
+ module: the module to initialize the environment
101
+ host: listen address
102
+ port: the port
103
+
104
+ Returns:
105
+ thr created server
106
+ """
107
+ cls.port = port
108
+
109
+ environment = Environment(module)
110
+
111
+ server = environment.get(FastAPIServer)
112
+
113
+ if start:
114
+ server.start_fastapi(host)
115
+
116
+ return server
117
+
33
118
  # constructor
34
119
 
35
- def __init__(self, host="0.0.0.0", port=8000, **kwargs):
120
+ def __init__(self, service_manager: ServiceManager, component_registry: ComponentRegistry):
36
121
  super().__init__()
37
122
 
38
- self.host = host
39
- Server.port = port
123
+ self.environment : Optional[Environment] = None
124
+ self.service_manager = service_manager
125
+ self.component_registry = component_registry
126
+
127
+ self.host = "localhost"
40
128
  self.server_thread = None
41
- self.service_manager : Optional[ServiceManager] = None
42
- self.component_registry: Optional[ComponentRegistry] = None
43
129
 
44
130
  self.router = APIRouter()
45
- self.fast_api = FastAPI()
46
131
 
47
132
  # cache
48
133
 
@@ -52,15 +137,37 @@ class FastAPIServer(Server):
52
137
 
53
138
  self.router.post("/invoke")(self.invoke)
54
139
 
140
+ # inject
141
+
142
+ @inject_environment()
143
+ def set_environment(self, environment: Environment):
144
+ self.environment = environment
145
+
146
+ @on_init()
147
+ def on_init(self):
148
+ self.service_manager.startup(self)
149
+
150
+ # add routes
151
+
152
+ self.add_routes()
153
+ self.fast_api.include_router(self.router)
154
+
155
+ #for route in self.fast_api.routes:
156
+ # print(f"{route.name}: {route.path} [{route.methods}]")
157
+
158
+ # add cleanup hook
159
+
160
+ def cleanup():
161
+ self.service_manager.shutdown()
162
+
163
+ atexit.register(cleanup)
164
+
55
165
  # private
56
166
 
57
167
  def add_routes(self):
58
168
  """
59
- add everything that looks like a http endpoint
169
+ add everything that looks like an http endpoint
60
170
  """
61
-
62
- # go
63
-
64
171
  for descriptor in self.service_manager.descriptors.values():
65
172
  if not descriptor.is_component() and descriptor.is_local():
66
173
  prefix = ""
@@ -82,12 +189,13 @@ class FastAPIServer(Server):
82
189
  response_model=method.return_type,
83
190
  )
84
191
 
85
- def start_fastapi_thread(self):
192
+ def start_fastapi(self, host: str):
86
193
  """
87
194
  start the fastapi server in a thread
88
195
  """
196
+ self.host = host
89
197
 
90
- config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, access_log=False) #log_level="debug"
198
+ config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, access_log=False)
91
199
  server = uvicorn.Server(config)
92
200
 
93
201
  thread = threading.Thread(target=server.run, daemon=True)
@@ -95,7 +203,6 @@ class FastAPIServer(Server):
95
203
 
96
204
  return thread
97
205
 
98
-
99
206
  def get_deserializers(self, service: Type, method):
100
207
  deserializers = self.deserializers.get(method, None)
101
208
  if deserializers is None:
@@ -106,8 +213,8 @@ class FastAPIServer(Server):
106
213
 
107
214
  return deserializers
108
215
 
109
- def deserialize_args(self, request: Request, type: Type, method: Callable) -> list:
110
- args = list(request.args)
216
+ def deserialize_args(self, args: list[Any], type: Type, method: Callable) -> list:
217
+ #args = list(request.args)
111
218
 
112
219
  deserializers = self.get_deserializers(type, method)
113
220
 
@@ -133,22 +240,22 @@ class FastAPIServer(Server):
133
240
  media_type="text/plain"
134
241
  )
135
242
 
136
- request = Request(**data)
243
+ request = data
137
244
 
138
245
  if content == "json":
139
- return await self.dispatch(request)
246
+ return await self.dispatch(http_request, request)
140
247
  else:
141
248
  return HttpResponse(
142
- content=msgpack.packb(await self.dispatch(request), use_bin_type=True),
249
+ content=msgpack.packb(await self.dispatch(http_request, request), use_bin_type=True),
143
250
  media_type="application/msgpack"
144
251
  )
145
252
 
146
- async def dispatch(self, request: Request) :
147
- ServiceManager.logger.debug("dispatch request %s", request.method)
253
+ async def dispatch(self, http_request: HttpRequest, request: dict) :
254
+ ServiceManager.logger.debug("dispatch request %s", request["method"])
148
255
 
149
256
  # <comp>:<service>:<method>
150
257
 
151
- parts = request.method.split(":")
258
+ parts = request["method"].split(":")
152
259
 
153
260
  #component = parts[0]
154
261
  service_name = parts[1]
@@ -159,16 +266,20 @@ class FastAPIServer(Server):
159
266
 
160
267
  method = getattr(service, method_name)
161
268
 
162
- args = self.deserialize_args(request, service_descriptor.type, method)
269
+ args = self.deserialize_args(request["args"], service_descriptor.type, method)
163
270
  try:
164
271
  if inspect.iscoroutinefunction(method):
165
272
  result = await method(*args)
166
273
  else:
167
274
  result = method(*args)
168
275
 
169
- return Response(result=result, exception=None).dict()
276
+ return Response(result=result, exception=None).model_dump()
277
+
278
+ except HTTPException as e:
279
+ raise
280
+
170
281
  except Exception as e:
171
- return Response(result=None, exception=str(e)).dict()
282
+ return Response(result=None, exception=str(e)).model_dump()
172
283
 
173
284
  # override
174
285
 
@@ -185,44 +296,3 @@ class FastAPIServer(Server):
185
296
  )
186
297
 
187
298
  self.router.get(url)(get_health_response)
188
-
189
- def boot(self, module_type: Type) -> Environment:
190
- """
191
- startup the service manager, DI framework and the fastapi server based on the supplied module
192
-
193
- Args:
194
- module_type: the module
195
-
196
- Returns:
197
-
198
- """
199
- # setup environment
200
-
201
- self.environment = Environment(module_type)
202
- self.service_manager = self.environment.get(ServiceManager)
203
- self.component_registry = self.environment.get(ComponentRegistry)
204
-
205
- self.service_manager.startup(self)
206
-
207
- # add routes
208
-
209
- self.add_routes()
210
- self.fast_api.include_router(self.router)
211
-
212
- #for route in self.fast_api.routes:
213
- # print(f"{route.name}: {route.path} [{route.methods}]")
214
-
215
- # start server thread
216
-
217
- self.start_fastapi_thread()
218
-
219
- # shutdown
220
-
221
- def cleanup():
222
- self.service_manager.shutdown()
223
-
224
- atexit.register(cleanup)
225
-
226
- # done
227
-
228
- return self.environment
aspyx_service/service.py CHANGED
@@ -15,9 +15,10 @@ from typing import Type, TypeVar, Generic, Callable, Optional, cast
15
15
 
16
16
  from aspyx.di import injectable, Environment, Providers, ClassInstanceProvider, inject_environment, order, \
17
17
  Lifecycle, LifecycleCallable, InstanceProvider
18
+ from aspyx.di.aop.aop import ClassAspectTarget
18
19
  from aspyx.reflection import Decorators, DynamicProxy, DecoratorDescriptor, TypeDescriptor
19
20
  from aspyx.util import StringBuilder
20
- from .healthcheck import HealthCheckManager
21
+ from .healthcheck import HealthCheckManager, HealthStatus
21
22
 
22
23
  T = TypeVar("T")
23
24
 
@@ -55,10 +56,6 @@ class Server(ABC):
55
56
  def get(self, type: Type[T]) -> T:
56
57
  return self.environment.get(type)
57
58
 
58
- @abstractmethod
59
- def boot(self, module_type: Type):
60
- pass
61
-
62
59
  @abstractmethod
63
60
  def route(self, url : str, callable: Callable):
64
61
  pass
@@ -121,7 +118,7 @@ class Component(Service):
121
118
  @abstractmethod
122
119
  def get_addresses(self, port: int) -> list[ChannelAddress]:
123
120
  """
124
- returns a list of channel addresses that expose this components services.
121
+ returns a list of channel addresses that expose this component's services.
125
122
 
126
123
  Args:
127
124
  port: the port of a server hosting this component
@@ -164,6 +161,9 @@ class AbstractComponent(Component, ABC):
164
161
  def get_status(self) -> ComponentStatus:
165
162
  return self.status
166
163
 
164
+ async def get_health(self) -> HealthCheckManager.Health:
165
+ return HealthCheckManager.Health(HealthStatus.OK)
166
+
167
167
  def to_snake_case(name: str) -> str:
168
168
  return re.sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
169
169
 
@@ -401,6 +401,22 @@ class RemoteServiceException(ServiceException):
401
401
  base class for service exceptions occurring on the server side
402
402
  """
403
403
 
404
+
405
+ class AuthorizationException(ServiceException):
406
+ pass
407
+
408
+ class TokenException(AuthorizationException):
409
+ pass
410
+
411
+ class InvalidTokenException(TokenException):
412
+ pass
413
+
414
+ class MissingTokenException(TokenException):
415
+ pass
416
+
417
+ class TokenExpiredException(TokenException):
418
+ pass
419
+
404
420
  class Channel(DynamicProxy.InvocationHandler, ABC):
405
421
  """
406
422
  A channel is a dynamic proxy invocation handler and transparently takes care of remoting.
@@ -432,7 +448,7 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
432
448
  a url selector always retrieving the first URL given a list of possible URLS
433
449
  """
434
450
  def get(self, urls: list[str]) -> str:
435
- if len(urls) == 0:
451
+ if not urls:
436
452
  raise ServiceCommunicationException("no known url")
437
453
 
438
454
  return urls[0]
@@ -445,7 +461,7 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
445
461
  self.index = 0
446
462
 
447
463
  def get(self, urls: list[str]) -> str:
448
- if len(urls) > 0:
464
+ if urls:
449
465
  try:
450
466
  return urls[self.index]
451
467
  finally:
@@ -462,6 +478,8 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
462
478
  self.address: Optional[ChannelInstances] = None
463
479
  self.url_selector : Channel.URLSelector = Channel.FirstURLSelector()
464
480
 
481
+ self.select_round_robin()
482
+
465
483
  # public
466
484
 
467
485
  def customize(self):
@@ -692,11 +710,17 @@ class ServiceManager:
692
710
 
693
711
  # fetch health
694
712
 
695
- descriptor.health = Decorators.get_decorator(descriptor.implementation, health).args[0]
713
+ health_name = None
714
+ health_descriptor = Decorators.get_decorator(descriptor.implementation, health)
715
+
716
+ if health_descriptor is not None:
717
+ health_name = health_descriptor.args[0]
718
+
719
+ descriptor.health = health_name
696
720
 
697
721
  self.component_registry.register(descriptor.get_component_descriptor(), [ChannelAddress("local", "")])
698
722
 
699
- health_name = next((decorator.args[0] for decorator in Decorators.get(descriptor.type) if decorator.decorator is health), None)
723
+ #health_name = next((decorator.args[0] for decorator in Decorators.get(descriptor.type) if decorator.decorator is health), None)
700
724
 
701
725
  # startup
702
726
 
@@ -704,7 +728,8 @@ class ServiceManager:
704
728
 
705
729
  # add health route
706
730
 
707
- server.route_health(health_name, instance.get_health)
731
+ if health_name is not None:
732
+ server.route_health(health_name, instance.get_health)
708
733
 
709
734
  # register addresses
710
735
 
@@ -733,7 +758,7 @@ class ServiceManager:
733
758
  addresses = self.component_registry.get_addresses(component_descriptor) # component, channel + urls
734
759
  address = next((address for address in addresses if address.channel == preferred_channel), None)
735
760
  if address is None:
736
- if len(addresses) > 0:
761
+ if addresses:
737
762
  # return the first match
738
763
  address = addresses[0]
739
764
  else:
@@ -908,3 +933,13 @@ class ServiceLifecycleCallable(LifecycleCallable):
908
933
 
909
934
  def args(self, decorator: DecoratorDescriptor, method: TypeDescriptor.MethodDescriptor, environment: Environment):
910
935
  return [self.manager.get_service(method.param_types[0], preferred_channel=decorator.args[0])]
936
+
937
+ def component_services(component_type: Type) -> ClassAspectTarget:
938
+ target = ClassAspectTarget()
939
+
940
+ descriptor = TypeDescriptor.for_type(component_type)
941
+
942
+ for service_type in descriptor.get_decorator(component).args[2]:
943
+ target.of_type(service_type)
944
+
945
+ return target
@@ -0,0 +1,97 @@
1
+ """
2
+ session related module
3
+ """
4
+ import contextvars
5
+ from typing import Type, Optional, Callable, Any, TypeVar
6
+ from datetime import datetime, timezone
7
+ from cachetools import TTLCache
8
+
9
+ from aspyx.di import injectable
10
+ from aspyx.threading import ThreadLocal
11
+
12
+
13
+ class Session:
14
+ """
15
+ Base class for objects covers data related to a server side session.
16
+ """
17
+ def __init__(self):
18
+ pass
19
+
20
+ T = TypeVar("T")
21
+
22
+ @injectable()
23
+ class SessionManager:
24
+ """
25
+ A SessionManager controls the lifecycle of sessions and is responsible to establish a session thread local.
26
+ """
27
+ #current_session = ThreadLocal[Session]()
28
+ current_session = contextvars.ContextVar("session")
29
+
30
+ @classmethod
31
+ def current(cls, type: Type[T]) -> T:
32
+ """
33
+ return the current session associated with the thread
34
+ Args:
35
+ type: the session type
36
+
37
+ Returns:
38
+ the current session
39
+ """
40
+ return cls.current_session.get()
41
+
42
+ @classmethod
43
+ def set_session(cls, session: Session) -> None:
44
+ """
45
+ set the current session in the thread context
46
+ Args:
47
+ session: the session
48
+ """
49
+ cls.current_session.set(session)
50
+
51
+ @classmethod
52
+ def delete_session(cls) -> None:
53
+ """
54
+ delete the current session
55
+ """
56
+ cls.current_session.set(None)#clear()
57
+
58
+ # constructor
59
+
60
+ def __init__(self):
61
+ self.sessions = TTLCache(maxsize=1000, ttl=3600)
62
+ self.session_creator : Optional[Callable[[Any], Session]] = None
63
+
64
+ # public
65
+
66
+ def set_session_factory(self, callable: Callable[..., Session]) -> None:
67
+ """
68
+ set a factory function that will be used to create a concrete session
69
+ Args:
70
+ callable: the function
71
+ """
72
+ self.session_creator = callable
73
+
74
+ def create_session(self, *args, **kwargs) -> Session:
75
+ """
76
+ create a session given the argument s(usually a token, etc.)
77
+ Args:
78
+ args: rest args
79
+ kwargs: keyword args
80
+
81
+ Returns:
82
+ the new session
83
+ """
84
+ return self.session_creator(*args, **kwargs)
85
+
86
+ def store_session(self, token: str, session: Session, expiry: datetime):
87
+ now = datetime.now(timezone.utc)
88
+ ttl_seconds = max(int((expiry - now).total_seconds()), 0)
89
+ self.sessions[token] = (session, ttl_seconds)
90
+
91
+ def get_session(self, token: str) -> Optional[Session]:
92
+ value = self.sessions.get(token)
93
+ if value is None:
94
+ return None
95
+
96
+ session, ttl = value
97
+ return session