sotkalib 0.0.2__py3-none-any.whl → 0.0.3__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/config/struct.py +121 -121
- sotkalib/enum/__init__.py +3 -0
- sotkalib/enum/mixins.py +59 -0
- sotkalib/exceptions/__init__.py +0 -0
- sotkalib/exceptions/api/__init__.py +0 -0
- sotkalib/exceptions/api/exc.py +56 -0
- sotkalib/exceptions/handlers/__init__.py +0 -0
- sotkalib/exceptions/handlers/args_incl_error.py +21 -0
- sotkalib/exceptions/handlers/core.py +33 -0
- sotkalib/http/__init__.py +12 -12
- sotkalib/http/client_session.py +194 -194
- sotkalib/redis/__init__.py +0 -0
- sotkalib/redis/client.py +38 -0
- sotkalib/redis/lock.py +89 -0
- sotkalib/sqla/__init__.py +0 -0
- {sotkalib-0.0.2.dist-info → sotkalib-0.0.3.dist-info}/METADATA +2 -1
- sotkalib-0.0.3.dist-info/RECORD +24 -0
- sotkalib-0.0.2.dist-info/RECORD +0 -12
- {sotkalib-0.0.2.dist-info → sotkalib-0.0.3.dist-info}/WHEEL +0 -0
sotkalib/config/struct.py
CHANGED
|
@@ -14,166 +14,166 @@ from sotkalib.log import get_logger
|
|
|
14
14
|
from .field import AllowedTypes, SettingsField
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
from logging import (
|
|
18
|
+
Logger as StdLogger,
|
|
19
|
+
)
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
from loguru import Logger as LoguruLogger
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
type _loggers = StdLogger | LoguruLogger
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
@dataclass
|
|
27
27
|
class AppSettings:
|
|
28
|
-
|
|
28
|
+
"""
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Base class for reading typed settings from environment variables.
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
Declare attributes with type annotations and assign SettingsField(...) to each.
|
|
33
|
+
On initialization, values are resolved from the environment (loading .env if provided),
|
|
34
|
+
then from default/factory, or set to None if nullable.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
**Notes:**
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Only immutable primitive types are allowed: int, float, complex, str, bool, None.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
If explicit_format is True, attribute names must be UPPER_SNAKE_CASE.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
**Example:**
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
>>> import secrets
|
|
45
|
+
>>> class MySettings(AppSettings):
|
|
46
|
+
... BOT_TOKEN: str = SettingsField(nullable=False)
|
|
47
|
+
... POSTGRES_USER: str = SettingsField(default="pg_user")
|
|
48
|
+
... POSTGRES_PASSWORD: str = SettingsField(nullable=False, factory=lambda: secrets.token_urlsafe(8))
|
|
49
|
+
... SECRET_ALIAS: str = SettingsField(factory="secret")
|
|
50
|
+
...
|
|
51
|
+
... @property
|
|
52
|
+
... def secret(self) -> str:
|
|
53
|
+
... return "computed"
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
>>> settings = MySettings()
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
"""
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
dotenv_path: str | PathLike[str] | None = None,
|
|
63
|
+
logger: _loggers | None = None,
|
|
64
|
+
explicit_format: bool = True,
|
|
65
|
+
strict: bool = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Initialize AppSettings and resolve annotated fields.
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
**Parameters:**
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
- `dotenv_path`: Optional path to a .env file. If None, python-dotenv searches recursively.
|
|
74
|
+
- `logger`: Optional log or loguru logger.
|
|
75
|
+
- `explicit_format`: If True, attribute names must be uppercase with underscores.
|
|
76
|
+
- `strict`: If True, any mutable types will raise an exception rather than being set to
|
|
77
|
+
None.
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
**Raises:**
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
- `AttributeError`: If an attribute name violates explicit_format constraint or a declared property is missing.
|
|
82
|
+
- `TypeError`: If an annotation uses a disallowed (mutable) type or a factory reference is not a property.
|
|
83
|
+
- `ValueError`: If a required field (nullable=False, no default/factory) is missing in the environment.
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
"""
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
def _unwrap_type(tp: type) -> type:
|
|
88
|
+
if isinstance(tp, UnionType):
|
|
89
|
+
args = [a for a in get_args(tp) if a is not NoneType]
|
|
90
|
+
return args[0] if args else NoneType
|
|
91
|
+
return tp
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
def evaluate_var(_type: type, _var: str) -> Any:
|
|
94
|
+
_type = _unwrap_type(_type)
|
|
95
|
+
if _type is NoneType:
|
|
96
|
+
return None
|
|
97
|
+
if _type is bool:
|
|
98
|
+
return _var.lower() in ("yes", "true", "1", "y")
|
|
99
|
+
return _type(_var)
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
load_dotenv(dotenv_path=dotenv_path)
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
self.__log = get_logger("utilities.appsettings") if logger is None else logger
|
|
104
|
+
self.__deferred = []
|
|
105
|
+
self.__strict = strict
|
|
105
106
|
|
|
106
|
-
|
|
107
|
+
cls_annotations = self.__class__.__annotations__
|
|
108
|
+
cls_dict = self.__class__.__dict__
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
settings_fields: dict[str, SettingsField] = {
|
|
111
|
+
attr: val for attr, val in cls_dict.items() if isinstance(val, SettingsField)
|
|
112
|
+
}
|
|
110
113
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
for attr, settings_field in settings_fields.items():
|
|
115
|
+
if explicit_format and not re.fullmatch(r"[A-Z][A-Z0-9_]*", attr):
|
|
116
|
+
raise AttributeError("AppSettings attributes should contain only capital letters and underscores")
|
|
114
117
|
|
|
115
|
-
|
|
118
|
+
annotated = cls_annotations.get(attr, NoneType)
|
|
119
|
+
string_value = getenv(attr, None)
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
if string_value is None:
|
|
122
|
+
self.__validate_empty_string_value(attr, settings_field)
|
|
123
|
+
continue
|
|
120
124
|
|
|
121
|
-
|
|
122
|
-
string_value = getenv(attr, None)
|
|
125
|
+
typed_value = evaluate_var(annotated, string_value)
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
continue
|
|
127
|
+
setattr(self, attr, self.__validate(typed_value, strict=self.__strict))
|
|
128
|
+
self.__log.debug(f"evaluated {attr} from environment")
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
self.__post_init__()
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
def __validate_empty_string_value(self, attr: str, settings_field: SettingsField) -> None:
|
|
133
|
+
if settings_field.default is not None:
|
|
134
|
+
setattr(self, attr, self.__validate(settings_field.default, strict=self.__strict))
|
|
135
|
+
self.__log.debug(f"evaluated {attr} from default")
|
|
136
|
+
return
|
|
132
137
|
|
|
133
|
-
|
|
138
|
+
if settings_field.factory is not None:
|
|
139
|
+
if isinstance(settings_field.factory, str):
|
|
140
|
+
self.__deferred.append((attr, settings_field.factory))
|
|
141
|
+
self.__log.debug(f"defer {attr} init as factory is a str; => property")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
if callable(settings_field.factory):
|
|
145
|
+
setattr(self, attr, self.__validate(settings_field.factory(), strict=self.__strict))
|
|
146
|
+
self.__log.debug(f"evaluated {attr} from factory")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
raise TypeError(f"unknown type for a factory: {type(settings_field.factory)}")
|
|
134
150
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
151
|
+
if settings_field.nullable:
|
|
152
|
+
setattr(self, attr, None)
|
|
153
|
+
self.__log.debug(f"evaluated {attr} as None (nullable)")
|
|
154
|
+
return
|
|
140
155
|
|
|
141
|
-
|
|
142
|
-
self.__deferred.append((attr, settings_field.factory))
|
|
143
|
-
self.__log.debug(f"defer {attr} init as factory is a str; => property")
|
|
144
|
-
return
|
|
156
|
+
raise ValueError(f"reqd field {attr} was not found in .env")
|
|
145
157
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
def __post_init__(self) -> None:
|
|
159
|
+
for attr, factory in self.__deferred:
|
|
160
|
+
if factory not in self.__class__.__dict__:
|
|
161
|
+
raise AttributeError(f"property {factory} was not found in {self.__class__.__name__}")
|
|
162
|
+
if not isinstance(getattr(self.__class__, factory), property):
|
|
163
|
+
raise TypeError(f"method {factory} is not a property")
|
|
164
|
+
self.__log.debug(f"evaluated {attr} from property {factory}")
|
|
165
|
+
setattr(self, attr, self.__validate(getattr(self, factory), strict=self.__strict))
|
|
150
166
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
@staticmethod
|
|
168
|
+
def __validate[T: Any](val: T, strict: bool) -> T | None:
|
|
169
|
+
typeval = type(val)
|
|
170
|
+
allowed = get_args(AllowedTypes.__value__)
|
|
171
|
+
|
|
172
|
+
if typeval not in allowed:
|
|
173
|
+
if strict:
|
|
174
|
+
raise TypeError(f"{typeval} is not an allowed immutable type")
|
|
175
|
+
else:
|
|
176
|
+
warn(f"{typeval} is mutable, setting value to None", stacklevel=2)
|
|
177
|
+
return None
|
|
155
178
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def __post_init__(self) -> None:
|
|
159
|
-
for attr, factory in self.__deferred:
|
|
160
|
-
if factory not in self.__class__.__dict__:
|
|
161
|
-
raise AttributeError(f"property {factory} was not found in {self.__class__.__name__}")
|
|
162
|
-
if not isinstance(getattr(self.__class__, factory), property):
|
|
163
|
-
raise TypeError(f"method {factory} is not a property")
|
|
164
|
-
self.__log.debug(f"evaluated {attr} from property {factory}")
|
|
165
|
-
setattr(self, attr, self.__validate(getattr(self, factory), strict=self.__strict))
|
|
166
|
-
|
|
167
|
-
@staticmethod
|
|
168
|
-
def __validate[T: Any](val: T, strict: bool) -> T | None:
|
|
169
|
-
typeval = type(val)
|
|
170
|
-
allowed = get_args(AllowedTypes.__value__)
|
|
171
|
-
|
|
172
|
-
if typeval not in allowed:
|
|
173
|
-
if strict:
|
|
174
|
-
raise TypeError(f"{typeval} is not an allowed immutable type")
|
|
175
|
-
else:
|
|
176
|
-
warn(f"{typeval} is mutable, setting value to None", stacklevel=2)
|
|
177
|
-
return None
|
|
178
|
-
|
|
179
|
-
return val
|
|
179
|
+
return val
|
sotkalib/enum/mixins.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Literal, Self, overload
|
|
4
|
+
|
|
5
|
+
|
|
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()
|
|
10
|
+
|
|
11
|
+
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import http
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorSchema(BaseModel):
|
|
9
|
+
code: str | None = None
|
|
10
|
+
phrase: str | None = None
|
|
11
|
+
desc: str | None = None
|
|
12
|
+
ctx: Mapping[str, Any] | str | list[Any] | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseHTTPError(Exception):
|
|
16
|
+
def __init__(self, status_code: int, detail: str | None = None, headers: Mapping[str, str] | None = None) -> None:
|
|
17
|
+
if detail is None:
|
|
18
|
+
detail = http.HTTPStatus(status_code).phrase
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.detail = detail
|
|
21
|
+
self.headers = headers
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
return f"{self.status_code}: {self.detail}"
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
class_name = self.__class__.__name__
|
|
28
|
+
return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIError(BaseHTTPError):
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
status: http.HTTPStatus | int = http.HTTPStatus.BAD_REQUEST,
|
|
36
|
+
code: str | None = None,
|
|
37
|
+
desc: str | None = None,
|
|
38
|
+
ctx: Mapping[str, Any] | list[Any] | str | None = None,
|
|
39
|
+
):
|
|
40
|
+
if isinstance(status, int):
|
|
41
|
+
status = http.HTTPStatus(status)
|
|
42
|
+
|
|
43
|
+
self.status = status
|
|
44
|
+
self.phrase = status.phrase
|
|
45
|
+
self.code = code
|
|
46
|
+
self.desc = desc
|
|
47
|
+
self.ctx = ctx
|
|
48
|
+
|
|
49
|
+
self.schema = ErrorSchema(
|
|
50
|
+
code=self.code,
|
|
51
|
+
phrase=self.phrase,
|
|
52
|
+
desc=self.desc,
|
|
53
|
+
ctx=self.ctx,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
super().__init__(status_code=self.status.value, detail=self.schema.model_dump_json())
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ArgsIncludedError(Exception):
|
|
5
|
+
def __init__(self, *args, stack_depth: int = 2):
|
|
6
|
+
_args = args
|
|
7
|
+
stack_args_to_exc = []
|
|
8
|
+
frames = inspect.stack()[1:-1][::-1][:stack_depth]
|
|
9
|
+
for frame_info in frames:
|
|
10
|
+
frame = frame_info.frame
|
|
11
|
+
args, _, _, values = inspect.getargvalues(frame)
|
|
12
|
+
f_locals = frame.f_locals
|
|
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
|
+
)
|
|
21
|
+
super().__init__(*_args, *stack_args_to_exc)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from collections.abc import Callable, Coroutine
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .args_incl_error import ArgsIncludedError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def exception_handler[**P, R](
|
|
9
|
+
func: Callable[P, R],
|
|
10
|
+
stack_depth: int = 3,
|
|
11
|
+
) -> Callable[P, R]:
|
|
12
|
+
@wraps(func)
|
|
13
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
14
|
+
try:
|
|
15
|
+
return func(*args, **kwargs)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
raise ArgsIncludedError(*e.args, stack_depth=stack_depth) from e
|
|
18
|
+
|
|
19
|
+
return wrapper
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def aexception_handler[**P, R](
|
|
23
|
+
func: Callable[P, Coroutine[Any, Any, R]],
|
|
24
|
+
stack_depth: int = 7,
|
|
25
|
+
) -> Callable[P, Coroutine[Any, Any, R]]:
|
|
26
|
+
@wraps(func)
|
|
27
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
28
|
+
try:
|
|
29
|
+
return await func(*args, **kwargs)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
raise ArgsIncludedError(*e.args, stack_depth=stack_depth) from e
|
|
32
|
+
|
|
33
|
+
return wrapper
|
sotkalib/http/__init__.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
from .client_session import (
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
ClientSession,
|
|
3
|
+
ClientSettings,
|
|
4
|
+
ExceptionSettings,
|
|
5
|
+
Handler,
|
|
6
|
+
Middleware,
|
|
7
|
+
StatusSettings,
|
|
8
8
|
)
|
|
9
9
|
|
|
10
10
|
__all__ = (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
"ClientSession",
|
|
12
|
+
"ExceptionSettings",
|
|
13
|
+
"StatusSettings",
|
|
14
|
+
"ClientSettings",
|
|
15
|
+
"Handler",
|
|
16
|
+
"Middleware",
|
|
17
17
|
)
|
sotkalib/http/client_session.py
CHANGED
|
@@ -12,236 +12,236 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
|
12
12
|
from sotkalib.log import get_logger
|
|
13
13
|
|
|
14
14
|
try:
|
|
15
|
-
|
|
15
|
+
certifi = importlib.import_module("certifi")
|
|
16
16
|
except ImportError:
|
|
17
|
-
|
|
17
|
+
certifi = None
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
MAXIMUM_BACKOFF: float = 120
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class RunOutOfAttemptsError(Exception):
|
|
24
|
-
|
|
24
|
+
pass
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class StatusRetryError(Exception):
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
status: int
|
|
29
|
+
context: str
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
def __init__(self, status: int, context: str) -> None:
|
|
32
|
+
super().__init__(f"{status}: {context}")
|
|
33
|
+
self.status = status
|
|
34
|
+
self.context = context
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
class CriticalStatusError(Exception):
|
|
38
|
-
|
|
38
|
+
pass
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class StatusSettings(BaseModel):
|
|
42
|
-
|
|
42
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
to_raise: set[HTTPStatus] = Field(default={HTTPStatus.FORBIDDEN})
|
|
45
|
+
to_retry: set[HTTPStatus] = Field(default={HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.FORBIDDEN})
|
|
46
|
+
exc_to_raise: type[Exception] = Field(default=CriticalStatusError)
|
|
47
|
+
not_found_as_none: bool = Field(default=True)
|
|
48
|
+
unspecified: Literal["retry", "raise"] = Field(default="retry")
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
class ExceptionSettings(BaseModel):
|
|
52
|
-
|
|
52
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
to_raise: tuple[type[Exception]] = Field(
|
|
55
|
+
default=(
|
|
56
|
+
client_exceptions.ConnectionTimeoutError,
|
|
57
|
+
client_exceptions.ClientProxyConnectionError,
|
|
58
|
+
client_exceptions.ContentTypeError,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
to_retry: tuple[type[Exception]] = Field(
|
|
63
|
+
default=(
|
|
64
|
+
TimeoutError,
|
|
65
|
+
client_exceptions.ServerDisconnectedError,
|
|
66
|
+
client_exceptions.ClientConnectionResetError,
|
|
67
|
+
client_exceptions.ClientOSError,
|
|
68
|
+
client_exceptions.ClientHttpProxyError,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
exc_to_raise: type[Exception] | None = Field(default=None)
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
unspecified: Literal["retry", "raise"] = Field(default="retry")
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
class
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
class ClientSettings(BaseModel):
|
|
78
|
+
timeout: float = Field(default=5.0, gt=0)
|
|
79
|
+
base: float = Field(default=1.0, gt=0)
|
|
80
|
+
backoff: float = Field(default=2.0, gt=0)
|
|
81
|
+
maximum_retries: int = Field(default=3, ge=1)
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
useragent_factory: Callable[[], str] | None = Field(default=None)
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
status_settings: StatusSettings = Field(default_factory=StatusSettings)
|
|
86
|
+
exception_settings: ExceptionSettings = Field(default_factory=ExceptionSettings)
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
session_kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
89
|
+
use_cookies_from_response: bool = Field(default=False)
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
class Handler[T](Protocol):
|
|
93
|
-
|
|
93
|
+
async def __call__(self, *args: Any, **kwargs: Any) -> T: ...
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
type Middleware[T, R] = Callable[[Handler[T]], Handler[R]]
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def _make_ssl_context(disable_tls13: bool = False) -> ssl.SSLContext:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
class
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
100
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
101
|
+
ctx.load_default_certs()
|
|
102
|
+
|
|
103
|
+
if certifi:
|
|
104
|
+
ctx.load_verify_locations(certifi.where())
|
|
105
|
+
|
|
106
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
107
|
+
ctx.maximum_version = ssl.TLSVersion.TLSv1_2 if disable_tls13 else ssl.TLSVersion.TLSv1_3
|
|
108
|
+
|
|
109
|
+
ctx.set_ciphers(
|
|
110
|
+
"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:"
|
|
111
|
+
"TLS_CHACHA20_POLY1305_SHA256:"
|
|
112
|
+
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:"
|
|
113
|
+
"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
ctx.check_hostname = True
|
|
117
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
118
|
+
|
|
119
|
+
return ctx
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ClientSession[R = aiohttp.ClientResponse | None]:
|
|
123
|
+
config: ClientSettings
|
|
124
|
+
_session: aiohttp.ClientSession
|
|
125
|
+
_middlewares: list[Callable[[Handler[Any]], Handler[Any]]]
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
config: ClientSettings | None = None,
|
|
130
|
+
_middlewares: list[Callable[[Handler[Any]], Handler[Any]]] | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self.config = config if config is not None else ClientSettings()
|
|
133
|
+
self._session = None
|
|
134
|
+
self._middlewares = _middlewares or []
|
|
135
|
+
|
|
136
|
+
def use[NewR](self, mw: Middleware[R, NewR]) -> ClientSession[NewR]:
|
|
137
|
+
new_session: ClientSession[NewR] = ClientSession(
|
|
138
|
+
config=self.config,
|
|
139
|
+
_middlewares=[*self._middlewares, mw],
|
|
140
|
+
)
|
|
141
|
+
return new_session
|
|
142
|
+
|
|
143
|
+
async def __aenter__(self) -> Self:
|
|
144
|
+
ctx = _make_ssl_context(disable_tls13=False)
|
|
145
|
+
|
|
146
|
+
if self.config.session_kwargs.get("connector") is None:
|
|
147
|
+
self.config.session_kwargs["connector"] = aiohttp.TCPConnector(ssl=ctx)
|
|
148
|
+
if self.config.session_kwargs.get("trust_env") is None:
|
|
149
|
+
self.config.session_kwargs["trust_env"] = False
|
|
150
|
+
|
|
151
|
+
self._session = aiohttp.ClientSession(
|
|
152
|
+
timeout=aiohttp.ClientTimeout(total=self.config.timeout),
|
|
153
|
+
**self.config.session_kwargs,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
get_logger("http.client_session").debug(
|
|
157
|
+
f"RetryableClientSession initialized with timeout: {self.config.timeout}"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
163
|
+
if self._session:
|
|
164
|
+
await self._session.close()
|
|
165
|
+
|
|
166
|
+
async def _handle_statuses(self, response: aiohttp.ClientResponse) -> aiohttp.ClientResponse | None:
|
|
167
|
+
sc = response.status
|
|
168
|
+
if self.config.use_cookies_from_response:
|
|
169
|
+
self._session.cookie_jar.update_cookies(response.cookies)
|
|
170
|
+
if sc in self.config.status_settings.to_retry:
|
|
171
|
+
raise StatusRetryError(status=sc, context=(await response.text()))
|
|
172
|
+
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()}")
|
|
174
|
+
elif self.config.status_settings.not_found_as_none and sc == HTTPStatus.NOT_FOUND:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
def _get_make_request_func(self) -> Handler[R]:
|
|
180
|
+
async def _make_request(*args: Any, **kwargs: Any) -> aiohttp.ClientResponse | None:
|
|
181
|
+
return await self._handle_statuses(await self._session.request(*args, **kwargs))
|
|
182
|
+
|
|
183
|
+
handler: Handler[Any] = _make_request
|
|
184
|
+
for mw in reversed(self._middlewares):
|
|
185
|
+
handler = mw(handler)
|
|
186
|
+
|
|
187
|
+
return handler
|
|
188
|
+
|
|
189
|
+
async def _handle_request(
|
|
190
|
+
self,
|
|
191
|
+
method: str,
|
|
192
|
+
url: str,
|
|
193
|
+
make_request_func: Handler[R],
|
|
194
|
+
**kw,
|
|
195
|
+
) -> R:
|
|
196
|
+
kw_for_request = kw.copy()
|
|
197
|
+
if self.config.useragent_factory is not None:
|
|
198
|
+
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)
|
|
201
|
+
|
|
202
|
+
async def _handle_retry(self, attempt: int, e: Exception, _: str, __: str, **___) -> None:
|
|
203
|
+
if attempt == self.config.maximum_retries:
|
|
204
|
+
raise RunOutOfAttemptsError(f"failed after {self.config.maximum_retries} retries: {type(e)} {e}") from e
|
|
205
|
+
|
|
206
|
+
await asyncio.sleep(self.config.base * min(MAXIMUM_BACKOFF, self.config.backoff**attempt))
|
|
207
|
+
|
|
208
|
+
async def _handle_to_raise(self, e: Exception, url: str, method: str, **kw) -> None:
|
|
209
|
+
if self.config.exception_settings.exc_to_raise is None:
|
|
210
|
+
raise e
|
|
211
|
+
|
|
212
|
+
raise self.config.exception_settings.exc_to_raise(f"EXC: {type(e)} {e}; {url} {method} {kw}") from e
|
|
213
|
+
|
|
214
|
+
async def _handle_exception(self, e: Exception, url: str, method: str, attempt: int, **kw) -> None:
|
|
215
|
+
if self.config.exception_settings.unspecified == "raise":
|
|
216
|
+
raise e
|
|
217
|
+
|
|
218
|
+
await self._handle_retry(attempt, e, url, method, **kw)
|
|
219
|
+
|
|
220
|
+
async def _request_with_retry(self, method: str, url: str, **kw) -> R:
|
|
221
|
+
_make_request = self._get_make_request_func()
|
|
222
|
+
for attempt in range(self.config.maximum_retries + 1):
|
|
223
|
+
try:
|
|
224
|
+
return await self._handle_request(method, url, _make_request, **kw)
|
|
225
|
+
except self.config.exception_settings.to_retry + (StatusRetryError,) as e:
|
|
226
|
+
await self._handle_retry(attempt, e, url, method, **kw)
|
|
227
|
+
except self.config.exception_settings.to_raise as e:
|
|
228
|
+
await self._handle_to_raise(e, url, method, **kw)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
await self._handle_exception(e, url, method, attempt, **kw)
|
|
231
|
+
|
|
232
|
+
return await _make_request()
|
|
233
|
+
|
|
234
|
+
async def get(self, url: str, **kwargs: Any) -> R:
|
|
235
|
+
return await self._request_with_retry("GET", url, **kwargs)
|
|
236
|
+
|
|
237
|
+
async def post(self, url: str, **kwargs: Any) -> R:
|
|
238
|
+
return await self._request_with_retry("POST", url, **kwargs)
|
|
239
|
+
|
|
240
|
+
async def put(self, url: str, **kwargs: Any) -> R:
|
|
241
|
+
return await self._request_with_retry("PUT", url, **kwargs)
|
|
242
|
+
|
|
243
|
+
async def delete(self, url: str, **kwargs: Any) -> R:
|
|
244
|
+
return await self._request_with_retry("DELETE", url, **kwargs)
|
|
245
|
+
|
|
246
|
+
async def patch(self, url: str, **kwargs: Any) -> R:
|
|
247
|
+
return await self._request_with_retry("PATCH", url, **kwargs)
|
|
File without changes
|
sotkalib/redis/client.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import AbstractAsyncContextManager
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from redis.asyncio import ConnectionPool, Redis
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedisPoolSettings(BaseModel):
|
|
9
|
+
uri: str = Field(default="redis://localhost:6379")
|
|
10
|
+
db_num: int = Field(default=4)
|
|
11
|
+
max_connections: int = Field(default=50)
|
|
12
|
+
socket_timeout: float = Field(default=5)
|
|
13
|
+
socket_connect_timeout: float = Field(default=5)
|
|
14
|
+
retry_on_timeout: bool = Field(default=True)
|
|
15
|
+
health_check_interval: float = Field(default=30)
|
|
16
|
+
decode_responses: bool = Field(default=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RedisPool(AbstractAsyncContextManager):
|
|
20
|
+
def __init__(self, settings: RedisPoolSettings | None = None):
|
|
21
|
+
if not settings:
|
|
22
|
+
settings = RedisPoolSettings()
|
|
23
|
+
|
|
24
|
+
self._pool = ConnectionPool.from_url(
|
|
25
|
+
settings.uri + "/" + str(settings.db_num),
|
|
26
|
+
**settings.model_dump(exclude={"uri", "db_num"}),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
self._usage_counter = 0
|
|
30
|
+
self._usage_lock = asyncio.Lock()
|
|
31
|
+
|
|
32
|
+
async def __aenter__(self: RedisPool) -> Redis:
|
|
33
|
+
try:
|
|
34
|
+
return Redis(connection_pool=self._pool)
|
|
35
|
+
except Exception:
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
async def __aexit__(self, exc_type, exc_value, traceback): ...
|
sotkalib/redis/lock.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from time import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from redis.asyncio import Redis
|
|
8
|
+
|
|
9
|
+
from sotkalib.redis.client import RedisPool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ContextLockError(Exception):
|
|
13
|
+
def __init__(self, *args, can_retry: bool = True):
|
|
14
|
+
super().__init__(*args)
|
|
15
|
+
self.can_retry = can_retry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _try_acquire(rc: Redis, key_to_lock: str, acquire_timeout: int) -> bool:
|
|
19
|
+
"""Atomically acquire a lock using SET NX (set-if-not-exists)."""
|
|
20
|
+
return bool(await rc.set(key_to_lock, "acquired", nx=True, ex=acquire_timeout))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def wait_till_lock_free(
|
|
24
|
+
client: Redis,
|
|
25
|
+
key_to_lock: str,
|
|
26
|
+
lock_timeout: float = 10.0,
|
|
27
|
+
base_delay: float = 0.1,
|
|
28
|
+
max_delay: float = 5.0,
|
|
29
|
+
) -> 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
|
+
start = time()
|
|
40
|
+
attempt = 0
|
|
41
|
+
while await client.get(key_to_lock) is not None:
|
|
42
|
+
if (time() - start) > lock_timeout:
|
|
43
|
+
raise ContextLockError(
|
|
44
|
+
f"{key_to_lock} lock already acquired, timeout after {lock_timeout}s",
|
|
45
|
+
can_retry=False,
|
|
46
|
+
)
|
|
47
|
+
delay = min(base_delay * (2**attempt), max_delay)
|
|
48
|
+
await asyncio.sleep(delay)
|
|
49
|
+
attempt += 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@asynccontextmanager
|
|
53
|
+
async def redis_context_lock(
|
|
54
|
+
client: Redis | RedisPool,
|
|
55
|
+
key_to_lock: str,
|
|
56
|
+
can_retry_if_lock_catched: bool = True,
|
|
57
|
+
wait_for_lock: bool = False,
|
|
58
|
+
wait_timeout: float = 60.0,
|
|
59
|
+
acquire_timeout: int = 5,
|
|
60
|
+
args_to_lock_exception: list[Any] | None = None,
|
|
61
|
+
) -> AsyncGenerator[None]:
|
|
62
|
+
"""
|
|
63
|
+
Acquire a Redis lock atomically using SET NX.
|
|
64
|
+
|
|
65
|
+
:param key_to_lock: Redis key for the lock
|
|
66
|
+
:param can_retry_if_lock_catched: Whether task should retry if lock is taken (only used if wait_for_lock=False)
|
|
67
|
+
:param wait_for_lock: If True, wait for lock to be free instead of immediately failing
|
|
68
|
+
:param wait_timeout: Maximum time to wait for lock in seconds (only used if wait_for_lock=True)
|
|
69
|
+
"""
|
|
70
|
+
if args_to_lock_exception is None:
|
|
71
|
+
args_to_lock_exception = []
|
|
72
|
+
|
|
73
|
+
if wait_for_lock:
|
|
74
|
+
async with client as rc:
|
|
75
|
+
await wait_till_lock_free(key_to_lock=key_to_lock, client=rc, lock_timeout=wait_timeout)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
async with client as rc:
|
|
79
|
+
acquired = await _try_acquire(rc, key_to_lock, acquire_timeout)
|
|
80
|
+
if not acquired:
|
|
81
|
+
raise ContextLockError(
|
|
82
|
+
f"{key_to_lock} lock already acquired",
|
|
83
|
+
*args_to_lock_exception,
|
|
84
|
+
can_retry=can_retry_if_lock_catched,
|
|
85
|
+
)
|
|
86
|
+
yield
|
|
87
|
+
finally:
|
|
88
|
+
async with client as rc:
|
|
89
|
+
await rc.delete(key_to_lock)
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sotkalib
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary:
|
|
5
5
|
Author: alexey
|
|
6
6
|
Author-email: alexey <me@pyrorhythm.dev>
|
|
@@ -8,6 +8,7 @@ Requires-Dist: aiohttp>=3.13.3
|
|
|
8
8
|
Requires-Dist: dotenv>=0.9.9
|
|
9
9
|
Requires-Dist: loguru>=0.7.3
|
|
10
10
|
Requires-Dist: pydantic>=2.12.5
|
|
11
|
+
Requires-Dist: redis>=7.1.0
|
|
11
12
|
Requires-Python: >=3.13
|
|
12
13
|
Description-Content-Type: text/markdown
|
|
13
14
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
sotkalib/__init__.py,sha256=ebrSgK8GtSh2aZNdP3vKA6A4b1iHLFNWzpgznyOOn8Q,48
|
|
2
|
+
sotkalib/config/__init__.py,sha256=CSjn02NCnBPO14QOg4OzKI-lTxyKoBxQ4ODsiWamlIM,102
|
|
3
|
+
sotkalib/config/field.py,sha256=UaIiZBa4TUOB-akEqIvFJKxj2QjjsTaAjL2r163N-V4,790
|
|
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=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
sotkalib/exceptions/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
sotkalib/exceptions/api/exc.py,sha256=OtY0O91sP4YVpQcrj4yDKEcgZvMF7HoMRd4ZDm3WPKc,1409
|
|
10
|
+
sotkalib/exceptions/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
sotkalib/exceptions/handlers/args_incl_error.py,sha256=QwM3k7H8vxUUMr4m3OSHcU7ad96H9hZcS-S8pwyd__w,562
|
|
12
|
+
sotkalib/exceptions/handlers/core.py,sha256=5fhusoxBhUz59TaVWobplBvD-sbkZKBnmmu-fcSyRk4,836
|
|
13
|
+
sotkalib/http/__init__.py,sha256=l5mJEW9dzOyHmfOAoZnuhPBMVO7Lyrzqr-dthyUHgRs,245
|
|
14
|
+
sotkalib/http/client_session.py,sha256=lmp8ljbAhitz-oVB3kNlVwQCn8WVrcv1vhgxndxB6HQ,7998
|
|
15
|
+
sotkalib/log/__init__.py,sha256=xrBx--c8QU5xkb3_n61LuqF8ySUaxlQkHCxHyH_D8aE,58
|
|
16
|
+
sotkalib/log/factory.py,sha256=N8SZNvCdBLmsM0ES38x2BxZbJD1GgdMHtJ8h8lZjv7A,806
|
|
17
|
+
sotkalib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
sotkalib/redis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
sotkalib/redis/client.py,sha256=0TWe-gYqFiuCjqimCQrVrnTHSM0EROIoJL36M3qwOtQ,1118
|
|
20
|
+
sotkalib/redis/lock.py,sha256=6nw8uqRKs9bkvUZQhjZhIkhE1z1Bq3ykHAk7SCAQrsk,2687
|
|
21
|
+
sotkalib/sqla/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
sotkalib-0.0.3.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
|
|
23
|
+
sotkalib-0.0.3.dist-info/METADATA,sha256=fuDtxf0vfKeyg-gAgZLq2OUFkW4efuewaElvD7bp7QM,337
|
|
24
|
+
sotkalib-0.0.3.dist-info/RECORD,,
|
sotkalib-0.0.2.dist-info/RECORD
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
sotkalib/__init__.py,sha256=ebrSgK8GtSh2aZNdP3vKA6A4b1iHLFNWzpgznyOOn8Q,48
|
|
2
|
-
sotkalib/config/__init__.py,sha256=CSjn02NCnBPO14QOg4OzKI-lTxyKoBxQ4ODsiWamlIM,102
|
|
3
|
-
sotkalib/config/field.py,sha256=UaIiZBa4TUOB-akEqIvFJKxj2QjjsTaAjL2r163N-V4,790
|
|
4
|
-
sotkalib/config/struct.py,sha256=kUabPNOv9uA8d0SzBOfr-w08YOYKy-D8LN0sof_ZQ5Q,6453
|
|
5
|
-
sotkalib/http/__init__.py,sha256=gkuf1ZM7qXRmnm-yDUMmxxbCuu7bLek_PL3UmMQBtlM,317
|
|
6
|
-
sotkalib/http/client_session.py,sha256=9AyOLFQFXf_lkirJcfp0NBIzx5owdRtDJZM-PEcCKtI,8952
|
|
7
|
-
sotkalib/log/__init__.py,sha256=xrBx--c8QU5xkb3_n61LuqF8ySUaxlQkHCxHyH_D8aE,58
|
|
8
|
-
sotkalib/log/factory.py,sha256=N8SZNvCdBLmsM0ES38x2BxZbJD1GgdMHtJ8h8lZjv7A,806
|
|
9
|
-
sotkalib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
sotkalib-0.0.2.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
|
|
11
|
-
sotkalib-0.0.2.dist-info/METADATA,sha256=TOF1FK1cVfMXt1kXc4gVjFsFsGj8hi5uMdQkude7sMQ,309
|
|
12
|
-
sotkalib-0.0.2.dist-info/RECORD,,
|
|
File without changes
|