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 CHANGED
@@ -1,3 +1,3 @@
1
1
  from . import config, enum, exceptions, http, log, redis, sqla
2
2
 
3
- __all__ = ["config", "enum", "exceptions", "http", "log", "redis", "sqla"]
3
+ __all__ = ["config", "enum", "exceptions", "http", "log", "redis", "sqla"]
@@ -1,6 +1,6 @@
1
1
  from .struct import AppSettings, SettingsField
2
2
 
3
3
  __all__ = [
4
- "AppSettings",
5
- "SettingsField",
4
+ "AppSettings",
5
+ "SettingsField",
6
6
  ]
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
- default: T | None = None
10
- factory: Callable[[], T] | str | None = None
11
- nullable: bool = False
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
- @staticmethod
8
- def _generate_next_value_(name: str, start: int, count: int, last_values: Sequence) -> str: # noqa
9
- return name.upper()
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
- @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)
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)
@@ -1,3 +1,3 @@
1
1
  from . import api, handlers
2
2
 
3
- __all__ = ["api", "handlers"]
3
+ __all__ = ["api", "handlers"]
@@ -1 +1 @@
1
- from .exc import APIError, ErrorSchema
1
+ from .exc import APIError, ErrorSchema
@@ -1,4 +1,4 @@
1
1
  from .args_incl_error import ArgsIncludedError
2
2
  from .core import aexception_handler, exception_handler
3
3
 
4
- __all__ = ["aexception_handler", "exception_handler", "ArgsIncludedError"]
4
+ __all__ = ["aexception_handler", "exception_handler", "ArgsIncludedError"]
@@ -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 | { "frame_name": frame.f_code.co_name })
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
  )
@@ -1,9 +1,10 @@
1
1
  import asyncio
2
2
  import ssl
3
- from collections.abc import Callable, Mapping, Sequence
4
- from functools import reduce
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, Protocol, Self
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
- async def default_stat_arg_func(resp: aiohttp.ClientResponse) -> tuple[Sequence[Any], None]:
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[**P, NewR](self, mw: Middleware[P, R, NewR]) -> HTTPSession[NewR]:
142
- new_session: HTTPSession[NewR] = HTTPSession(
218
+ def use[NewR](self, middleware: Middleware[R, NewR]) -> HTTPSession[NewR]:
219
+ return HTTPSession[NewR](
143
220
  config=self.config,
144
- _middlewares=[*self._middlewares, mw],
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
- if self.config.session_kwargs.get("connector") is None:
152
- self.config.session_kwargs["connector"] = aiohttp.TCPConnector(ssl=ctx)
153
- if self.config.session_kwargs.get("trust_env") is None:
154
- self.config.session_kwargs["trust_env"] = False
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
- **self.config.session_kwargs,
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__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
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
- async def _handle_statuses(self, response: aiohttp.ClientResponse) -> aiohttp.ClientResponse | None:
172
- sc = response.status
173
- exc, argfunc = self.config.status_settings.exc_to_raise, self.config.status_settings.args_for_exc_func
174
- if self.config.use_cookies_from_response:
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
- if sc in self.config.status_settings.to_retry:
177
- raise StatusRetryError(status=sc, context=(await response.text()))
178
- elif sc in self.config.status_settings.to_raise:
179
- a, kw = await argfunc(response)
180
- if kw is None:
181
- raise exc(*a)
182
- raise exc(*a, **kw)
183
- elif self.config.status_settings.not_found_as_none and sc == HTTPStatus.NOT_FOUND:
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 _get_make_request_func(self) -> Callable[..., Any]:
189
- async def _make_request(*args: Any, **kwargs: Any) -> aiohttp.ClientResponse | None:
190
- return await self._handle_statuses(await self._session.request(*args, **kwargs))
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
- return reduce(lambda t, s: s(t), reversed(self._middlewares), _make_request)
301
+ pipeline = self._build_pipeline()
193
302
 
194
- async def _handle_request(
195
- self,
196
- method: str,
197
- url: str,
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
- return await make_request_func(method, url, **kw)
308
+ try:
309
+ result = await pipeline(ctx)
310
+ ctx.finished_at = time.monotonic()
311
+ return result
206
312
 
207
- async def _handle_retry(self, e: Exception, attempt: int, url: str, method: str, **kws: Any) -> None:
208
- if attempt == self.config.maximum_retries:
209
- raise RanOutOfAttemptsError(f"failed after {self.config.maximum_retries} retries: {type(e)} {e}") from e
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
- await asyncio.sleep(self.config.base * min(MAXIMUM_BACKOFF, self.config.backoff**attempt))
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
- async def _handle_to_raise(self, e: Exception, attempt: int, url: str, method: str, **kw: Any) -> None:
214
- if self.config.exception_settings.exc_to_raise is None:
215
- raise e
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
- exc, argfunc = self.config.exception_settings.exc_to_raise, self.config.exception_settings.args_for_exc_func
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
- a, exckw = argfunc(e, attempt, url, method, **kw)
220
- if exckw is None:
221
- raise exc(*a) from e
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
- raise exc(*a, **exckw) from e
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 _handle_exception(self, e: Exception, attempt: int, url: str, method: str, **kw: Any) -> None:
226
- if self.config.exception_settings.unspecified == "raise":
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
- await self._handle_retry(e, attempt, url, method, **kw)
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 _request_with_retry(self, method: str, url: str, **kw: Any) -> R:
232
- _make_request = self._get_make_request_func()
233
- for attempt in range(self.config.maximum_retries + 1):
234
- try:
235
- return await self._handle_request(method, url, _make_request, **kw)
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
- return await _make_request()
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
- return await self._request_with_retry("GET", url, **kwargs)
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
- return await self._request_with_retry("POST", url, **kwargs)
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
- return await self._request_with_retry("PUT", url, **kwargs)
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
- return await self._request_with_retry("DELETE", url, **kwargs)
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
- return await self._request_with_retry("PATCH", url, **kwargs)
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
- from loguru import Logger
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
- return logger if logger_name is None else logger.bind(name=logger_name.replace(".", " -> "))
12
+ return logger if logger_name is None else logger.bind(name=logger_name.replace(".", " -> "))
@@ -1,8 +1,4 @@
1
1
  from .client import RedisPool, RedisPoolSettings
2
2
  from .lock import redis_context_lock
3
3
 
4
- __all__ = [
5
- "redis_context_lock",
6
- "RedisPool",
7
- "RedisPoolSettings"
8
- ]
4
+ __all__ = ["redis_context_lock", "RedisPool", "RedisPoolSettings"]
sotkalib/sqla/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from .db import Database, DatabaseSettings
2
2
 
3
- __all__ = ("Database", "DatabaseSettings")
3
+ __all__ = ("Database", "DatabaseSettings")
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): pass
10
+ class ConnectionTimeoutError(Exception):
11
+ pass
12
+
11
13
 
12
14
  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
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
- _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
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sotkalib
3
- Version: 0.0.4.post1
3
+ Version: 0.0.5.post1
4
4
  Summary:
5
5
  Author: alexey
6
6
  Author-email: alexey <me@pyrorhythm.dev>
@@ -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,,