sotkalib 0.0.4.post1__py3-none-any.whl → 0.0.5.post1__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.
- sotkalib/__init__.py +1 -1
- sotkalib/config/__init__.py +2 -2
- sotkalib/config/field.py +3 -3
- sotkalib/enum/mixins.py +50 -50
- sotkalib/exceptions/__init__.py +1 -1
- sotkalib/exceptions/api/__init__.py +1 -1
- sotkalib/exceptions/handlers/__init__.py +1 -1
- sotkalib/exceptions/handlers/args_incl_error.py +1 -1
- sotkalib/http/__init__.py +12 -0
- sotkalib/http/client_session.py +280 -94
- sotkalib/log/factory.py +2 -2
- sotkalib/redis/__init__.py +1 -5
- sotkalib/sqla/__init__.py +1 -1
- sotkalib/sqla/db.py +88 -87
- {sotkalib-0.0.4.post1.dist-info → sotkalib-0.0.5.post1.dist-info}/METADATA +1 -1
- sotkalib-0.0.5.post1.dist-info/RECORD +25 -0
- sotkalib-0.0.4.post1.dist-info/RECORD +0 -25
- {sotkalib-0.0.4.post1.dist-info → sotkalib-0.0.5.post1.dist-info}/WHEEL +0 -0
sotkalib/__init__.py
CHANGED
sotkalib/config/__init__.py
CHANGED
sotkalib/config/field.py
CHANGED
|
@@ -6,6 +6,6 @@ type AllowedTypes = int | float | complex | str | bool | None
|
|
|
6
6
|
|
|
7
7
|
@dataclass(init=True, slots=True, frozen=True)
|
|
8
8
|
class SettingsField[T: AllowedTypes]:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
default: T | None = None
|
|
10
|
+
factory: Callable[[], T] | str | None = None
|
|
11
|
+
nullable: bool = False
|
sotkalib/enum/mixins.py
CHANGED
|
@@ -4,56 +4,56 @@ from typing import Any, Literal, Self, overload
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class UppercaseStrEnumMixin(str, Enum):
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
@staticmethod
|
|
8
|
+
def _generate_next_value_(name: str, start: int, count: int, last_values: Sequence) -> str: # noqa
|
|
9
|
+
return name.upper()
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ValidatorStrEnumMixin(str, Enum):
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
13
|
+
@classmethod
|
|
14
|
+
def _normalize_value(cls, val: Any) -> str:
|
|
15
|
+
if isinstance(val, (str, bytes, bytearray)):
|
|
16
|
+
return val.decode("utf-8") if isinstance(val, (bytes, bytearray)) else val
|
|
17
|
+
raise TypeError("value must be str-like")
|
|
18
|
+
|
|
19
|
+
@overload
|
|
20
|
+
@classmethod
|
|
21
|
+
def validate(cls, *, val: Any, req: Literal[False] = False) -> Self | None: ...
|
|
22
|
+
|
|
23
|
+
@overload
|
|
24
|
+
@classmethod
|
|
25
|
+
def validate(cls, *, val: Any, req: Literal[True]) -> Self: ...
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def validate(cls, *, val: Any, req: bool = False) -> Self | None:
|
|
29
|
+
if val is None:
|
|
30
|
+
if req:
|
|
31
|
+
raise ValueError("value is None and req=True")
|
|
32
|
+
return None
|
|
33
|
+
normalized = cls._normalize_value(val)
|
|
34
|
+
try:
|
|
35
|
+
return cls(normalized)
|
|
36
|
+
except ValueError as e:
|
|
37
|
+
raise TypeError(f"{normalized=} not valid: {e}") from e
|
|
38
|
+
|
|
39
|
+
@overload
|
|
40
|
+
@classmethod
|
|
41
|
+
def get(cls, val: Any, default: Literal[None] = None) -> Self | None: ...
|
|
42
|
+
|
|
43
|
+
@overload
|
|
44
|
+
@classmethod
|
|
45
|
+
def get(cls, val: Any, default: Self) -> Self: ...
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def get(cls, val: Any, default: Self | None = None) -> Self | None:
|
|
49
|
+
try:
|
|
50
|
+
return cls.validate(val=val, req=False) or default
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
return default
|
|
53
|
+
|
|
54
|
+
def in_(self, *enum_values: Self) -> bool:
|
|
55
|
+
return self in enum_values
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def values(cls) -> Sequence[Self]:
|
|
59
|
+
return list(cls)
|
sotkalib/exceptions/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
from .exc import APIError, ErrorSchema
|
|
1
|
+
from .exc import APIError, ErrorSchema
|
|
@@ -11,5 +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(args_with_values | f_locals | {
|
|
14
|
+
stack_args_to_exc.append(args_with_values | f_locals | {"frame_name": frame.f_code.co_name})
|
|
15
15
|
super().__init__(*_args, *stack_args_to_exc)
|
sotkalib/http/__init__.py
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
from .client_session import (
|
|
2
2
|
ClientSettings,
|
|
3
|
+
# Exceptions
|
|
4
|
+
CriticalStatusError,
|
|
3
5
|
ExceptionSettings,
|
|
4
6
|
Handler,
|
|
5
7
|
HTTPSession,
|
|
6
8
|
Middleware,
|
|
9
|
+
Next,
|
|
10
|
+
RanOutOfAttemptsError,
|
|
11
|
+
RequestContext,
|
|
12
|
+
StatusRetryError,
|
|
7
13
|
StatusSettings,
|
|
8
14
|
)
|
|
9
15
|
|
|
10
16
|
__all__ = (
|
|
11
17
|
"HTTPSession",
|
|
18
|
+
"RequestContext",
|
|
12
19
|
"ExceptionSettings",
|
|
13
20
|
"StatusSettings",
|
|
14
21
|
"ClientSettings",
|
|
15
22
|
"Handler",
|
|
16
23
|
"Middleware",
|
|
24
|
+
"Next",
|
|
25
|
+
# Exceptions
|
|
26
|
+
"CriticalStatusError",
|
|
27
|
+
"RanOutOfAttemptsError",
|
|
28
|
+
"StatusRetryError",
|
|
17
29
|
)
|
sotkalib/http/client_session.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import ssl
|
|
3
|
-
|
|
4
|
-
from
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
5
6
|
from http import HTTPStatus
|
|
6
|
-
from typing import Any, Literal,
|
|
7
|
+
from typing import Any, Literal, Self
|
|
7
8
|
|
|
8
9
|
import aiohttp
|
|
9
10
|
from aiohttp import client_exceptions
|
|
@@ -18,12 +19,15 @@ try:
|
|
|
18
19
|
except ImportError:
|
|
19
20
|
certifi = None
|
|
20
21
|
|
|
22
|
+
|
|
21
23
|
class RanOutOfAttemptsError(Exception):
|
|
22
24
|
pass
|
|
23
25
|
|
|
26
|
+
|
|
24
27
|
class CriticalStatusError(Exception):
|
|
25
28
|
pass
|
|
26
29
|
|
|
30
|
+
|
|
27
31
|
class StatusRetryError(Exception):
|
|
28
32
|
status: int
|
|
29
33
|
context: str
|
|
@@ -33,12 +37,93 @@ class StatusRetryError(Exception):
|
|
|
33
37
|
self.status = status
|
|
34
38
|
self.context = context
|
|
35
39
|
|
|
36
|
-
type ExcArgFunc = Callable[..., tuple[Sequence[Any], Mapping[str, Any] | None]]
|
|
37
|
-
type StatArgFunc = Callable[..., Any]
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
@dataclass
|
|
42
|
+
class RequestContext:
|
|
43
|
+
method: str
|
|
44
|
+
url: str
|
|
45
|
+
params: dict[str, Any] | None = None
|
|
46
|
+
headers: dict[str, Any] | None = None
|
|
47
|
+
data: Any = None
|
|
48
|
+
json: Any = None
|
|
49
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
attempt: int = 0
|
|
52
|
+
max_attempts: int = 1
|
|
53
|
+
|
|
54
|
+
response: aiohttp.ClientResponse | None = None
|
|
55
|
+
response_body: Any = None
|
|
56
|
+
response_text: str | None = None
|
|
57
|
+
response_json: Any = None
|
|
58
|
+
|
|
59
|
+
started_at: float | None = None
|
|
60
|
+
finished_at: float | None = None
|
|
61
|
+
attempt_started_at: float | None = None
|
|
62
|
+
|
|
63
|
+
errors: list[Exception] = field(default_factory=list)
|
|
64
|
+
last_error: Exception | None = None
|
|
65
|
+
|
|
66
|
+
state: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def elapsed(self) -> float | None:
|
|
70
|
+
if self.started_at is None:
|
|
71
|
+
return None
|
|
72
|
+
end = self.finished_at if self.finished_at else time.monotonic()
|
|
73
|
+
return end - self.started_at
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def attempt_elapsed(self) -> float | None:
|
|
77
|
+
if self.attempt_started_at is None:
|
|
78
|
+
return None
|
|
79
|
+
return time.monotonic() - self.attempt_started_at
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_retry(self) -> bool:
|
|
83
|
+
return self.attempt > 0
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def status(self) -> int | None:
|
|
87
|
+
return self.response.status if self.response else None
|
|
88
|
+
|
|
89
|
+
def merge_headers(self, headers: dict[str, str]) -> None:
|
|
90
|
+
if self.headers is None:
|
|
91
|
+
self.headers = {}
|
|
92
|
+
self.headers.update(headers)
|
|
93
|
+
|
|
94
|
+
def to_request_kwargs(self) -> dict[str, Any]:
|
|
95
|
+
kw = dict(self.kwargs)
|
|
96
|
+
if self.params is not None:
|
|
97
|
+
kw["params"] = self.params
|
|
98
|
+
if self.headers is not None:
|
|
99
|
+
kw["headers"] = self.headers
|
|
100
|
+
if self.data is not None:
|
|
101
|
+
kw["data"] = self.data
|
|
102
|
+
if self.json is not None:
|
|
103
|
+
kw["json"] = self.json
|
|
104
|
+
return kw
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
type Next[T] = Callable[[RequestContext], Awaitable[T]]
|
|
108
|
+
type Middleware[T, R] = Callable[[RequestContext, Next[T]], Awaitable[R]]
|
|
109
|
+
|
|
110
|
+
type ExcArgFunc = Callable[[RequestContext], tuple[Sequence[Any], Mapping[str, Any] | None]]
|
|
111
|
+
type StatArgFunc = Callable[[RequestContext], tuple[Sequence[Any], Mapping[str, Any] | None]]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def default_stat_arg_func(ctx: RequestContext) -> tuple[Sequence[Any], None]:
|
|
115
|
+
resp = ctx.response
|
|
116
|
+
if resp is None:
|
|
117
|
+
return (), None
|
|
40
118
|
return (f"[{resp.status}]; {await resp.text()=}",), None
|
|
41
119
|
|
|
120
|
+
|
|
121
|
+
def default_exc_arg_func(ctx: RequestContext) -> tuple[Sequence[Any], None]:
|
|
122
|
+
exc = ctx.last_error
|
|
123
|
+
msg = f"exception {type(exc)}: ({exc=}) attempt={ctx.attempt}; url={ctx.url} method={ctx.method}"
|
|
124
|
+
return (msg,), None
|
|
125
|
+
|
|
126
|
+
|
|
42
127
|
class StatusSettings(BaseModel):
|
|
43
128
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
44
129
|
|
|
@@ -49,13 +134,11 @@ class StatusSettings(BaseModel):
|
|
|
49
134
|
args_for_exc_func: StatArgFunc = Field(default=default_stat_arg_func)
|
|
50
135
|
unspecified: Literal["retry", "raise"] = Field(default="retry")
|
|
51
136
|
|
|
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
|
|
54
137
|
|
|
55
138
|
class ExceptionSettings(BaseModel):
|
|
56
139
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
57
140
|
|
|
58
|
-
to_raise: tuple[type[Exception]] = Field(
|
|
141
|
+
to_raise: tuple[type[Exception], ...] = Field(
|
|
59
142
|
default=(
|
|
60
143
|
client_exceptions.ConnectionTimeoutError,
|
|
61
144
|
client_exceptions.ClientProxyConnectionError,
|
|
@@ -63,7 +146,7 @@ class ExceptionSettings(BaseModel):
|
|
|
63
146
|
),
|
|
64
147
|
)
|
|
65
148
|
|
|
66
|
-
to_retry: tuple[type[Exception]] = Field(
|
|
149
|
+
to_retry: tuple[type[Exception], ...] = Field(
|
|
67
150
|
default=(
|
|
68
151
|
TimeoutError,
|
|
69
152
|
client_exceptions.ServerDisconnectedError,
|
|
@@ -93,13 +176,6 @@ class ClientSettings(BaseModel):
|
|
|
93
176
|
use_cookies_from_response: bool = Field(default=False)
|
|
94
177
|
|
|
95
178
|
|
|
96
|
-
class Handler[**P, T](Protocol):
|
|
97
|
-
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
type Middleware[**P, T, R] = Callable[[Handler[P, T]], Handler[P, R]]
|
|
101
|
-
|
|
102
|
-
|
|
103
179
|
def _make_ssl_context(disable_tls13: bool = False) -> ssl.SSLContext:
|
|
104
180
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
105
181
|
ctx.load_default_certs()
|
|
@@ -123,136 +199,246 @@ def _make_ssl_context(disable_tls13: bool = False) -> ssl.SSLContext:
|
|
|
123
199
|
return ctx
|
|
124
200
|
|
|
125
201
|
|
|
126
|
-
|
|
127
202
|
class HTTPSession[R = aiohttp.ClientResponse | None]:
|
|
128
203
|
config: ClientSettings
|
|
129
|
-
_session: aiohttp.ClientSession
|
|
130
|
-
_middlewares: list[Middleware]
|
|
204
|
+
_session: aiohttp.ClientSession | None
|
|
205
|
+
_middlewares: list[Middleware[Any, Any]]
|
|
206
|
+
_logger: Any
|
|
131
207
|
|
|
132
208
|
def __init__(
|
|
133
209
|
self,
|
|
134
210
|
config: ClientSettings | None = None,
|
|
135
|
-
_middlewares: list[Middleware] | None = None,
|
|
211
|
+
_middlewares: list[Middleware[Any, Any]] | None = None,
|
|
136
212
|
) -> None:
|
|
137
213
|
self.config = config if config is not None else ClientSettings()
|
|
138
214
|
self._session = None
|
|
139
215
|
self._middlewares = _middlewares or []
|
|
216
|
+
self._logger = get_logger("http.client_session")
|
|
140
217
|
|
|
141
|
-
def use[
|
|
142
|
-
|
|
218
|
+
def use[NewR](self, middleware: Middleware[R, NewR]) -> HTTPSession[NewR]:
|
|
219
|
+
return HTTPSession[NewR](
|
|
143
220
|
config=self.config,
|
|
144
|
-
_middlewares=[*self._middlewares,
|
|
221
|
+
_middlewares=[*self._middlewares, middleware],
|
|
145
222
|
)
|
|
146
|
-
return new_session
|
|
147
223
|
|
|
148
224
|
async def __aenter__(self) -> Self:
|
|
149
225
|
ctx = _make_ssl_context(disable_tls13=False)
|
|
150
226
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
227
|
+
session_kwargs = dict(self.config.session_kwargs)
|
|
228
|
+
if session_kwargs.get("connector") is None:
|
|
229
|
+
session_kwargs["connector"] = aiohttp.TCPConnector(ssl=ctx)
|
|
230
|
+
if session_kwargs.get("trust_env") is None:
|
|
231
|
+
session_kwargs["trust_env"] = False
|
|
155
232
|
|
|
156
233
|
self._session = aiohttp.ClientSession(
|
|
157
234
|
timeout=aiohttp.ClientTimeout(total=self.config.timeout),
|
|
158
|
-
**
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
get_logger("http.client_session").debug(
|
|
162
|
-
f"RetryableClientSession initialized with timeout: {self.config.timeout}"
|
|
235
|
+
**session_kwargs,
|
|
163
236
|
)
|
|
164
237
|
|
|
238
|
+
self._logger.debug(f"HTTPSession initialized with timeout: {self.config.timeout}")
|
|
165
239
|
return self
|
|
166
240
|
|
|
167
|
-
async def __aexit__(
|
|
241
|
+
async def __aexit__(
|
|
242
|
+
self,
|
|
243
|
+
exc_type: type[BaseException] | None,
|
|
244
|
+
exc_val: BaseException | None,
|
|
245
|
+
exc_tb: Any,
|
|
246
|
+
) -> None:
|
|
168
247
|
if self._session:
|
|
169
248
|
await self._session.close()
|
|
170
249
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
250
|
+
def _build_pipeline(self) -> Next[R]:
|
|
251
|
+
async def core_request(ctx: RequestContext) -> aiohttp.ClientResponse | None:
|
|
252
|
+
return await self._execute_request(ctx)
|
|
253
|
+
|
|
254
|
+
pipeline: Next[Any] = core_request
|
|
255
|
+
for middleware in reversed(self._middlewares):
|
|
256
|
+
pipeline = (lambda mw, nxt: lambda c: mw(c, nxt))(middleware, pipeline) # noqa: PLC3002
|
|
257
|
+
|
|
258
|
+
return pipeline
|
|
259
|
+
|
|
260
|
+
async def _execute_request(self, ctx: RequestContext) -> aiohttp.ClientResponse | None:
|
|
261
|
+
if self._session is None:
|
|
262
|
+
raise RuntimeError("HTTPSession must be used as async context manager")
|
|
263
|
+
|
|
264
|
+
response = await self._session.request(ctx.method, ctx.url, **ctx.to_request_kwargs())
|
|
265
|
+
ctx.response = response
|
|
266
|
+
|
|
267
|
+
return await self._handle_status(ctx, response)
|
|
268
|
+
|
|
269
|
+
async def _handle_status(
|
|
270
|
+
self,
|
|
271
|
+
ctx: RequestContext,
|
|
272
|
+
response: aiohttp.ClientResponse,
|
|
273
|
+
) -> aiohttp.ClientResponse | None:
|
|
274
|
+
status = response.status
|
|
275
|
+
settings = self.config.status_settings
|
|
276
|
+
|
|
277
|
+
if self.config.use_cookies_from_response and self._session:
|
|
175
278
|
self._session.cookie_jar.update_cookies(response.cookies)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
279
|
+
|
|
280
|
+
if HTTPStatus(status) in settings.to_retry:
|
|
281
|
+
text = await response.text()
|
|
282
|
+
ctx.response_text = text
|
|
283
|
+
raise StatusRetryError(status=status, context=text)
|
|
284
|
+
|
|
285
|
+
if HTTPStatus(status) in settings.to_raise:
|
|
286
|
+
exc_cls = settings.exc_to_raise
|
|
287
|
+
args, kwargs = settings.args_for_exc_func(ctx)
|
|
288
|
+
if kwargs is None:
|
|
289
|
+
raise exc_cls(*args)
|
|
290
|
+
raise exc_cls(*args, **kwargs)
|
|
291
|
+
|
|
292
|
+
if settings.not_found_as_none and status == HTTPStatus.NOT_FOUND:
|
|
184
293
|
return None
|
|
185
294
|
|
|
186
295
|
return response
|
|
187
296
|
|
|
188
|
-
def
|
|
189
|
-
|
|
190
|
-
|
|
297
|
+
async def _request_with_retry(self, ctx: RequestContext) -> R:
|
|
298
|
+
ctx.started_at = time.monotonic()
|
|
299
|
+
ctx.max_attempts = self.config.maximum_retries + 1
|
|
191
300
|
|
|
192
|
-
|
|
301
|
+
pipeline = self._build_pipeline()
|
|
193
302
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
make_request_func: Callable[..., Any],
|
|
199
|
-
**kw: Any,
|
|
200
|
-
) -> R:
|
|
201
|
-
if self.config.useragent_factory is not None:
|
|
202
|
-
user_agent_header = {"User-Agent": self.config.useragent_factory()}
|
|
203
|
-
kw["headers"] = kw.get("headers", {}) | user_agent_header
|
|
303
|
+
for attempt in range(ctx.max_attempts):
|
|
304
|
+
ctx.attempt = attempt
|
|
305
|
+
ctx.attempt_started_at = time.monotonic()
|
|
306
|
+
ctx.response = None
|
|
204
307
|
|
|
205
|
-
|
|
308
|
+
try:
|
|
309
|
+
result = await pipeline(ctx)
|
|
310
|
+
ctx.finished_at = time.monotonic()
|
|
311
|
+
return result
|
|
206
312
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
313
|
+
except merge_tuples(self.config.exception_settings.to_retry, (StatusRetryError,)) as e:
|
|
314
|
+
ctx.errors.append(e)
|
|
315
|
+
ctx.last_error = e
|
|
316
|
+
await self._handle_retry(ctx, e)
|
|
210
317
|
|
|
211
|
-
|
|
318
|
+
except self.config.exception_settings.to_raise as e:
|
|
319
|
+
ctx.errors.append(e)
|
|
320
|
+
ctx.last_error = e
|
|
321
|
+
ctx.finished_at = time.monotonic()
|
|
322
|
+
await self._handle_to_raise(ctx, e)
|
|
212
323
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
324
|
+
except Exception as e:
|
|
325
|
+
ctx.errors.append(e)
|
|
326
|
+
ctx.last_error = e
|
|
327
|
+
await self._handle_exception(ctx, e)
|
|
216
328
|
|
|
217
|
-
|
|
329
|
+
ctx.finished_at = time.monotonic()
|
|
330
|
+
raise RanOutOfAttemptsError(
|
|
331
|
+
f"failed after {self.config.maximum_retries} retries: {type(ctx.last_error).__name__}: {ctx.last_error}"
|
|
332
|
+
)
|
|
218
333
|
|
|
219
|
-
|
|
220
|
-
if
|
|
221
|
-
raise
|
|
334
|
+
async def _handle_retry(self, ctx: RequestContext, e: Exception) -> None:
|
|
335
|
+
if ctx.attempt >= self.config.maximum_retries:
|
|
336
|
+
raise RanOutOfAttemptsError(
|
|
337
|
+
f"failed after {self.config.maximum_retries} retries: {type(e).__name__}: {e}"
|
|
338
|
+
) from e
|
|
222
339
|
|
|
223
|
-
|
|
340
|
+
delay = self.config.base * min(MAXIMUM_BACKOFF, self.config.backoff**ctx.attempt)
|
|
341
|
+
self._logger.debug(
|
|
342
|
+
f"Retry {ctx.attempt + 1}/{ctx.max_attempts} for {ctx.method} {ctx.url} "
|
|
343
|
+
f"after {delay:.2f}s (error: {type(e).__name__})"
|
|
344
|
+
)
|
|
345
|
+
await asyncio.sleep(delay)
|
|
224
346
|
|
|
225
|
-
async def
|
|
226
|
-
|
|
347
|
+
async def _handle_to_raise(self, ctx: RequestContext, e: Exception) -> None:
|
|
348
|
+
exc_cls = self.config.exception_settings.exc_to_raise
|
|
349
|
+
if exc_cls is None:
|
|
227
350
|
raise e
|
|
228
351
|
|
|
229
|
-
|
|
352
|
+
args, kwargs = self.config.exception_settings.args_for_exc_func(ctx)
|
|
353
|
+
if kwargs is None:
|
|
354
|
+
raise exc_cls(*args) from e
|
|
355
|
+
raise exc_cls(*args, **kwargs) from e
|
|
230
356
|
|
|
231
|
-
async def
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
except self.config.exception_settings.to_retry + (StatusRetryError,) as e:
|
|
237
|
-
await self._handle_retry(e, attempt, url, method, **kw)
|
|
238
|
-
except self.config.exception_settings.to_raise as e:
|
|
239
|
-
await self._handle_to_raise(e, attempt, url, method, **kw)
|
|
240
|
-
except Exception as e:
|
|
241
|
-
await self._handle_exception(e, attempt, url, method, **kw)
|
|
357
|
+
async def _handle_exception(self, ctx: RequestContext, e: Exception) -> None:
|
|
358
|
+
"""Handle unspecified exceptions according to settings."""
|
|
359
|
+
if self.config.exception_settings.unspecified == "raise":
|
|
360
|
+
raise e
|
|
361
|
+
await self._handle_retry(ctx, e)
|
|
242
362
|
|
|
243
|
-
|
|
363
|
+
def _create_context(
|
|
364
|
+
self,
|
|
365
|
+
method: str,
|
|
366
|
+
url: str,
|
|
367
|
+
params: dict[str, Any] | None = None,
|
|
368
|
+
headers: dict[str, Any] | None = None,
|
|
369
|
+
data: Any = None,
|
|
370
|
+
json: Any = None,
|
|
371
|
+
**kwargs: Any,
|
|
372
|
+
) -> RequestContext:
|
|
373
|
+
"""Create a RequestContext for the given request parameters."""
|
|
374
|
+
# Apply user agent if configured
|
|
375
|
+
if self.config.useragent_factory is not None:
|
|
376
|
+
if headers is None:
|
|
377
|
+
headers = {}
|
|
378
|
+
headers["User-Agent"] = self.config.useragent_factory()
|
|
379
|
+
|
|
380
|
+
return RequestContext(
|
|
381
|
+
method=method,
|
|
382
|
+
url=url,
|
|
383
|
+
params=params,
|
|
384
|
+
headers=headers,
|
|
385
|
+
data=data,
|
|
386
|
+
json=json,
|
|
387
|
+
kwargs=kwargs,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
async def request(
|
|
391
|
+
self,
|
|
392
|
+
method: str,
|
|
393
|
+
url: str,
|
|
394
|
+
*,
|
|
395
|
+
params: dict[str, Any] | None = None,
|
|
396
|
+
headers: dict[str, Any] | None = None,
|
|
397
|
+
data: Any = None,
|
|
398
|
+
json: Any = None,
|
|
399
|
+
**kwargs: Any,
|
|
400
|
+
) -> R:
|
|
401
|
+
ctx = self._create_context(method, url, params, headers, data, json, **kwargs)
|
|
402
|
+
return await self._request_with_retry(ctx)
|
|
244
403
|
|
|
245
404
|
async def get(self, url: str, **kwargs: Any) -> R:
|
|
246
|
-
|
|
405
|
+
"""Make a GET request."""
|
|
406
|
+
return await self.request("GET", url, **kwargs)
|
|
247
407
|
|
|
248
408
|
async def post(self, url: str, **kwargs: Any) -> R:
|
|
249
|
-
|
|
409
|
+
"""Make a POST request."""
|
|
410
|
+
return await self.request("POST", url, **kwargs)
|
|
250
411
|
|
|
251
412
|
async def put(self, url: str, **kwargs: Any) -> R:
|
|
252
|
-
|
|
413
|
+
"""Make a PUT request."""
|
|
414
|
+
return await self.request("PUT", url, **kwargs)
|
|
253
415
|
|
|
254
416
|
async def delete(self, url: str, **kwargs: Any) -> R:
|
|
255
|
-
|
|
417
|
+
"""Make a DELETE request."""
|
|
418
|
+
return await self.request("DELETE", url, **kwargs)
|
|
256
419
|
|
|
257
420
|
async def patch(self, url: str, **kwargs: Any) -> R:
|
|
258
|
-
|
|
421
|
+
"""Make a PATCH request."""
|
|
422
|
+
return await self.request("PATCH", url, **kwargs)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def merge_tuples[T](t1: tuple[T, ...], t2: tuple[T, ...]) -> tuple[T, ...]:
|
|
426
|
+
return t1 + t2
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ============================================================================
|
|
430
|
+
# Legacy compatibility aliases
|
|
431
|
+
# ============================================================================
|
|
432
|
+
|
|
433
|
+
# Old Handler protocol - kept for backwards compatibility but deprecated
|
|
434
|
+
from typing import Protocol
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class Handler[**P, T](Protocol):
|
|
438
|
+
"""
|
|
439
|
+
DEPRECATED: Use Middleware type instead.
|
|
440
|
+
|
|
441
|
+
Old handler protocol for backwards compatibility.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
|
sotkalib/log/factory.py
CHANGED
|
@@ -4,9 +4,9 @@ from typing import TYPE_CHECKING
|
|
|
4
4
|
from loguru import logger
|
|
5
5
|
|
|
6
6
|
if TYPE_CHECKING:
|
|
7
|
-
|
|
7
|
+
from loguru import Logger
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@lru_cache
|
|
11
11
|
def get_logger(logger_name: str | None = None) -> Logger:
|
|
12
|
-
|
|
12
|
+
return logger if logger_name is None else logger.bind(name=logger_name.replace(".", " -> "))
|
sotkalib/redis/__init__.py
CHANGED
sotkalib/sqla/__init__.py
CHANGED
sotkalib/sqla/db.py
CHANGED
|
@@ -7,95 +7,96 @@ from sqlalchemy.orm import Session, sessionmaker
|
|
|
7
7
|
from sotkalib.log import get_logger
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
class ConnectionTimeoutError(Exception):
|
|
10
|
+
class ConnectionTimeoutError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
class DatabaseSettings(BaseModel):
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
uri: str = Field(examples=["postgresql://username:password@localhost:5432/database"])
|
|
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 + "://")
|
|
19
23
|
|
|
20
|
-
@property
|
|
21
|
-
def async_uri(self) -> str:
|
|
22
|
-
return self.uri.replace("postgresql://", "postgresql" + self.async_driver + "://")
|
|
23
24
|
|
|
24
25
|
class Database:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
26
|
+
_sync_engine: Engine | None
|
|
27
|
+
_async_engine: AsyncEngine | None
|
|
28
|
+
_sync_session_factory: sessionmaker = None
|
|
29
|
+
_async_session_factory: async_sessionmaker = None
|
|
30
|
+
|
|
31
|
+
logger = get_logger("sqldb.instance")
|
|
32
|
+
|
|
33
|
+
def __init__(self, settings: DatabaseSettings):
|
|
34
|
+
self.__async_uri = settings.async_uri
|
|
35
|
+
self.__sync_uri = settings.uri
|
|
36
|
+
self.echo = settings.echo
|
|
37
|
+
self.pool_size = settings.pool_size
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
43
|
+
if self._sync_engine:
|
|
44
|
+
self._sync_engine.dispose()
|
|
45
|
+
self.logger.info("closed sync db connection")
|
|
46
|
+
|
|
47
|
+
async def __aenter__(self):
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(self, *args):
|
|
51
|
+
if self._async_engine:
|
|
52
|
+
await self._async_engine.dispose()
|
|
53
|
+
self.logger.info("closed async db connection")
|
|
54
|
+
|
|
55
|
+
def __async_init(self):
|
|
56
|
+
self._async_engine = create_async_engine(
|
|
57
|
+
url=self.__async_uri,
|
|
58
|
+
echo=self.echo,
|
|
59
|
+
pool_size=self.pool_size,
|
|
60
|
+
)
|
|
61
|
+
self._async_session_factory = async_sessionmaker(bind=self._async_engine, expire_on_commit=False)
|
|
62
|
+
self.logger.debug( # noqa: PLE1205
|
|
63
|
+
"successfully initialized async db connection, engine.status = {} sessionmaker.status = {}",
|
|
64
|
+
self._async_engine.name is not None,
|
|
65
|
+
self._async_session_factory is not None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def async_session(self) -> async_sessionmaker[AsyncSession]:
|
|
70
|
+
if self._async_engine is None or self._async_session_factory is None:
|
|
71
|
+
self.logger.debug("async_sf not found, initializing")
|
|
72
|
+
self.__async_init()
|
|
73
|
+
if self._async_engine is None or self._async_session_factory is None:
|
|
74
|
+
self.logger.error(c := "could not asynchronously connect to pgsql")
|
|
75
|
+
raise ConnectionTimeoutError(c)
|
|
76
|
+
self.logger.debug("success getting (asyncmaker)")
|
|
77
|
+
return self._async_session_factory
|
|
78
|
+
|
|
79
|
+
def __sync_init(self):
|
|
80
|
+
self._sync_engine = create_engine(
|
|
81
|
+
url=self.__sync_uri,
|
|
82
|
+
echo=self.echo,
|
|
83
|
+
pool_size=self.pool_size,
|
|
84
|
+
)
|
|
85
|
+
self._sync_session_factory = sessionmaker(bind=self._sync_engine, expire_on_commit=False)
|
|
86
|
+
self.logger.debug( # noqa
|
|
87
|
+
" -> (__sync_init) successfully initialized sync db connection,\n"
|
|
88
|
+
"\t\t\t\tengine.status = {} sessionmaker.status = {}",
|
|
89
|
+
self._sync_engine.name is not None,
|
|
90
|
+
self._sync_session_factory is not None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def session(self) -> sessionmaker[Session]:
|
|
95
|
+
if self._sync_engine is None or self._sync_session_factory is None:
|
|
96
|
+
self.logger.debug("not found, initializing...")
|
|
97
|
+
self.__sync_init()
|
|
98
|
+
if self._sync_engine is None or self._sync_session_factory is None:
|
|
99
|
+
self.logger.error(c := "could not synchronously connect to pgsql")
|
|
100
|
+
raise ConnectionTimeoutError(c)
|
|
101
|
+
self.logger.debug("success getting (syncmaker)")
|
|
102
|
+
return self._sync_session_factory
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
sotkalib/__init__.py,sha256=TDJPQ_pOk73TTDkjgNpvn4nvn3siktb1sTogogLCwa0,139
|
|
2
|
+
sotkalib/config/__init__.py,sha256=_F7rSYgBsSxnNL1JtxrJYlw3lBXyVg0JdsOrxbWtcDA,96
|
|
3
|
+
sotkalib/config/field.py,sha256=vbKGAEevEmdvyw4eaZprfR2g7ZVAB-5AbYPx0f4uusc,317
|
|
4
|
+
sotkalib/config/struct.py,sha256=gv1jFrSRytMC6bZTUDOUQf00Zo1iKxQvuTxGv9qnyHI,5679
|
|
5
|
+
sotkalib/enum/__init__.py,sha256=pKpLPm8fqHO4Et21TWIybIPRiehN1KrmxcBh6hPRsxM,127
|
|
6
|
+
sotkalib/enum/mixins.py,sha256=CQrgKftnmZSWkNb-56Z9PZ3um0_lHGEsnEYy9GwCmhM,1611
|
|
7
|
+
sotkalib/exceptions/__init__.py,sha256=r-DwSwJIkuQ2UGAorKvkIVv87n4Yt8H0mk_uxKcBGTw,59
|
|
8
|
+
sotkalib/exceptions/api/__init__.py,sha256=yTbg2p5mB0-8ZHtzlLL6e0ZkC3LRUZmjmWMxU9Uh8-Q,39
|
|
9
|
+
sotkalib/exceptions/api/exc.py,sha256=gqx4GrHXUvKcR7tEmJpRqPbDWOT2AgKoyck8-FovQCc,1329
|
|
10
|
+
sotkalib/exceptions/handlers/__init__.py,sha256=Pz1akT2x3SaRsPezNPYnCoTcejxy4n4_cO4cXRJUBIk,179
|
|
11
|
+
sotkalib/exceptions/handlers/args_incl_error.py,sha256=rYiBximsXVw1YDUBbdsqeqsfTWxshyX4EdISXWYkPDE,533
|
|
12
|
+
sotkalib/exceptions/handlers/core.py,sha256=5fhusoxBhUz59TaVWobplBvD-sbkZKBnmmu-fcSyRk4,836
|
|
13
|
+
sotkalib/http/__init__.py,sha256=TGjBT5pgmNuBO1TpXU_YqSfXdWsGTEHpGO8Qwf2x2w4,457
|
|
14
|
+
sotkalib/http/client_session.py,sha256=pIbTYbP8NT9ZGnuTi7LI7nE-UvxPx2G8RCgiEpNpMCA,12928
|
|
15
|
+
sotkalib/log/__init__.py,sha256=xrBx--c8QU5xkb3_n61LuqF8ySUaxlQkHCxHyH_D8aE,58
|
|
16
|
+
sotkalib/log/factory.py,sha256=oyvHOum8jwLGr_XC0c44VIVLzWQQqHSbQOnf85bP9Co,303
|
|
17
|
+
sotkalib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
sotkalib/redis/__init__.py,sha256=-0ZXD-cC-Xi6RmAvnrAgU-8Z6g4l88XmXf3kvIgBD9k,154
|
|
19
|
+
sotkalib/redis/client.py,sha256=0TWe-gYqFiuCjqimCQrVrnTHSM0EROIoJL36M3qwOtQ,1118
|
|
20
|
+
sotkalib/redis/lock.py,sha256=nEZjIyXmgq3vH-Urs8qXC_N8lmXNho00SaTZ7wJIEIo,2528
|
|
21
|
+
sotkalib/sqla/__init__.py,sha256=n-I_hoRS-N7XN02yYCTtw6Dh4BBSQRmolS19tEB2KMM,87
|
|
22
|
+
sotkalib/sqla/db.py,sha256=6ZckKQ8kmRlYrwCAzKidc_JNPwqp38tSGEy3XMGnv08,3376
|
|
23
|
+
sotkalib-0.0.5.post1.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
|
|
24
|
+
sotkalib-0.0.5.post1.dist-info/METADATA,sha256=qU3FtZwv03aV_0TcWvi_xgif-WyzqJVfgLkUclZbRNU,392
|
|
25
|
+
sotkalib-0.0.5.post1.dist-info/RECORD,,
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
sotkalib/__init__.py,sha256=t3dEAlrtHABFRZdF7RM5K2N1fDUTVwUPmBPG4jFdvSY,138
|
|
2
|
-
sotkalib/config/__init__.py,sha256=CSjn02NCnBPO14QOg4OzKI-lTxyKoBxQ4ODsiWamlIM,102
|
|
3
|
-
sotkalib/config/field.py,sha256=596_6DLnUIMN86h2uob9YN5IrHnc7fguWScSbIYsTH4,326
|
|
4
|
-
sotkalib/config/struct.py,sha256=gv1jFrSRytMC6bZTUDOUQf00Zo1iKxQvuTxGv9qnyHI,5679
|
|
5
|
-
sotkalib/enum/__init__.py,sha256=pKpLPm8fqHO4Et21TWIybIPRiehN1KrmxcBh6hPRsxM,127
|
|
6
|
-
sotkalib/enum/mixins.py,sha256=rgXb0eXaBSozrviOMJo1671x4DiN9SELtw3-x6PvhDM,1821
|
|
7
|
-
sotkalib/exceptions/__init__.py,sha256=H2h-yW1o0_X1Z9O-hWgj6h2spFxpzJJQ_N2ITMr600A,58
|
|
8
|
-
sotkalib/exceptions/api/__init__.py,sha256=tIFOiRlbPkgCRNv5OPZ1M98nRnAMkFIuqSK7dZpKMRI,38
|
|
9
|
-
sotkalib/exceptions/api/exc.py,sha256=gqx4GrHXUvKcR7tEmJpRqPbDWOT2AgKoyck8-FovQCc,1329
|
|
10
|
-
sotkalib/exceptions/handlers/__init__.py,sha256=rA6o6_LVa-0TToyPhT1vB_Lz2E0U8EUfHICAYgAUd78,178
|
|
11
|
-
sotkalib/exceptions/handlers/args_incl_error.py,sha256=DB8TMhZdSkwZwEahOJ99zXWknqBTkSC4IKZsZ5psxdg,535
|
|
12
|
-
sotkalib/exceptions/handlers/core.py,sha256=5fhusoxBhUz59TaVWobplBvD-sbkZKBnmmu-fcSyRk4,836
|
|
13
|
-
sotkalib/http/__init__.py,sha256=HxOuGbHbz39MD0ICjOSh4zv-nGPZca9TgxP4rCceamw,241
|
|
14
|
-
sotkalib/http/client_session.py,sha256=OtGTpwr8I7tsgOxRGS8c7tby5sGT_T6B0Zc8HTC9Sxs,8838
|
|
15
|
-
sotkalib/log/__init__.py,sha256=xrBx--c8QU5xkb3_n61LuqF8ySUaxlQkHCxHyH_D8aE,58
|
|
16
|
-
sotkalib/log/factory.py,sha256=Wl8qY2-vimpctRlRYSWPLjC0KgeEGgSSuDDJaxWtvK8,309
|
|
17
|
-
sotkalib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
-
sotkalib/redis/__init__.py,sha256=wv3AIRw3aXXLgTTrt6my8C8jSBl4-R3dWRfGLbGTHng,167
|
|
19
|
-
sotkalib/redis/client.py,sha256=0TWe-gYqFiuCjqimCQrVrnTHSM0EROIoJL36M3qwOtQ,1118
|
|
20
|
-
sotkalib/redis/lock.py,sha256=nEZjIyXmgq3vH-Urs8qXC_N8lmXNho00SaTZ7wJIEIo,2528
|
|
21
|
-
sotkalib/sqla/__init__.py,sha256=fYT8O-bPcdXxJ3QVu3KbypwbZ_hpm8Eq1CuQgjvyNJ8,86
|
|
22
|
-
sotkalib/sqla/db.py,sha256=lxE6XLgX-CowyhfRwTAA_FGJybiihaGWWzlNe8M41CE,3866
|
|
23
|
-
sotkalib-0.0.4.post1.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
|
|
24
|
-
sotkalib-0.0.4.post1.dist-info/METADATA,sha256=aHmKbSM7SWEwCVkr-557q0o_Q5_EJ59H5d5F5QMvnbs,392
|
|
25
|
-
sotkalib-0.0.4.post1.dist-info/RECORD,,
|
|
File without changes
|