sotkalib 0.0.3__tar.gz → 0.0.4.post1__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.
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/PKG-INFO +5 -4
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/pyproject.toml +5 -4
- sotkalib-0.0.4.post1/src/sotkalib/__init__.py +3 -0
- sotkalib-0.0.4.post1/src/sotkalib/config/field.py +11 -0
- sotkalib-0.0.4.post1/src/sotkalib/exceptions/__init__.py +3 -0
- sotkalib-0.0.4.post1/src/sotkalib/exceptions/api/__init__.py +1 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/exceptions/api/exc.py +0 -3
- sotkalib-0.0.4.post1/src/sotkalib/exceptions/handlers/__init__.py +4 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/exceptions/handlers/args_incl_error.py +1 -7
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/http/__init__.py +2 -2
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/http/client_session.py +54 -43
- sotkalib-0.0.4.post1/src/sotkalib/log/factory.py +12 -0
- sotkalib-0.0.4.post1/src/sotkalib/redis/__init__.py +8 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/redis/lock.py +10 -17
- sotkalib-0.0.4.post1/src/sotkalib/sqla/__init__.py +3 -0
- sotkalib-0.0.4.post1/src/sotkalib/sqla/db.py +101 -0
- sotkalib-0.0.3/src/sotkalib/__init__.py +0 -5
- sotkalib-0.0.3/src/sotkalib/config/field.py +0 -27
- sotkalib-0.0.3/src/sotkalib/exceptions/__init__.py +0 -0
- sotkalib-0.0.3/src/sotkalib/exceptions/api/__init__.py +0 -0
- sotkalib-0.0.3/src/sotkalib/exceptions/handlers/__init__.py +0 -0
- sotkalib-0.0.3/src/sotkalib/log/factory.py +0 -29
- sotkalib-0.0.3/src/sotkalib/redis/__init__.py +0 -0
- sotkalib-0.0.3/src/sotkalib/sqla/__init__.py +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/README.md +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/config/__init__.py +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/config/struct.py +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/enum/__init__.py +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/enum/mixins.py +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/exceptions/handlers/core.py +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/log/__init__.py +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/py.typed +0 -0
- {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/redis/client.py +0 -0
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sotkalib
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4.post1
|
|
4
4
|
Summary:
|
|
5
5
|
Author: alexey
|
|
6
6
|
Author-email: alexey <me@pyrorhythm.dev>
|
|
7
|
-
Requires-Dist: aiohttp>=3.13.
|
|
7
|
+
Requires-Dist: aiohttp>=3.13.0
|
|
8
8
|
Requires-Dist: dotenv>=0.9.9
|
|
9
9
|
Requires-Dist: loguru>=0.7.3
|
|
10
|
-
Requires-Dist: pydantic>=2.12.
|
|
11
|
-
Requires-Dist: redis>=
|
|
10
|
+
Requires-Dist: pydantic>=2.12.0
|
|
11
|
+
Requires-Dist: redis>=6.4.0,<8.0.0
|
|
12
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
12
13
|
Requires-Python: >=3.13
|
|
13
14
|
Description-Content-Type: text/markdown
|
|
14
15
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sotkalib"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.4.post1"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = [
|
|
6
6
|
{ email = "me@pyrorhythm.dev", name = "alexey" }
|
|
@@ -8,11 +8,12 @@ authors = [
|
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.13"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"aiohttp>=3.13.
|
|
11
|
+
"aiohttp>=3.13.0",
|
|
12
12
|
"dotenv>=0.9.9",
|
|
13
13
|
"loguru>=0.7.3",
|
|
14
|
-
"pydantic>=2.12.
|
|
15
|
-
"redis>=
|
|
14
|
+
"pydantic>=2.12.0",
|
|
15
|
+
"redis>=6.4.0,<8.0.0",
|
|
16
|
+
"sqlalchemy[asyncio]>=2.0.0",
|
|
16
17
|
]
|
|
17
18
|
|
|
18
19
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
type AllowedTypes = int | float | complex | str | bool | None
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(init=True, slots=True, frozen=True)
|
|
8
|
+
class SettingsField[T: AllowedTypes]:
|
|
9
|
+
default: T | None = None
|
|
10
|
+
factory: Callable[[], T] | str | None = None
|
|
11
|
+
nullable: bool = False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .exc import APIError, ErrorSchema
|
|
@@ -7,7 +7,6 @@ from pydantic import BaseModel
|
|
|
7
7
|
|
|
8
8
|
class ErrorSchema(BaseModel):
|
|
9
9
|
code: str | None = None
|
|
10
|
-
phrase: str | None = None
|
|
11
10
|
desc: str | None = None
|
|
12
11
|
ctx: Mapping[str, Any] | str | list[Any] | None = None
|
|
13
12
|
|
|
@@ -41,14 +40,12 @@ class APIError(BaseHTTPError):
|
|
|
41
40
|
status = http.HTTPStatus(status)
|
|
42
41
|
|
|
43
42
|
self.status = status
|
|
44
|
-
self.phrase = status.phrase
|
|
45
43
|
self.code = code
|
|
46
44
|
self.desc = desc
|
|
47
45
|
self.ctx = ctx
|
|
48
46
|
|
|
49
47
|
self.schema = ErrorSchema(
|
|
50
48
|
code=self.code,
|
|
51
|
-
phrase=self.phrase,
|
|
52
49
|
desc=self.desc,
|
|
53
50
|
ctx=self.ctx,
|
|
54
51
|
)
|
|
@@ -11,11 +11,5 @@ class ArgsIncludedError(Exception):
|
|
|
11
11
|
args, _, _, values = inspect.getargvalues(frame)
|
|
12
12
|
f_locals = frame.f_locals
|
|
13
13
|
args_with_values = {arg: values[arg] for arg in args}
|
|
14
|
-
stack_args_to_exc.append(
|
|
15
|
-
args_with_values
|
|
16
|
-
| {
|
|
17
|
-
"frame_name": frame.f_code.co_name,
|
|
18
|
-
}
|
|
19
|
-
| f_locals
|
|
20
|
-
)
|
|
14
|
+
stack_args_to_exc.append(args_with_values | f_locals | { "frame_name": frame.f_code.co_name })
|
|
21
15
|
super().__init__(*_args, *stack_args_to_exc)
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
from .client_session import (
|
|
2
|
-
ClientSession,
|
|
3
2
|
ClientSettings,
|
|
4
3
|
ExceptionSettings,
|
|
5
4
|
Handler,
|
|
5
|
+
HTTPSession,
|
|
6
6
|
Middleware,
|
|
7
7
|
StatusSettings,
|
|
8
8
|
)
|
|
9
9
|
|
|
10
10
|
__all__ = (
|
|
11
|
-
"
|
|
11
|
+
"HTTPSession",
|
|
12
12
|
"ExceptionSettings",
|
|
13
13
|
"StatusSettings",
|
|
14
14
|
"ClientSettings",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import importlib
|
|
3
2
|
import ssl
|
|
4
|
-
from collections.abc import Callable
|
|
3
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
4
|
+
from functools import reduce
|
|
5
5
|
from http import HTTPStatus
|
|
6
6
|
from typing import Any, Literal, Protocol, Self
|
|
7
7
|
|
|
@@ -11,18 +11,18 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
|
11
11
|
|
|
12
12
|
from sotkalib.log import get_logger
|
|
13
13
|
|
|
14
|
+
MAXIMUM_BACKOFF: float = 120
|
|
15
|
+
|
|
14
16
|
try:
|
|
15
|
-
|
|
17
|
+
import certifi
|
|
16
18
|
except ImportError:
|
|
17
19
|
certifi = None
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
MAXIMUM_BACKOFF: float = 120
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class RunOutOfAttemptsError(Exception):
|
|
21
|
+
class RanOutOfAttemptsError(Exception):
|
|
24
22
|
pass
|
|
25
23
|
|
|
24
|
+
class CriticalStatusError(Exception):
|
|
25
|
+
pass
|
|
26
26
|
|
|
27
27
|
class StatusRetryError(Exception):
|
|
28
28
|
status: int
|
|
@@ -33,10 +33,11 @@ class StatusRetryError(Exception):
|
|
|
33
33
|
self.status = status
|
|
34
34
|
self.context = context
|
|
35
35
|
|
|
36
|
+
type ExcArgFunc = Callable[..., tuple[Sequence[Any], Mapping[str, Any] | None]]
|
|
37
|
+
type StatArgFunc = Callable[..., Any]
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
async def default_stat_arg_func(resp: aiohttp.ClientResponse) -> tuple[Sequence[Any], None]:
|
|
40
|
+
return (f"[{resp.status}]; {await resp.text()=}",), None
|
|
40
41
|
|
|
41
42
|
class StatusSettings(BaseModel):
|
|
42
43
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
@@ -45,8 +46,11 @@ class StatusSettings(BaseModel):
|
|
|
45
46
|
to_retry: set[HTTPStatus] = Field(default={HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.FORBIDDEN})
|
|
46
47
|
exc_to_raise: type[Exception] = Field(default=CriticalStatusError)
|
|
47
48
|
not_found_as_none: bool = Field(default=True)
|
|
49
|
+
args_for_exc_func: StatArgFunc = Field(default=default_stat_arg_func)
|
|
48
50
|
unspecified: Literal["retry", "raise"] = Field(default="retry")
|
|
49
51
|
|
|
52
|
+
def default_exc_arg_func(exc: Exception, attempt: int, url: str, method: str, **kw) -> tuple[Sequence[Any], None]:
|
|
53
|
+
return (f"exception {type(exc)}: ({exc=}) {attempt=}; {url=} {method=} {kw=}",), None
|
|
50
54
|
|
|
51
55
|
class ExceptionSettings(BaseModel):
|
|
52
56
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
@@ -70,7 +74,7 @@ class ExceptionSettings(BaseModel):
|
|
|
70
74
|
)
|
|
71
75
|
|
|
72
76
|
exc_to_raise: type[Exception] | None = Field(default=None)
|
|
73
|
-
|
|
77
|
+
args_for_exc_func: ExcArgFunc = Field(default=default_exc_arg_func)
|
|
74
78
|
unspecified: Literal["retry", "raise"] = Field(default="retry")
|
|
75
79
|
|
|
76
80
|
|
|
@@ -89,11 +93,11 @@ class ClientSettings(BaseModel):
|
|
|
89
93
|
use_cookies_from_response: bool = Field(default=False)
|
|
90
94
|
|
|
91
95
|
|
|
92
|
-
class Handler[T](Protocol):
|
|
93
|
-
async def __call__(self, *args:
|
|
96
|
+
class Handler[**P, T](Protocol):
|
|
97
|
+
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
|
|
94
98
|
|
|
95
99
|
|
|
96
|
-
type Middleware[T, R] = Callable[[Handler[T]], Handler[R]]
|
|
100
|
+
type Middleware[**P, T, R] = Callable[[Handler[P, T]], Handler[P, R]]
|
|
97
101
|
|
|
98
102
|
|
|
99
103
|
def _make_ssl_context(disable_tls13: bool = False) -> ssl.SSLContext:
|
|
@@ -119,22 +123,23 @@ def _make_ssl_context(disable_tls13: bool = False) -> ssl.SSLContext:
|
|
|
119
123
|
return ctx
|
|
120
124
|
|
|
121
125
|
|
|
122
|
-
|
|
126
|
+
|
|
127
|
+
class HTTPSession[R = aiohttp.ClientResponse | None]:
|
|
123
128
|
config: ClientSettings
|
|
124
129
|
_session: aiohttp.ClientSession
|
|
125
|
-
_middlewares: list[
|
|
130
|
+
_middlewares: list[Middleware]
|
|
126
131
|
|
|
127
132
|
def __init__(
|
|
128
133
|
self,
|
|
129
134
|
config: ClientSettings | None = None,
|
|
130
|
-
_middlewares: list[
|
|
135
|
+
_middlewares: list[Middleware] | None = None,
|
|
131
136
|
) -> None:
|
|
132
137
|
self.config = config if config is not None else ClientSettings()
|
|
133
138
|
self._session = None
|
|
134
139
|
self._middlewares = _middlewares or []
|
|
135
140
|
|
|
136
|
-
def use[NewR](self, mw: Middleware[R, NewR]) ->
|
|
137
|
-
new_session:
|
|
141
|
+
def use[**P, NewR](self, mw: Middleware[P, R, NewR]) -> HTTPSession[NewR]:
|
|
142
|
+
new_session: HTTPSession[NewR] = HTTPSession(
|
|
138
143
|
config=self.config,
|
|
139
144
|
_middlewares=[*self._middlewares, mw],
|
|
140
145
|
)
|
|
@@ -159,75 +164,81 @@ class ClientSession[R = aiohttp.ClientResponse | None]:
|
|
|
159
164
|
|
|
160
165
|
return self
|
|
161
166
|
|
|
162
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
167
|
+
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
|
163
168
|
if self._session:
|
|
164
169
|
await self._session.close()
|
|
165
170
|
|
|
166
171
|
async def _handle_statuses(self, response: aiohttp.ClientResponse) -> aiohttp.ClientResponse | None:
|
|
167
172
|
sc = response.status
|
|
173
|
+
exc, argfunc = self.config.status_settings.exc_to_raise, self.config.status_settings.args_for_exc_func
|
|
168
174
|
if self.config.use_cookies_from_response:
|
|
169
175
|
self._session.cookie_jar.update_cookies(response.cookies)
|
|
170
176
|
if sc in self.config.status_settings.to_retry:
|
|
171
177
|
raise StatusRetryError(status=sc, context=(await response.text()))
|
|
172
178
|
elif sc in self.config.status_settings.to_raise:
|
|
173
|
-
|
|
179
|
+
a, kw = await argfunc(response)
|
|
180
|
+
if kw is None:
|
|
181
|
+
raise exc(*a)
|
|
182
|
+
raise exc(*a, **kw)
|
|
174
183
|
elif self.config.status_settings.not_found_as_none and sc == HTTPStatus.NOT_FOUND:
|
|
175
184
|
return None
|
|
176
185
|
|
|
177
186
|
return response
|
|
178
187
|
|
|
179
|
-
def _get_make_request_func(self) ->
|
|
188
|
+
def _get_make_request_func(self) -> Callable[..., Any]:
|
|
180
189
|
async def _make_request(*args: Any, **kwargs: Any) -> aiohttp.ClientResponse | None:
|
|
181
190
|
return await self._handle_statuses(await self._session.request(*args, **kwargs))
|
|
182
191
|
|
|
183
|
-
|
|
184
|
-
for mw in reversed(self._middlewares):
|
|
185
|
-
handler = mw(handler)
|
|
186
|
-
|
|
187
|
-
return handler
|
|
192
|
+
return reduce(lambda t, s: s(t), reversed(self._middlewares), _make_request)
|
|
188
193
|
|
|
189
194
|
async def _handle_request(
|
|
190
195
|
self,
|
|
191
196
|
method: str,
|
|
192
197
|
url: str,
|
|
193
|
-
make_request_func:
|
|
194
|
-
**kw,
|
|
198
|
+
make_request_func: Callable[..., Any],
|
|
199
|
+
**kw: Any,
|
|
195
200
|
) -> R:
|
|
196
|
-
kw_for_request = kw.copy()
|
|
197
201
|
if self.config.useragent_factory is not None:
|
|
198
202
|
user_agent_header = {"User-Agent": self.config.useragent_factory()}
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
kw["headers"] = kw.get("headers", {}) | user_agent_header
|
|
204
|
+
|
|
205
|
+
return await make_request_func(method, url, **kw)
|
|
201
206
|
|
|
202
|
-
async def _handle_retry(self,
|
|
207
|
+
async def _handle_retry(self, e: Exception, attempt: int, url: str, method: str, **kws: Any) -> None:
|
|
203
208
|
if attempt == self.config.maximum_retries:
|
|
204
|
-
raise
|
|
209
|
+
raise RanOutOfAttemptsError(f"failed after {self.config.maximum_retries} retries: {type(e)} {e}") from e
|
|
205
210
|
|
|
206
211
|
await asyncio.sleep(self.config.base * min(MAXIMUM_BACKOFF, self.config.backoff**attempt))
|
|
207
212
|
|
|
208
|
-
async def _handle_to_raise(self, e: Exception, url: str, method: str, **kw) -> None:
|
|
213
|
+
async def _handle_to_raise(self, e: Exception, attempt: int, url: str, method: str, **kw: Any) -> None:
|
|
209
214
|
if self.config.exception_settings.exc_to_raise is None:
|
|
210
215
|
raise e
|
|
211
216
|
|
|
212
|
-
|
|
217
|
+
exc, argfunc = self.config.exception_settings.exc_to_raise, self.config.exception_settings.args_for_exc_func
|
|
218
|
+
|
|
219
|
+
a, exckw = argfunc(e, attempt, url, method, **kw)
|
|
220
|
+
if exckw is None:
|
|
221
|
+
raise exc(*a) from e
|
|
222
|
+
|
|
223
|
+
raise exc(*a, **exckw) from e
|
|
213
224
|
|
|
214
|
-
async def _handle_exception(self, e: Exception,
|
|
225
|
+
async def _handle_exception(self, e: Exception, attempt: int, url: str, method: str, **kw: Any) -> None:
|
|
215
226
|
if self.config.exception_settings.unspecified == "raise":
|
|
216
227
|
raise e
|
|
217
228
|
|
|
218
|
-
await self._handle_retry(
|
|
229
|
+
await self._handle_retry(e, attempt, url, method, **kw)
|
|
219
230
|
|
|
220
|
-
async def _request_with_retry(self, method: str, url: str, **kw) -> R:
|
|
231
|
+
async def _request_with_retry(self, method: str, url: str, **kw: Any) -> R:
|
|
221
232
|
_make_request = self._get_make_request_func()
|
|
222
233
|
for attempt in range(self.config.maximum_retries + 1):
|
|
223
234
|
try:
|
|
224
235
|
return await self._handle_request(method, url, _make_request, **kw)
|
|
225
236
|
except self.config.exception_settings.to_retry + (StatusRetryError,) as e:
|
|
226
|
-
await self._handle_retry(
|
|
237
|
+
await self._handle_retry(e, attempt, url, method, **kw)
|
|
227
238
|
except self.config.exception_settings.to_raise as e:
|
|
228
|
-
await self._handle_to_raise(e, url, method, **kw)
|
|
239
|
+
await self._handle_to_raise(e, attempt, url, method, **kw)
|
|
229
240
|
except Exception as e:
|
|
230
|
-
await self._handle_exception(e, url, method,
|
|
241
|
+
await self._handle_exception(e, attempt, url, method, **kw)
|
|
231
242
|
|
|
232
243
|
return await _make_request()
|
|
233
244
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from loguru import Logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache
|
|
11
|
+
def get_logger(logger_name: str | None = None) -> Logger:
|
|
12
|
+
return logger if logger_name is None else logger.bind(name=logger_name.replace(".", " -> "))
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from collections.abc import AsyncGenerator
|
|
3
|
-
from contextlib import asynccontextmanager
|
|
3
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
4
4
|
from time import time
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from redis.asyncio import Redis
|
|
8
8
|
|
|
9
|
-
from sotkalib.redis.client import RedisPool
|
|
10
|
-
|
|
11
9
|
|
|
12
10
|
class ContextLockError(Exception):
|
|
13
11
|
def __init__(self, *args, can_retry: bool = True):
|
|
@@ -15,27 +13,18 @@ class ContextLockError(Exception):
|
|
|
15
13
|
self.can_retry = can_retry
|
|
16
14
|
|
|
17
15
|
|
|
18
|
-
async def
|
|
16
|
+
async def __try_acquire(rc: Redis, key_to_lock: str, acquire_timeout: int) -> bool:
|
|
19
17
|
"""Atomically acquire a lock using SET NX (set-if-not-exists)."""
|
|
20
18
|
return bool(await rc.set(key_to_lock, "acquired", nx=True, ex=acquire_timeout))
|
|
21
19
|
|
|
22
20
|
|
|
23
|
-
async def
|
|
21
|
+
async def __wait_till_lock_free(
|
|
24
22
|
client: Redis,
|
|
25
23
|
key_to_lock: str,
|
|
26
24
|
lock_timeout: float = 10.0,
|
|
27
25
|
base_delay: float = 0.1,
|
|
28
26
|
max_delay: float = 5.0,
|
|
29
27
|
) -> None:
|
|
30
|
-
"""
|
|
31
|
-
Wait until lock is free with exponential backoff.
|
|
32
|
-
|
|
33
|
-
:param key_to_lock: Redis key for the lock
|
|
34
|
-
:param lock_timeout: Maximum time to wait in seconds
|
|
35
|
-
:param base_delay: Initial delay between checks in seconds
|
|
36
|
-
:param max_delay: Maximum delay between checks in seconds
|
|
37
|
-
:raises ContextLockError: If timeout is reached
|
|
38
|
-
"""
|
|
39
28
|
start = time()
|
|
40
29
|
attempt = 0
|
|
41
30
|
while await client.get(key_to_lock) is not None:
|
|
@@ -51,7 +40,7 @@ async def wait_till_lock_free(
|
|
|
51
40
|
|
|
52
41
|
@asynccontextmanager
|
|
53
42
|
async def redis_context_lock(
|
|
54
|
-
client: Redis
|
|
43
|
+
client: AbstractAsyncContextManager[Redis],
|
|
55
44
|
key_to_lock: str,
|
|
56
45
|
can_retry_if_lock_catched: bool = True,
|
|
57
46
|
wait_for_lock: bool = False,
|
|
@@ -62,21 +51,25 @@ async def redis_context_lock(
|
|
|
62
51
|
"""
|
|
63
52
|
Acquire a Redis lock atomically using SET NX.
|
|
64
53
|
|
|
54
|
+
:param client: async context mng for redis
|
|
65
55
|
:param key_to_lock: Redis key for the lock
|
|
66
56
|
:param can_retry_if_lock_catched: Whether task should retry if lock is taken (only used if wait_for_lock=False)
|
|
67
57
|
:param wait_for_lock: If True, wait for lock to be free instead of immediately failing
|
|
68
58
|
:param wait_timeout: Maximum time to wait for lock in seconds (only used if wait_for_lock=True)
|
|
59
|
+
:param acquire_timeout: Timeout for acquiring lock
|
|
60
|
+
:param args_to_lock_exception: Args to pass to ContextLockError
|
|
61
|
+
|
|
69
62
|
"""
|
|
70
63
|
if args_to_lock_exception is None:
|
|
71
64
|
args_to_lock_exception = []
|
|
72
65
|
|
|
73
66
|
if wait_for_lock:
|
|
74
67
|
async with client as rc:
|
|
75
|
-
await
|
|
68
|
+
await __wait_till_lock_free(key_to_lock=key_to_lock, client=rc, lock_timeout=wait_timeout)
|
|
76
69
|
|
|
77
70
|
try:
|
|
78
71
|
async with client as rc:
|
|
79
|
-
acquired = await
|
|
72
|
+
acquired = await __try_acquire(rc, key_to_lock, acquire_timeout)
|
|
80
73
|
if not acquired:
|
|
81
74
|
raise ContextLockError(
|
|
82
75
|
f"{key_to_lock} lock already acquired",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from sqlalchemy import Engine, create_engine
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
|
4
|
+
from sqlalchemy.ext.asyncio.session import AsyncSession
|
|
5
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
6
|
+
|
|
7
|
+
from sotkalib.log import get_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConnectionTimeoutError(Exception): pass
|
|
11
|
+
|
|
12
|
+
class DatabaseSettings(BaseModel):
|
|
13
|
+
uri: str = Field(examples=[
|
|
14
|
+
"postgresql://username:password@localhost:5432/database"
|
|
15
|
+
])
|
|
16
|
+
async_driver: str = "asyncpg"
|
|
17
|
+
echo: bool = False
|
|
18
|
+
pool_size: int = 10
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def async_uri(self) -> str:
|
|
22
|
+
return self.uri.replace("postgresql://", "postgresql" + self.async_driver + "://")
|
|
23
|
+
|
|
24
|
+
class Database:
|
|
25
|
+
_sync_engine: Engine | None
|
|
26
|
+
_async_engine: AsyncEngine | None
|
|
27
|
+
_sync_session_factory: sessionmaker = None
|
|
28
|
+
_async_session_factory: async_sessionmaker = None
|
|
29
|
+
|
|
30
|
+
logger = get_logger("sqldb.instance")
|
|
31
|
+
|
|
32
|
+
def __init__(self, settings: DatabaseSettings):
|
|
33
|
+
self.__async_uri = settings.async_uri
|
|
34
|
+
self.__sync_uri = settings.uri
|
|
35
|
+
self.echo = settings.echo
|
|
36
|
+
self.pool_size = settings.pool_size
|
|
37
|
+
|
|
38
|
+
def __enter__(self):
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
42
|
+
if self._sync_engine:
|
|
43
|
+
self._sync_engine.dispose()
|
|
44
|
+
self.logger.info("closed sync db connection")
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self):
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
async def __aexit__(self, *args):
|
|
50
|
+
if self._async_engine:
|
|
51
|
+
await self._async_engine.dispose()
|
|
52
|
+
self.logger.info("closed async db connection")
|
|
53
|
+
|
|
54
|
+
def __async_init(self):
|
|
55
|
+
self._async_engine = create_async_engine(
|
|
56
|
+
url=self.__async_uri,
|
|
57
|
+
echo=self.echo,
|
|
58
|
+
pool_size=self.pool_size,
|
|
59
|
+
)
|
|
60
|
+
self._async_session_factory = async_sessionmaker(bind=self._async_engine, expire_on_commit=False)
|
|
61
|
+
self.logger.debug( # noqa: PLE1205
|
|
62
|
+
"successfully initialized async db connection, engine.status = {} sessionmaker.status = {}",
|
|
63
|
+
self._async_engine.name is not None,
|
|
64
|
+
self._async_session_factory is not None,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def async_session(self) -> async_sessionmaker[AsyncSession]:
|
|
69
|
+
if self._async_engine is None or self._async_session_factory is None:
|
|
70
|
+
self.logger.debug("async_sf not found, initializing")
|
|
71
|
+
self.__async_init()
|
|
72
|
+
if self._async_engine is None or self._async_session_factory is None:
|
|
73
|
+
self.logger.error(c := "could not asynchronously connect to pgsql")
|
|
74
|
+
raise ConnectionTimeoutError(c)
|
|
75
|
+
self.logger.debug("success getting (asyncmaker)")
|
|
76
|
+
return self._async_session_factory
|
|
77
|
+
|
|
78
|
+
def __sync_init(self):
|
|
79
|
+
self._sync_engine = create_engine(
|
|
80
|
+
url=self.__sync_uri,
|
|
81
|
+
echo=self.echo,
|
|
82
|
+
pool_size=self.pool_size,
|
|
83
|
+
)
|
|
84
|
+
self._sync_session_factory = sessionmaker(bind=self._sync_engine, expire_on_commit=False)
|
|
85
|
+
self.logger.debug( # noqa
|
|
86
|
+
" -> (__sync_init) successfully initialized sync db connection,\n"
|
|
87
|
+
"\t\t\t\tengine.status = {} sessionmaker.status = {}",
|
|
88
|
+
self._sync_engine.name is not None,
|
|
89
|
+
self._sync_session_factory is not None,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def session(self) -> sessionmaker[Session]:
|
|
94
|
+
if self._sync_engine is None or self._sync_session_factory is None:
|
|
95
|
+
self.logger.debug("not found, initializing...")
|
|
96
|
+
self.__sync_init()
|
|
97
|
+
if self._sync_engine is None or self._sync_session_factory is None:
|
|
98
|
+
self.logger.error(c := "could not synchronously connect to pgsql")
|
|
99
|
+
raise ConnectionTimeoutError(c)
|
|
100
|
+
self.logger.debug("success getting (syncmaker)")
|
|
101
|
+
return self._sync_session_factory
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
from collections.abc import Callable
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
|
|
4
|
-
type AllowedTypes = int | float | complex | str | bool | None
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@dataclass(init=True, slots=True, frozen=True)
|
|
8
|
-
class SettingsField[T: AllowedTypes]:
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
Typed field declaration for AppSettings.
|
|
12
|
-
|
|
13
|
-
**Parameters:**
|
|
14
|
-
|
|
15
|
-
- `T`: Python type of the value (see AllowedTypes).
|
|
16
|
-
|
|
17
|
-
**Attributes:**
|
|
18
|
-
|
|
19
|
-
- `default`: Optional fallback value when the variable is missing.
|
|
20
|
-
- `factory`: A callable returning a value, or a name of a @property on the class that will be evaluated after initialization.
|
|
21
|
-
- `nullable`: Whether None is allowed when no value is provided and no default/factory is set.
|
|
22
|
-
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
default: T | None = None
|
|
26
|
-
factory: Callable[[], T] | str | None = None
|
|
27
|
-
nullable: bool = False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
from functools import lru_cache
|
|
2
|
-
from typing import TYPE_CHECKING
|
|
3
|
-
|
|
4
|
-
from loguru import logger
|
|
5
|
-
|
|
6
|
-
if TYPE_CHECKING:
|
|
7
|
-
from loguru import Logger
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@lru_cache
|
|
11
|
-
def get_logger(logger_name: str | None = None) -> Logger:
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
Return a cached loguru Logger optionally bound with a humanized name.
|
|
15
|
-
|
|
16
|
-
If a name is provided, the returned logger is bound with extra["logger_name"]
|
|
17
|
-
in a " src -> sub -> leaf " format so it can be referenced in loguru sinks.
|
|
18
|
-
|
|
19
|
-
**Parameters:**
|
|
20
|
-
|
|
21
|
-
- `logger_name`: Dotted logger name (e.g., "src.database.service"). If None, return the global logger.
|
|
22
|
-
|
|
23
|
-
**Returns:**
|
|
24
|
-
|
|
25
|
-
A cached loguru Logger with the extra context bound when name is provided.
|
|
26
|
-
|
|
27
|
-
"""
|
|
28
|
-
|
|
29
|
-
return logger if logger_name is None else logger.bind(logger_name=f" {logger_name.replace('.', ' -> ')} ")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|