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.
Files changed (33) hide show
  1. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/PKG-INFO +5 -4
  2. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/pyproject.toml +5 -4
  3. sotkalib-0.0.4.post1/src/sotkalib/__init__.py +3 -0
  4. sotkalib-0.0.4.post1/src/sotkalib/config/field.py +11 -0
  5. sotkalib-0.0.4.post1/src/sotkalib/exceptions/__init__.py +3 -0
  6. sotkalib-0.0.4.post1/src/sotkalib/exceptions/api/__init__.py +1 -0
  7. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/exceptions/api/exc.py +0 -3
  8. sotkalib-0.0.4.post1/src/sotkalib/exceptions/handlers/__init__.py +4 -0
  9. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/exceptions/handlers/args_incl_error.py +1 -7
  10. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/http/__init__.py +2 -2
  11. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/http/client_session.py +54 -43
  12. sotkalib-0.0.4.post1/src/sotkalib/log/factory.py +12 -0
  13. sotkalib-0.0.4.post1/src/sotkalib/redis/__init__.py +8 -0
  14. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/redis/lock.py +10 -17
  15. sotkalib-0.0.4.post1/src/sotkalib/sqla/__init__.py +3 -0
  16. sotkalib-0.0.4.post1/src/sotkalib/sqla/db.py +101 -0
  17. sotkalib-0.0.3/src/sotkalib/__init__.py +0 -5
  18. sotkalib-0.0.3/src/sotkalib/config/field.py +0 -27
  19. sotkalib-0.0.3/src/sotkalib/exceptions/__init__.py +0 -0
  20. sotkalib-0.0.3/src/sotkalib/exceptions/api/__init__.py +0 -0
  21. sotkalib-0.0.3/src/sotkalib/exceptions/handlers/__init__.py +0 -0
  22. sotkalib-0.0.3/src/sotkalib/log/factory.py +0 -29
  23. sotkalib-0.0.3/src/sotkalib/redis/__init__.py +0 -0
  24. sotkalib-0.0.3/src/sotkalib/sqla/__init__.py +0 -0
  25. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/README.md +0 -0
  26. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/config/__init__.py +0 -0
  27. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/config/struct.py +0 -0
  28. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/enum/__init__.py +0 -0
  29. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/enum/mixins.py +0 -0
  30. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/exceptions/handlers/core.py +0 -0
  31. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/log/__init__.py +0 -0
  32. {sotkalib-0.0.3 → sotkalib-0.0.4.post1}/src/sotkalib/py.typed +0 -0
  33. {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
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.3
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.5
11
- Requires-Dist: redis>=7.1.0
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"
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.3",
11
+ "aiohttp>=3.13.0",
12
12
  "dotenv>=0.9.9",
13
13
  "loguru>=0.7.3",
14
- "pydantic>=2.12.5",
15
- "redis>=7.1.0",
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,3 @@
1
+ from . import config, enum, exceptions, http, log, redis, sqla
2
+
3
+ __all__ = ["config", "enum", "exceptions", "http", "log", "redis", "sqla"]
@@ -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,3 @@
1
+ from . import api, handlers
2
+
3
+ __all__ = ["api", "handlers"]
@@ -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
  )
@@ -0,0 +1,4 @@
1
+ from .args_incl_error import ArgsIncludedError
2
+ from .core import aexception_handler, exception_handler
3
+
4
+ __all__ = ["aexception_handler", "exception_handler", "ArgsIncludedError"]
@@ -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
- "ClientSession",
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
- certifi = importlib.import_module("certifi")
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
- class CriticalStatusError(Exception):
38
- pass
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: Any, **kwargs: Any) -> T: ...
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
- class ClientSession[R = aiohttp.ClientResponse | None]:
126
+
127
+ class HTTPSession[R = aiohttp.ClientResponse | None]:
123
128
  config: ClientSettings
124
129
  _session: aiohttp.ClientSession
125
- _middlewares: list[Callable[[Handler[Any]], Handler[Any]]]
130
+ _middlewares: list[Middleware]
126
131
 
127
132
  def __init__(
128
133
  self,
129
134
  config: ClientSettings | None = None,
130
- _middlewares: list[Callable[[Handler[Any]], Handler[Any]]] | None = None,
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]) -> ClientSession[NewR]:
137
- new_session: ClientSession[NewR] = ClientSession(
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
- raise self.config.status_settings.exc_to_raise(f"status code: {sc} | {await response.text()}")
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) -> Handler[R]:
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
- handler: Handler[Any] = _make_request
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: Handler[R],
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
- kw_for_request["headers"] = kw_for_request.get("headers", {}) | user_agent_header
200
- return await make_request_func(method, url, **kw_for_request)
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, attempt: int, e: Exception, _: str, __: str, **___) -> None:
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 RunOutOfAttemptsError(f"failed after {self.config.maximum_retries} retries: {type(e)} {e}") from e
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
- raise self.config.exception_settings.exc_to_raise(f"EXC: {type(e)} {e}; {url} {method} {kw}") from e
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, url: str, method: str, attempt: int, **kw) -> None:
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(attempt, e, url, method, **kw)
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(attempt, e, url, method, **kw)
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, attempt, **kw)
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(".", " -> "))
@@ -0,0 +1,8 @@
1
+ from .client import RedisPool, RedisPoolSettings
2
+ from .lock import redis_context_lock
3
+
4
+ __all__ = [
5
+ "redis_context_lock",
6
+ "RedisPool",
7
+ "RedisPoolSettings"
8
+ ]
@@ -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 _try_acquire(rc: Redis, key_to_lock: str, acquire_timeout: int) -> bool:
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 wait_till_lock_free(
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 | RedisPool,
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 wait_till_lock_free(key_to_lock=key_to_lock, client=rc, lock_timeout=wait_timeout)
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 _try_acquire(rc, key_to_lock, acquire_timeout)
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,3 @@
1
+ from .db import Database, DatabaseSettings
2
+
3
+ __all__ = ("Database", "DatabaseSettings")
@@ -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,5 +0,0 @@
1
- """
2
-
3
- Library with utils, made for s0tka.ru
4
-
5
- """
@@ -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
@@ -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