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 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
- from logging import (
18
- Logger as StdLogger,
19
- )
17
+ from logging import (
18
+ Logger as StdLogger,
19
+ )
20
20
 
21
- from loguru import Logger as LoguruLogger
21
+ from loguru import Logger as LoguruLogger
22
22
 
23
- type _loggers = StdLogger | LoguruLogger
23
+ type _loggers = StdLogger | LoguruLogger
24
24
 
25
25
 
26
26
  @dataclass
27
27
  class AppSettings:
28
- """
28
+ """
29
29
 
30
- Base class for reading typed settings from environment variables.
30
+ Base class for reading typed settings from environment variables.
31
31
 
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.
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
- **Notes:**
36
+ **Notes:**
37
37
 
38
- Only immutable primitive types are allowed: int, float, complex, str, bool, None.
38
+ Only immutable primitive types are allowed: int, float, complex, str, bool, None.
39
39
 
40
- If explicit_format is True, attribute names must be UPPER_SNAKE_CASE.
40
+ If explicit_format is True, attribute names must be UPPER_SNAKE_CASE.
41
41
 
42
- **Example:**
42
+ **Example:**
43
43
 
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"
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
- >>> settings = MySettings()
56
+ >>> settings = MySettings()
57
57
 
58
- """
58
+ """
59
59
 
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
- """
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
- Initialize AppSettings and resolve annotated fields.
69
+ Initialize AppSettings and resolve annotated fields.
70
70
 
71
- **Parameters:**
71
+ **Parameters:**
72
72
 
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.
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
- **Raises:**
79
+ **Raises:**
80
80
 
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.
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
- 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
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
- 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)
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
- load_dotenv(dotenv_path=dotenv_path)
101
+ load_dotenv(dotenv_path=dotenv_path)
102
102
 
103
- _log = get_logger("utilities.appsettings") if logger is None else logger
104
- self.__log = _log
103
+ self.__log = get_logger("utilities.appsettings") if logger is None else logger
104
+ self.__deferred = []
105
+ self.__strict = strict
105
106
 
106
- self.__strict = strict
107
+ cls_annotations = self.__class__.__annotations__
108
+ cls_dict = self.__class__.__dict__
107
109
 
108
- cls_annotations = self.__class__.__annotations__
109
- cls_dict = self.__class__.__dict__
110
+ settings_fields: dict[str, SettingsField] = {
111
+ attr: val for attr, val in cls_dict.items() if isinstance(val, SettingsField)
112
+ }
110
113
 
111
- settings_fields: dict[str, SettingsField] = {
112
- attr: val for attr, val in cls_dict.items() if isinstance(val, SettingsField)
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
- self.__deferred = []
118
+ annotated = cls_annotations.get(attr, NoneType)
119
+ string_value = getenv(attr, None)
116
120
 
117
- for attr, settings_field in settings_fields.items():
118
- if explicit_format and not re.fullmatch(r"[A-Z][A-Z0-9_]*", attr):
119
- raise AttributeError("AppSettings attributes should contain only capital letters and underscores")
121
+ if string_value is None:
122
+ self.__validate_empty_string_value(attr, settings_field)
123
+ continue
120
124
 
121
- annotated = cls_annotations.get(attr, NoneType)
122
- string_value = getenv(attr, None)
125
+ typed_value = evaluate_var(annotated, string_value)
123
126
 
124
- if string_value is None:
125
- self.__validate_empty_string_value(attr, settings_field)
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
- typed_value = evaluate_var(annotated, string_value)
130
+ self.__post_init__()
129
131
 
130
- setattr(self, attr, self.__validate(typed_value, strict=self.__strict))
131
- _log.debug(f"evaluated {attr} from environment")
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
- self.__post_init__()
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
- def __validate_empty_string_value(self, attr: str, settings_field: SettingsField) -> None:
136
- if settings_field.default is not None:
137
- setattr(self, attr, self.__validate(settings_field.default, strict=self.__strict))
138
- self.__log.debug(f"evaluated {attr} from default")
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
- if settings_field.factory is not None and isinstance(settings_field.factory, str):
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
- if callable(settings_field.factory):
147
- setattr(self, attr, self.__validate(settings_field.factory(), strict=self.__strict))
148
- self.__log.debug(f"evaluated {attr} from factory")
149
- return
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
- if settings_field.nullable:
152
- setattr(self, attr, None)
153
- self.__log.debug(f"evaluated {attr} as None (nullable)")
154
- return
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
- raise ValueError(f"reqd field {attr} was not found in .env")
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
@@ -0,0 +1,3 @@
1
+ from .mixins import UppercaseStrEnumMixin, ValidatorStrEnumMixin
2
+
3
+ __all__ = ["ValidatorStrEnumMixin", "UppercaseStrEnumMixin"]
@@ -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
- ExceptionSettings,
3
- Handler,
4
- Middleware,
5
- RetryableClientSession,
6
- RetryableClientSettings,
7
- StatusSettings,
2
+ ClientSession,
3
+ ClientSettings,
4
+ ExceptionSettings,
5
+ Handler,
6
+ Middleware,
7
+ StatusSettings,
8
8
  )
9
9
 
10
10
  __all__ = (
11
- "RetryableClientSession",
12
- "ExceptionSettings",
13
- "StatusSettings",
14
- "RetryableClientSettings",
15
- "Handler",
16
- "Middleware",
11
+ "ClientSession",
12
+ "ExceptionSettings",
13
+ "StatusSettings",
14
+ "ClientSettings",
15
+ "Handler",
16
+ "Middleware",
17
17
  )
@@ -12,236 +12,236 @@ from pydantic import BaseModel, ConfigDict, Field
12
12
  from sotkalib.log import get_logger
13
13
 
14
14
  try:
15
- certifi = importlib.import_module("certifi")
15
+ certifi = importlib.import_module("certifi")
16
16
  except ImportError:
17
- certifi = None
17
+ certifi = None
18
18
 
19
19
 
20
20
  MAXIMUM_BACKOFF: float = 120
21
21
 
22
22
 
23
23
  class RunOutOfAttemptsError(Exception):
24
- pass
24
+ pass
25
25
 
26
26
 
27
27
  class StatusRetryError(Exception):
28
- status: int
29
- context: str
28
+ status: int
29
+ context: str
30
30
 
31
- def __init__(self, status: int, context: str) -> None:
32
- super().__init__(f"{status}: {context}")
33
- self.status = status
34
- self.context = context
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
- pass
38
+ pass
39
39
 
40
40
 
41
41
  class StatusSettings(BaseModel):
42
- model_config = ConfigDict(arbitrary_types_allowed=True)
42
+ model_config = ConfigDict(arbitrary_types_allowed=True)
43
43
 
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")
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
- model_config = ConfigDict(arbitrary_types_allowed=True)
52
+ model_config = ConfigDict(arbitrary_types_allowed=True)
53
53
 
54
- to_raise: tuple[type[Exception]] = Field(
55
- default=(
56
- client_exceptions.ConnectionTimeoutError,
57
- client_exceptions.ClientProxyConnectionError,
58
- client_exceptions.ContentTypeError,
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
- 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
- )
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
- exc_to_raise: type[Exception] | None = Field(default=None)
72
+ exc_to_raise: type[Exception] | None = Field(default=None)
73
73
 
74
- unspecified: Literal["retry", "raise"] = Field(default="retry")
74
+ unspecified: Literal["retry", "raise"] = Field(default="retry")
75
75
 
76
76
 
77
- class RetryableClientSettings(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)
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
- useragent_factory: Callable[[], str] | None = Field(default=None)
83
+ useragent_factory: Callable[[], str] | None = Field(default=None)
84
84
 
85
- status_settings: StatusSettings = Field(default_factory=StatusSettings)
86
- exception_settings: ExceptionSettings = Field(default_factory=ExceptionSettings)
85
+ status_settings: StatusSettings = Field(default_factory=StatusSettings)
86
+ exception_settings: ExceptionSettings = Field(default_factory=ExceptionSettings)
87
87
 
88
- session_kwargs: dict[str, Any] = Field(default_factory=dict)
89
- use_cookies_from_response: bool = Field(default=False)
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
- async def __call__(self, *args: Any, **kwargs: Any) -> T: ...
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
- 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 RetryableClientSession[R = aiohttp.ClientResponse | None]:
123
- config: RetryableClientSettings
124
- _session: aiohttp.ClientSession
125
- _middlewares: list[Callable[[Handler[Any]], Handler[Any]]]
126
-
127
- def __init__(
128
- self,
129
- config: RetryableClientSettings | None = None,
130
- _middlewares: list[Callable[[Handler[Any]], Handler[Any]]] | None = None,
131
- ) -> None:
132
- self.config = config if config is not None else RetryableClientSettings()
133
- self._session = None
134
- self._middlewares = _middlewares or []
135
-
136
- def use[NewR](self, mw: Middleware[R, NewR]) -> RetryableClientSession[NewR]:
137
- new_session: RetryableClientSession[NewR] = RetryableClientSession(
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)
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
@@ -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.2
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,,
@@ -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,,