qena-shared-lib 0.1.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.
@@ -0,0 +1,27 @@
1
+ from . import (
2
+ application,
3
+ background,
4
+ dependencies,
5
+ exceptions,
6
+ http,
7
+ logging,
8
+ logstash,
9
+ rabbitmq,
10
+ scheduler,
11
+ security,
12
+ utils,
13
+ )
14
+
15
+ __all__ = [
16
+ "application",
17
+ "background",
18
+ "dependencies",
19
+ "exceptions",
20
+ "http",
21
+ "logging",
22
+ "logstash",
23
+ "rabbitmq",
24
+ "scheduler",
25
+ "security",
26
+ "utils",
27
+ ]
@@ -0,0 +1,190 @@
1
+ from enum import Enum
2
+ from typing import TypeVar
3
+
4
+ from fastapi import APIRouter, FastAPI
5
+ from fastapi.exceptions import RequestValidationError
6
+ from prometheus_fastapi_instrumentator import Instrumentator
7
+ from punq import Container, Scope, empty
8
+ from starlette.types import Lifespan
9
+
10
+ from .exception_handlers import (
11
+ handle_general_http_exception,
12
+ handle_request_validation_error,
13
+ handle_service_exception,
14
+ )
15
+ from .exceptions import ServiceException
16
+ from .http import ControllerBase
17
+
18
+ __all__ = [
19
+ "Builder",
20
+ "Environment",
21
+ ]
22
+
23
+ D = TypeVar("D")
24
+
25
+
26
+ class Environment(Enum):
27
+ DEVELOPMENT = 0
28
+ PRODUCTION = 1
29
+
30
+
31
+ class Builder:
32
+ def __init__(self):
33
+ self._environment = Environment.DEVELOPMENT
34
+ self._debug = False
35
+ self._title = "Qena shared lib"
36
+ self._description = "Qena shared tools for microservice"
37
+ self._version = "0.1.0"
38
+ self._lifespan = None
39
+ self._openapi_url = "/openapi.json"
40
+ self._docs_url = "/docs"
41
+ self._redoc_url = "/redoc"
42
+ self._metrics_endpoint = None
43
+ self._instrumentator = None
44
+ self._routers = []
45
+ self._container = Container()
46
+ self._built = False
47
+
48
+ def with_environment(self, environment: Environment) -> "Builder":
49
+ match environment:
50
+ case Environment.DEVELOPMENT:
51
+ self._environment = Environment.DEVELOPMENT
52
+ self._debug = True
53
+ case Environment.PRODUCTION:
54
+ self._environment = Environment.PRODUCTION
55
+ self._debug = False
56
+ self._openapi_url = None
57
+ self._docs_url = None
58
+ self._redoc_url = None
59
+
60
+ return self
61
+
62
+ def with_title(self, title: str) -> "Builder":
63
+ self._title = title
64
+
65
+ return self
66
+
67
+ def with_description(self, description: str) -> "Builder":
68
+ self._description = description
69
+
70
+ return self
71
+
72
+ def with_version(self, version: str) -> "Builder":
73
+ self._version = version
74
+
75
+ return self
76
+
77
+ def with_lifespan(self, lifespan: Lifespan) -> "Builder":
78
+ self._lifespan = lifespan
79
+
80
+ return self
81
+
82
+ def with_controllers(
83
+ self, controllers: list[type[ControllerBase]]
84
+ ) -> "Builder":
85
+ for index, controller in enumerate(controllers):
86
+ if not isinstance(controller, type) or not issubclass(
87
+ controller, ControllerBase
88
+ ):
89
+ raise TypeError(
90
+ f"controller {index} is {type(ControllerBase)}, expected instance of type or subclass of `ControllerBase`"
91
+ )
92
+
93
+ self._container.register(
94
+ service=ControllerBase,
95
+ factory=controller,
96
+ scope=Scope.singleton,
97
+ )
98
+
99
+ return self
100
+
101
+ def with_routers(self, routers: list[APIRouter]) -> "Builder":
102
+ if any(not isinstance(router, APIRouter) for router in routers):
103
+ raise TypeError("some routers are not type `APIRouter`")
104
+
105
+ self._routers.extend(routers)
106
+
107
+ return self
108
+
109
+ def with_singleton(
110
+ self, service: type[D], factory=empty, instance=empty, **kwargs
111
+ ) -> "Builder":
112
+ self._container.register(
113
+ service=service,
114
+ factory=factory,
115
+ instance=instance,
116
+ scope=Scope.singleton,
117
+ **kwargs,
118
+ )
119
+
120
+ return self
121
+
122
+ def with_transient(
123
+ self, service: type[D], factory=empty, **kwargs
124
+ ) -> "Builder":
125
+ self._container.register(
126
+ service=service,
127
+ factory=factory,
128
+ scope=Scope.transient,
129
+ **kwargs,
130
+ )
131
+
132
+ return self
133
+
134
+ def with_metrics(self, endpoint: str = "/metrics") -> "Builder":
135
+ self._metrics_endpoint = endpoint
136
+ self._instrumentator = Instrumentator()
137
+
138
+ return self
139
+
140
+ def build(self) -> FastAPI:
141
+ if self._built:
142
+ raise RuntimeError("fastapi application aleady built")
143
+
144
+ app = FastAPI(
145
+ debug=self._debug,
146
+ title=self._title,
147
+ description=self._description,
148
+ version=self._version,
149
+ openapi_url=self._openapi_url,
150
+ docs_url=self._docs_url,
151
+ redoc_url=self._redoc_url,
152
+ lifespan=self._lifespan,
153
+ )
154
+ app.state.container = self._container
155
+
156
+ app.exception_handler(ServiceException)(handle_service_exception)
157
+ app.exception_handler(RequestValidationError)(
158
+ handle_request_validation_error
159
+ )
160
+ app.exception_handler(Exception)(handle_general_http_exception)
161
+
162
+ self._resolve_api_controllers(app)
163
+
164
+ if self._instrumentator is not None:
165
+ self._instrumentator.instrument(app).expose(
166
+ app=app,
167
+ endpoint=self._metrics_endpoint or "/metrics",
168
+ include_in_schema=False,
169
+ )
170
+
171
+ self._built = True
172
+
173
+ return app
174
+
175
+ def _resolve_api_controllers(self, app: FastAPI):
176
+ api_controller_routers = [
177
+ api_controller.register_route_handlers()
178
+ for api_controller in self._container.resolve_all(ControllerBase)
179
+ ]
180
+
181
+ for router in self._routers + api_controller_routers:
182
+ app.include_router(router)
183
+
184
+ @property
185
+ def environment(self) -> Environment:
186
+ return self._environment
187
+
188
+ @property
189
+ def container(self) -> Container:
190
+ return self._container
@@ -0,0 +1,109 @@
1
+ from asyncio import (
2
+ Queue,
3
+ Task,
4
+ gather,
5
+ )
6
+ from uuid import uuid4
7
+
8
+ from prometheus_client import Enum as PrometheusEnum
9
+ from starlette.background import BackgroundTask
10
+
11
+ from .logging import LoggerProvider
12
+ from .logstash import BaseLogstashSender
13
+ from .utils import AsyncEventLoopMixin
14
+
15
+ __all__ = [
16
+ "Background",
17
+ "BackgroundTask",
18
+ ]
19
+
20
+
21
+ class Background(AsyncEventLoopMixin):
22
+ BACKGROUND_RUNNER_STATE = PrometheusEnum(
23
+ name="background_runner_state",
24
+ documentation="Background runner state",
25
+ states=["running", "stopped"],
26
+ )
27
+
28
+ def __init__(
29
+ self,
30
+ logstash: BaseLogstashSender,
31
+ ):
32
+ self._queue = Queue()
33
+ self._started = False
34
+ self._stopped = False
35
+ self._logstash = logstash
36
+ self._logger = LoggerProvider.default().get_logger("backgroud")
37
+ self._tasks: dict[str, Task] = {}
38
+
39
+ async def _task_manager(
40
+ self, task: BackgroundTask, task_id: str | None = None
41
+ ):
42
+ self._logger.info(
43
+ "running %s: %s with %s", task_id, task.func.__name__, task.args
44
+ )
45
+
46
+ if task_id is None:
47
+ task_id = str(uuid4())
48
+
49
+ try:
50
+ self._tasks[task_id] = self.loop.create_task(
51
+ task.func(*task.args, **task.kwargs)
52
+ )
53
+
54
+ await self._tasks[task_id]
55
+ except Exception:
56
+ self._logstash.error(
57
+ "exception occured when running background task {task.func.__name__} with id {task_id}"
58
+ )
59
+ finally:
60
+ self._logger.info("finished running %s", task.func.__name__)
61
+ self._tasks.pop(task_id, None)
62
+
63
+ def _run(self, task: BackgroundTask, task_id: str | None = None):
64
+ if not self._stopped and (
65
+ task_id is None or task_id not in self._tasks
66
+ ):
67
+ self.loop.create_task(self._task_manager(task, task_id))
68
+
69
+ async def _run_tasks(self):
70
+ while not self._stopped or not self._queue.empty():
71
+ task, task_id = await self._queue.get()
72
+
73
+ if task is None and task_id is None:
74
+ break
75
+
76
+ self._run(task=task, task_id=task_id)
77
+
78
+ tasks = [t for _, t in self._tasks.items() if not t.done()]
79
+
80
+ await gather(*tasks)
81
+
82
+ def add_task(self, task: BackgroundTask, task_id: str | None = None):
83
+ self._queue.put_nowait((task, task_id))
84
+
85
+ def start(self):
86
+ if self._started:
87
+ raise RuntimeError("background runner already running")
88
+
89
+ self.loop.create_task(self._run_tasks())
90
+ self.BACKGROUND_RUNNER_STATE.state("running")
91
+
92
+ self._started = True
93
+
94
+ def stop(self):
95
+ if self._stopped:
96
+ raise RuntimeError("background runner already stopped")
97
+
98
+ self._stopped = True
99
+ self._queue.put_nowait((None, None))
100
+ self.BACKGROUND_RUNNER_STATE.state("stopped")
101
+
102
+ def is_alive(self, task_id: str):
103
+ if task_id in self._tasks and not self._tasks[task_id].done():
104
+ return True
105
+
106
+ return False
107
+
108
+ def count(self):
109
+ return len(self._tasks)
@@ -0,0 +1,19 @@
1
+ from punq import (
2
+ Container,
3
+ InvalidForwardReferenceError,
4
+ InvalidRegistrationError,
5
+ MissingDependencyError,
6
+ Scope,
7
+ )
8
+
9
+ from . import http, miscellaneous
10
+
11
+ __all__ = [
12
+ "Container",
13
+ "http",
14
+ "InvalidForwardReferenceError",
15
+ "InvalidRegistrationError",
16
+ "miscellaneous",
17
+ "MissingDependencyError",
18
+ "Scope",
19
+ ]
@@ -0,0 +1,62 @@
1
+ from typing import Any, TypeVar
2
+
3
+ from fastapi import Depends, FastAPI, Request
4
+ from punq import Container, Scope, empty
5
+
6
+ __all__ = [
7
+ "add_service",
8
+ "DependsOn",
9
+ "get_service",
10
+ ]
11
+
12
+ D = TypeVar("D")
13
+
14
+
15
+ def get_container(app: FastAPI) -> Container:
16
+ if not hasattr(app.state, "container"):
17
+ raise RuntimeError(
18
+ "application does include container, possibly not created with builder"
19
+ )
20
+
21
+ if not isinstance(app.state.container, Container):
22
+ raise TypeError("container is not type of `punq.Container`")
23
+
24
+ return app.state.container
25
+
26
+
27
+ def add_service(
28
+ app: FastAPI,
29
+ service: type[D],
30
+ factory=empty,
31
+ instance: D = empty,
32
+ scope: Scope = Scope.transient,
33
+ **kwargs,
34
+ ):
35
+ get_container(app).register(
36
+ service=service,
37
+ factory=factory,
38
+ instance=instance,
39
+ scope=scope,
40
+ **kwargs,
41
+ )
42
+
43
+
44
+ def get_service(app: FastAPI, service_key: type[D]) -> D:
45
+ service = get_container(app).resolve(service_key=service_key)
46
+
47
+ if not isinstance(service, service_key):
48
+ raise TypeError(f"`{service}` not a `{service_key}`")
49
+
50
+ return service
51
+
52
+
53
+ class DependencyResolver:
54
+ def __init__(self, dependency: type):
55
+ self._dependency = dependency
56
+
57
+ def __call__(self, request: Request) -> Any:
58
+ return get_service(app=request.app, service_key=self._dependency)
59
+
60
+
61
+ def DependsOn(dependency: type) -> Any:
62
+ return Depends(DependencyResolver(dependency))
@@ -0,0 +1,35 @@
1
+ from dataclasses import dataclass
2
+ from inspect import Parameter
3
+ from typing import Annotated, get_args, get_origin
4
+
5
+ __all__ = [
6
+ "DependsOn",
7
+ "validate_annotation",
8
+ ]
9
+
10
+
11
+ @dataclass
12
+ class DependsOn:
13
+ dependency: type
14
+
15
+
16
+ def validate_annotation(parameter: Parameter | type) -> type | None:
17
+ if isinstance(parameter, Parameter):
18
+ annotation = parameter.annotation
19
+ else:
20
+ annotation = parameter
21
+
22
+ if annotation is Parameter.empty or get_origin(annotation) is not Annotated:
23
+ return None
24
+
25
+ args = get_args(annotation)
26
+
27
+ if len(args) != 2:
28
+ return None
29
+
30
+ _, dependency_metadata = args
31
+
32
+ if not isinstance(dependency_metadata, DependsOn):
33
+ return None
34
+
35
+ return dependency_metadata.dependency
@@ -0,0 +1,165 @@
1
+ from typing import Any
2
+
3
+ from fastapi import Request, Response, status
4
+ from fastapi.encoders import jsonable_encoder
5
+ from fastapi.exceptions import RequestValidationError
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from .dependencies.http import get_service
9
+ from .exceptions import ServiceException, Severity
10
+ from .logging import LoggerProvider
11
+ from .logstash._base import BaseLogstashSender
12
+
13
+ __all__ = [
14
+ "handle_service_exception",
15
+ "handle_request_validation_error",
16
+ "handle_general_http_exception",
17
+ ]
18
+
19
+
20
+ def handle_service_exception(
21
+ request: Request, exception: ServiceException
22
+ ) -> Response:
23
+ logstash = get_service(app=request.app, service_key=BaseLogstashSender)
24
+ logger_provider = get_service(app=request.app, service_key=LoggerProvider)
25
+ logger = logger_provider.get_logger("http.exception_handler")
26
+ exception_severity = exception.severity or Severity.LOW
27
+ message = exception.message
28
+ tags = [
29
+ "HTTP",
30
+ request.method,
31
+ request.url.path,
32
+ exception.__class__.__name__,
33
+ ]
34
+
35
+ if exception.tags:
36
+ exception.tags.extend(tags)
37
+
38
+ extra = {
39
+ "serviceType": "HTTP",
40
+ "method": request.method,
41
+ "path": request.url.path,
42
+ "exception": exception.__class__.__name__,
43
+ }
44
+
45
+ if exception.extra:
46
+ exception.extra.update(extra)
47
+
48
+ exc_info = (
49
+ (type(exception), exception, exception.__traceback__)
50
+ if exception.extract_exc_info
51
+ else None
52
+ )
53
+
54
+ match exception_severity:
55
+ case Severity.LOW:
56
+ logstash_logger_method = logstash.info
57
+ logger_method = logger.info
58
+ case Severity.MEDIUM:
59
+ logstash_logger_method = logstash.warning
60
+ logger_method = logger.warning
61
+ case _:
62
+ message = "something went wrong"
63
+ logstash_logger_method = logstash.error
64
+ logger_method = logger.error
65
+
66
+ if exception.logstash_logging:
67
+ logstash_logger_method(
68
+ message=exception.message,
69
+ tags=exception.tags or tags,
70
+ extra=exception.extra or extra,
71
+ exception=exception if exception.extract_exc_info else None,
72
+ )
73
+ else:
74
+ logger_method(
75
+ "\n%s %s\n%s",
76
+ request.method,
77
+ request.url.path,
78
+ exception.message,
79
+ exc_info=exc_info,
80
+ )
81
+
82
+ content: dict[str, Any] = {
83
+ "severity": exception_severity.name
84
+ if exception_severity
85
+ else Severity.LOW.name,
86
+ "message": message,
87
+ }
88
+
89
+ if exception.response_code is not None:
90
+ content["code"] = exception.response_code
91
+
92
+ if exception.corrective_action is not None:
93
+ content["correctiveAction"] = exception.corrective_action
94
+
95
+ if exception.body is not None:
96
+ content.update(dict(exception.body))
97
+
98
+ return JSONResponse(
99
+ content=content,
100
+ status_code=exception.status_code
101
+ or _status_code_from_severity(exception.severity),
102
+ headers=exception.headers,
103
+ )
104
+
105
+
106
+ def handle_request_validation_error(
107
+ request: Request, error: RequestValidationError
108
+ ) -> Response:
109
+ logger_provider = get_service(app=request.app, service_key=LoggerProvider)
110
+ logger = logger_provider.get_logger("http.exception_handler")
111
+ message = "invalid request data"
112
+
113
+ logger.warning("\n%s %s\n%s", request.method, request.url.path, message)
114
+
115
+ return JSONResponse(
116
+ content={
117
+ "severity": Severity.MEDIUM.name,
118
+ "message": message,
119
+ "code": 100,
120
+ "detail": jsonable_encoder(error.errors()),
121
+ },
122
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
123
+ )
124
+
125
+
126
+ def handle_general_http_exception(
127
+ request: Request, exception: Exception
128
+ ) -> Response:
129
+ logstash = get_service(app=request.app, service_key=BaseLogstashSender)
130
+
131
+ logstash.error(
132
+ message=f"something went wrong on endpoint `{request.method} {request.url.path}`",
133
+ tags=[
134
+ "HTTP",
135
+ request.method,
136
+ request.url.path,
137
+ exception.__class__.__name__,
138
+ ],
139
+ extra={
140
+ "serviceType": "HTTP",
141
+ "method": request.method,
142
+ "path": request.url.path,
143
+ "exception": exception.__class__.__name__,
144
+ },
145
+ exception=exception,
146
+ )
147
+
148
+ return JSONResponse(
149
+ content={
150
+ "severity": Severity.HIGH.name,
151
+ "message": "something went wrong",
152
+ },
153
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
154
+ )
155
+
156
+
157
+ def _status_code_from_severity(severity: Severity | None) -> int:
158
+ if (
159
+ severity is None
160
+ or severity == Severity.LOW
161
+ or severity == Severity.MEDIUM
162
+ ):
163
+ return status.HTTP_400_BAD_REQUEST
164
+
165
+ return status.HTTP_500_INTERNAL_SERVER_ERROR