sotkalib 0.0.2__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.
- sotkalib-0.0.2/PKG-INFO +14 -0
- sotkalib-0.0.2/README.md +1 -0
- sotkalib-0.0.2/pyproject.toml +116 -0
- sotkalib-0.0.2/src/sotkalib/__init__.py +5 -0
- sotkalib-0.0.2/src/sotkalib/config/__init__.py +6 -0
- sotkalib-0.0.2/src/sotkalib/config/field.py +27 -0
- sotkalib-0.0.2/src/sotkalib/config/struct.py +179 -0
- sotkalib-0.0.2/src/sotkalib/http/__init__.py +17 -0
- sotkalib-0.0.2/src/sotkalib/http/client_session.py +247 -0
- sotkalib-0.0.2/src/sotkalib/log/__init__.py +3 -0
- sotkalib-0.0.2/src/sotkalib/log/factory.py +29 -0
- sotkalib-0.0.2/src/sotkalib/py.typed +0 -0
sotkalib-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sotkalib
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary:
|
|
5
|
+
Author: alexey
|
|
6
|
+
Author-email: alexey <me@pyrorhythm.dev>
|
|
7
|
+
Requires-Dist: aiohttp>=3.13.3
|
|
8
|
+
Requires-Dist: dotenv>=0.9.9
|
|
9
|
+
Requires-Dist: loguru>=0.7.3
|
|
10
|
+
Requires-Dist: pydantic>=2.12.5
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ...
|
sotkalib-0.0.2/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ...
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sotkalib"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = [
|
|
6
|
+
{ email = "me@pyrorhythm.dev", name = "alexey" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"aiohttp>=3.13.3",
|
|
12
|
+
"dotenv>=0.9.9",
|
|
13
|
+
"loguru>=0.7.3",
|
|
14
|
+
"pydantic>=2.12.5",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
[[tool.uv.index]]
|
|
19
|
+
name = "pypi"
|
|
20
|
+
url = "https://pypi.org/simple"
|
|
21
|
+
publish-url = "https://upload.pypi.org/legacy/"
|
|
22
|
+
explicit = true
|
|
23
|
+
|
|
24
|
+
[[tool.uv.index]]
|
|
25
|
+
name = "testpypi"
|
|
26
|
+
url = "https://test.pypi.org/simple/"
|
|
27
|
+
publish-url = "https://test.pypi.org/legacy/"
|
|
28
|
+
explicit = true
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.9.10,<0.10.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"mypy>=1.19.0",
|
|
37
|
+
"pyrefly>=0.45.2",
|
|
38
|
+
"pytest>=9.0.2",
|
|
39
|
+
"ruff>=0.14.9",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 120
|
|
44
|
+
target-version = "py314"
|
|
45
|
+
src = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.format]
|
|
48
|
+
docstring-code-format = true
|
|
49
|
+
line-ending = "lf"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
|
53
|
+
fixable = ["ALL"]
|
|
54
|
+
unfixable = []
|
|
55
|
+
select = [
|
|
56
|
+
"E",
|
|
57
|
+
"F",
|
|
58
|
+
"I",
|
|
59
|
+
"N",
|
|
60
|
+
"UP",
|
|
61
|
+
"ASYNC",
|
|
62
|
+
"S",
|
|
63
|
+
"B",
|
|
64
|
+
"A",
|
|
65
|
+
"C4",
|
|
66
|
+
"G",
|
|
67
|
+
"PIE",
|
|
68
|
+
"Q",
|
|
69
|
+
"SIM",
|
|
70
|
+
"ARG",
|
|
71
|
+
"PTH",
|
|
72
|
+
"TD",
|
|
73
|
+
"PL",
|
|
74
|
+
"PERF"
|
|
75
|
+
]
|
|
76
|
+
ignore = ["PERF401"]
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.isort]
|
|
79
|
+
known-first-party = [
|
|
80
|
+
"common",
|
|
81
|
+
"db",
|
|
82
|
+
"libs",
|
|
83
|
+
"handlers",
|
|
84
|
+
"backend_common",
|
|
85
|
+
"actions"
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.pep8-naming]
|
|
89
|
+
classmethod-decorators = [
|
|
90
|
+
"pydantic.root_validator",
|
|
91
|
+
"pydantic.validator",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint.per-file-ignores]
|
|
95
|
+
"__init__.py" = ["F403"]
|
|
96
|
+
"**/{tests}/*" = ["S101", "PLR2004"]
|
|
97
|
+
"**/{tests}/*/test_*" = ["ANN201"]
|
|
98
|
+
|
|
99
|
+
[tool.ruff.lint.pylint]
|
|
100
|
+
max-args = 9
|
|
101
|
+
|
|
102
|
+
[tool.pyrefly]
|
|
103
|
+
python_version = "3.14.0"
|
|
104
|
+
|
|
105
|
+
search-path = ["src"]
|
|
106
|
+
project-includes = ["src"]
|
|
107
|
+
project-excludes = ["**/.[!/.]*", "**/tests"]
|
|
108
|
+
site-package-path = [".venv/lib/python3.14/site-packages"]
|
|
109
|
+
python-interpreter-path = ".venv/bin/python"
|
|
110
|
+
untyped-def-behavior = "check-and-infer-return-any"
|
|
111
|
+
ignore-errors-in-generated-code = true
|
|
112
|
+
disable-search-path-heuristics = true
|
|
113
|
+
|
|
114
|
+
[tool.pyrefly.errors]
|
|
115
|
+
bad-assignment = false
|
|
116
|
+
invalid-argument = false
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from os import PathLike, getenv
|
|
6
|
+
from types import NoneType, UnionType
|
|
7
|
+
from typing import TYPE_CHECKING, Any, get_args
|
|
8
|
+
from warnings import warn
|
|
9
|
+
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
from sotkalib.log import get_logger
|
|
13
|
+
|
|
14
|
+
from .field import AllowedTypes, SettingsField
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from logging import (
|
|
18
|
+
Logger as StdLogger,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from loguru import Logger as LoguruLogger
|
|
22
|
+
|
|
23
|
+
type _loggers = StdLogger | LoguruLogger
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AppSettings:
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
Base class for reading typed settings from environment variables.
|
|
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.
|
|
35
|
+
|
|
36
|
+
**Notes:**
|
|
37
|
+
|
|
38
|
+
Only immutable primitive types are allowed: int, float, complex, str, bool, None.
|
|
39
|
+
|
|
40
|
+
If explicit_format is True, attribute names must be UPPER_SNAKE_CASE.
|
|
41
|
+
|
|
42
|
+
**Example:**
|
|
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"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
>>> settings = MySettings()
|
|
57
|
+
|
|
58
|
+
"""
|
|
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
|
+
"""
|
|
68
|
+
|
|
69
|
+
Initialize AppSettings and resolve annotated fields.
|
|
70
|
+
|
|
71
|
+
**Parameters:**
|
|
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.
|
|
78
|
+
|
|
79
|
+
**Raises:**
|
|
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.
|
|
84
|
+
|
|
85
|
+
"""
|
|
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
|
|
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)
|
|
100
|
+
|
|
101
|
+
load_dotenv(dotenv_path=dotenv_path)
|
|
102
|
+
|
|
103
|
+
_log = get_logger("utilities.appsettings") if logger is None else logger
|
|
104
|
+
self.__log = _log
|
|
105
|
+
|
|
106
|
+
self.__strict = strict
|
|
107
|
+
|
|
108
|
+
cls_annotations = self.__class__.__annotations__
|
|
109
|
+
cls_dict = self.__class__.__dict__
|
|
110
|
+
|
|
111
|
+
settings_fields: dict[str, SettingsField] = {
|
|
112
|
+
attr: val for attr, val in cls_dict.items() if isinstance(val, SettingsField)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
self.__deferred = []
|
|
116
|
+
|
|
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")
|
|
120
|
+
|
|
121
|
+
annotated = cls_annotations.get(attr, NoneType)
|
|
122
|
+
string_value = getenv(attr, None)
|
|
123
|
+
|
|
124
|
+
if string_value is None:
|
|
125
|
+
self.__validate_empty_string_value(attr, settings_field)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
typed_value = evaluate_var(annotated, string_value)
|
|
129
|
+
|
|
130
|
+
setattr(self, attr, self.__validate(typed_value, strict=self.__strict))
|
|
131
|
+
_log.debug(f"evaluated {attr} from environment")
|
|
132
|
+
|
|
133
|
+
self.__post_init__()
|
|
134
|
+
|
|
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
|
|
140
|
+
|
|
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
|
|
145
|
+
|
|
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
|
|
150
|
+
|
|
151
|
+
if settings_field.nullable:
|
|
152
|
+
setattr(self, attr, None)
|
|
153
|
+
self.__log.debug(f"evaluated {attr} as None (nullable)")
|
|
154
|
+
return
|
|
155
|
+
|
|
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
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .client_session import (
|
|
2
|
+
ExceptionSettings,
|
|
3
|
+
Handler,
|
|
4
|
+
Middleware,
|
|
5
|
+
RetryableClientSession,
|
|
6
|
+
RetryableClientSettings,
|
|
7
|
+
StatusSettings,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = (
|
|
11
|
+
"RetryableClientSession",
|
|
12
|
+
"ExceptionSettings",
|
|
13
|
+
"StatusSettings",
|
|
14
|
+
"RetryableClientSettings",
|
|
15
|
+
"Handler",
|
|
16
|
+
"Middleware",
|
|
17
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib
|
|
3
|
+
import ssl
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import Any, Literal, Protocol, Self
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from aiohttp import client_exceptions
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from sotkalib.log import get_logger
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
certifi = importlib.import_module("certifi")
|
|
16
|
+
except ImportError:
|
|
17
|
+
certifi = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
MAXIMUM_BACKOFF: float = 120
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunOutOfAttemptsError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StatusRetryError(Exception):
|
|
28
|
+
status: int
|
|
29
|
+
context: str
|
|
30
|
+
|
|
31
|
+
def __init__(self, status: int, context: str) -> None:
|
|
32
|
+
super().__init__(f"{status}: {context}")
|
|
33
|
+
self.status = status
|
|
34
|
+
self.context = context
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CriticalStatusError(Exception):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StatusSettings(BaseModel):
|
|
42
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
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")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExceptionSettings(BaseModel):
|
|
52
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
53
|
+
|
|
54
|
+
to_raise: tuple[type[Exception]] = Field(
|
|
55
|
+
default=(
|
|
56
|
+
client_exceptions.ConnectionTimeoutError,
|
|
57
|
+
client_exceptions.ClientProxyConnectionError,
|
|
58
|
+
client_exceptions.ContentTypeError,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
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
|
+
)
|
|
71
|
+
|
|
72
|
+
exc_to_raise: type[Exception] | None = Field(default=None)
|
|
73
|
+
|
|
74
|
+
unspecified: Literal["retry", "raise"] = Field(default="retry")
|
|
75
|
+
|
|
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)
|
|
82
|
+
|
|
83
|
+
useragent_factory: Callable[[], str] | None = Field(default=None)
|
|
84
|
+
|
|
85
|
+
status_settings: StatusSettings = Field(default_factory=StatusSettings)
|
|
86
|
+
exception_settings: ExceptionSettings = Field(default_factory=ExceptionSettings)
|
|
87
|
+
|
|
88
|
+
session_kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
89
|
+
use_cookies_from_response: bool = Field(default=False)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Handler[T](Protocol):
|
|
93
|
+
async def __call__(self, *args: Any, **kwargs: Any) -> T: ...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
type Middleware[T, R] = Callable[[Handler[T]], Handler[R]]
|
|
97
|
+
|
|
98
|
+
|
|
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)
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|