python-corekit 0.1.0__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.
- corekit/__init__.py +0 -0
- corekit/api/__init__.py +9 -0
- corekit/api/handler.py +76 -0
- corekit/api/responses.py +40 -0
- corekit/api/routers.py +115 -0
- corekit/concurrency/__init__.py +9 -0
- corekit/concurrency/decorators.py +72 -0
- corekit/concurrency/thread_local.py +99 -0
- corekit/concurrency/worker.py +65 -0
- corekit/config/__init__.py +47 -0
- corekit/config/loader.py +153 -0
- corekit/config/settings.py +161 -0
- corekit/config/sources.py +125 -0
- corekit/connections/__init__.py +31 -0
- corekit/connections/connectable.py +212 -0
- corekit/connections/decorators.py +92 -0
- corekit/connections/redis/__init__.py +7 -0
- corekit/connections/redis/connection.py +239 -0
- corekit/connections/registry.py +80 -0
- corekit/connections/sql/__init__.py +10 -0
- corekit/connections/sql/connection.py +342 -0
- corekit/connections/sql/fields/__init__.py +7 -0
- corekit/connections/sql/fields/jsonb.py +67 -0
- corekit/connections/sql/migration/__init__.py +57 -0
- corekit/connections/sql/migration/base.py +40 -0
- corekit/connections/sql/migration/operations.py +416 -0
- corekit/connections/sql/migration/registry.py +166 -0
- corekit/connections/sql/migration/table.py +27 -0
- corekit/connections/sql/query.py +68 -0
- corekit/connections/sql/table.py +96 -0
- corekit/constants.py +45 -0
- corekit/crypto/__init__.py +1 -0
- corekit/crypto/constants.py +7 -0
- corekit/crypto/enum.py +11 -0
- corekit/crypto/hasher.py +89 -0
- corekit/data/__init__.py +81 -0
- corekit/data/dataset.py +340 -0
- corekit/data/expressions/__init__.py +46 -0
- corekit/data/expressions/comparison.py +252 -0
- corekit/data/expressions/expression.py +98 -0
- corekit/data/record.py +147 -0
- corekit/data/stats.py +157 -0
- corekit/decorators/__init__.py +2 -0
- corekit/decorators/exception_handling.py +43 -0
- corekit/decorators/warnings.py +35 -0
- corekit/docker/__init__.py +7 -0
- corekit/docker/watchdog.py +222 -0
- corekit/etl/__init__.py +44 -0
- corekit/etl/connection.py +44 -0
- corekit/etl/extract/__init__.py +0 -0
- corekit/etl/extract/extractor.py +48 -0
- corekit/etl/extract/schemas.py +18 -0
- corekit/etl/load/__init__.py +0 -0
- corekit/etl/load/loader.py +53 -0
- corekit/etl/load/schemas.py +33 -0
- corekit/etl/orchestrator.py +201 -0
- corekit/etl/schemas.py +22 -0
- corekit/etl/transform/__init__.py +0 -0
- corekit/etl/transform/schemas.py +15 -0
- corekit/etl/transform/transformer.py +28 -0
- corekit/events/__init__.py +38 -0
- corekit/events/enum.py +58 -0
- corekit/events/frames.py +51 -0
- corekit/events/models.py +23 -0
- corekit/events/publisher.py +75 -0
- corekit/events/reader.py +132 -0
- corekit/events/sse.py +109 -0
- corekit/events/websocket.py +97 -0
- corekit/exceptions/__init__.py +0 -0
- corekit/exceptions/base.py +45 -0
- corekit/exceptions/custom/__init__.py +0 -0
- corekit/exceptions/http/__init__.py +0 -0
- corekit/exceptions/http/exceptions.py +37 -0
- corekit/exceptions/types.py +17 -0
- corekit/files/__init__.py +25 -0
- corekit/files/base.py +117 -0
- corekit/files/enum.py +30 -0
- corekit/files/json.py +12 -0
- corekit/files/pickle.py +12 -0
- corekit/files/toml.py +43 -0
- corekit/http/__init__.py +0 -0
- corekit/http/client.py +176 -0
- corekit/http/exponential_backoff.py +100 -0
- corekit/http/response.py +12 -0
- corekit/log_monitor/__init__.py +23 -0
- corekit/log_monitor/constants.py +8 -0
- corekit/log_monitor/models.py +150 -0
- corekit/log_monitor/service.py +418 -0
- corekit/notifications/__init__.py +8 -0
- corekit/notifications/base.py +51 -0
- corekit/notifications/models.py +34 -0
- corekit/observability/__init__.py +21 -0
- corekit/observability/benchmarkable.py +12 -0
- corekit/observability/loggable.py +29 -0
- corekit/observability/timing/__init__.py +0 -0
- corekit/observability/timing/constants.py +1 -0
- corekit/observability/timing/split.py +20 -0
- corekit/observability/timing/timer.py +30 -0
- corekit/py.typed +0 -0
- corekit/registry/__init__.py +12 -0
- corekit/registry/registry.py +134 -0
- corekit/schemas/__init__.py +0 -0
- corekit/schemas/dataclasses/__init__.py +0 -0
- corekit/schemas/enum.py +49 -0
- corekit/schemas/models/__init__.py +0 -0
- corekit/schemas/models/arbitrary.py +11 -0
- corekit/schemas/models/date_models.py +18 -0
- corekit/schemas/pydantic/__init__.py +0 -0
- corekit/schemas/pydantic/fields.py +35 -0
- corekit/schemas/types.py +40 -0
- corekit/serialization/__init__.py +0 -0
- corekit/serialization/enum.py +21 -0
- corekit/serialization/serializable.py +42 -0
- corekit/serialization/serializer.py +179 -0
- corekit/utils/__init__.py +5 -0
- corekit/utils/ids.py +5 -0
- corekit/utils/raise_exc.py +8 -0
- corekit/utils/time.py +21 -0
- corekit/utils/validators.py +15 -0
- corekit/utils/void.py +8 -0
- python_corekit-0.1.0.dist-info/METADATA +417 -0
- python_corekit-0.1.0.dist-info/RECORD +125 -0
- python_corekit-0.1.0.dist-info/WHEEL +5 -0
- python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_corekit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import status
|
|
4
|
+
|
|
5
|
+
from corekit.exceptions.base import CustomHTTPException
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# 400 Errors
|
|
9
|
+
class UnauthorizedException(CustomHTTPException):
|
|
10
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
11
|
+
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ForbiddenException(CustomHTTPException):
|
|
15
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
16
|
+
super().__init__(status_code=status.HTTP_403_FORBIDDEN, **kwargs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotFoundException(CustomHTTPException):
|
|
20
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
21
|
+
super().__init__(status_code=status.HTTP_404_NOT_FOUND, **kwargs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConflictErrorException(CustomHTTPException):
|
|
25
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
26
|
+
super().__init__(status_code=status.HTTP_409_CONFLICT, **kwargs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnprocessableEntityException(CustomHTTPException):
|
|
30
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
31
|
+
super().__init__(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, **kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# 500 Errors
|
|
35
|
+
class InternalServerErrorException(CustomHTTPException):
|
|
36
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
37
|
+
super().__init__(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, **kwargs)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type aliases for exception classes.
|
|
3
|
+
|
|
4
|
+
These name the *class*, for signatures that accept an exception type rather than
|
|
5
|
+
an instance -- ``def handle(exc: CustomExceptionType) -> None``.
|
|
6
|
+
|
|
7
|
+
``type(X)`` returns X's metaclass, which is ``type`` for an ordinary class and
|
|
8
|
+
carries no information about X. ``type[X]`` is the subscript form these need.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from corekit.exceptions.base import CustomException, CustomHTTPException
|
|
12
|
+
|
|
13
|
+
__all__ = ["ArbitraryCustomExceptionType", "CustomExceptionType", "CustomHTTPExceptionType"]
|
|
14
|
+
|
|
15
|
+
CustomExceptionType = type[CustomException]
|
|
16
|
+
CustomHTTPExceptionType = type[CustomHTTPException]
|
|
17
|
+
ArbitraryCustomExceptionType = CustomExceptionType | CustomHTTPExceptionType
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File reading and writing, with format handled by the manager.
|
|
3
|
+
|
|
4
|
+
with JsonFileManager("data.json") as file:
|
|
5
|
+
data = file.read()
|
|
6
|
+
|
|
7
|
+
``FileManager`` handles opening, closing and mode checking; a subclass supplies
|
|
8
|
+
``_serialize`` and ``_deserialize`` for its format.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from corekit.files.base import FileContent, FileManager
|
|
12
|
+
from corekit.files.enum import FileError, FileMode
|
|
13
|
+
from corekit.files.json import JsonFileManager
|
|
14
|
+
from corekit.files.pickle import PickleFileManager
|
|
15
|
+
from corekit.files.toml import TomlFileManager
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FileContent",
|
|
19
|
+
"FileError",
|
|
20
|
+
"FileManager",
|
|
21
|
+
"FileMode",
|
|
22
|
+
"JsonFileManager",
|
|
23
|
+
"PickleFileManager",
|
|
24
|
+
"TomlFileManager",
|
|
25
|
+
]
|
corekit/files/base.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from typing import Any, BinaryIO, Iterator, TextIO, Union
|
|
2
|
+
|
|
3
|
+
from corekit.files.enum import FileError, FileMode
|
|
4
|
+
|
|
5
|
+
FileContent: type[str | bytes] = Union[str, bytes]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileManager:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
file_path: str,
|
|
12
|
+
mode: FileMode | None = None,
|
|
13
|
+
binary: bool = False,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.file_path = file_path
|
|
16
|
+
self._mode = mode or FileMode.get_default(binary=binary)
|
|
17
|
+
self._fp: BinaryIO | TextIO | None = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def mode(self) -> FileMode:
|
|
21
|
+
return self._mode
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_open(self) -> bool:
|
|
25
|
+
"""
|
|
26
|
+
Helper method to determine if the file is open
|
|
27
|
+
"""
|
|
28
|
+
return self._fp is not None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def can_write(self) -> bool:
|
|
32
|
+
"""
|
|
33
|
+
Helper method to determine if the file can be written to
|
|
34
|
+
"""
|
|
35
|
+
return self.is_open and self._mode.can_write()
|
|
36
|
+
|
|
37
|
+
def _verify_file_status(self, should_be_open: bool) -> None:
|
|
38
|
+
if self.is_open != should_be_open:
|
|
39
|
+
raise ValueError(FileError.NOT_OPEN if should_be_open else FileError.OPEN) # FIXME: raise custom exception
|
|
40
|
+
|
|
41
|
+
# ===== File Opening/Closing Methods =====
|
|
42
|
+
def _open(self) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Opens the file
|
|
45
|
+
"""
|
|
46
|
+
self._verify_file_status(should_be_open=False)
|
|
47
|
+
self._fp = open(self.file_path, self.mode.value)
|
|
48
|
+
|
|
49
|
+
def _close(self) -> None:
|
|
50
|
+
self._verify_file_status(should_be_open=True)
|
|
51
|
+
self._fp.close()
|
|
52
|
+
|
|
53
|
+
# ===== Formatting Methods =====
|
|
54
|
+
def _serialize(self, content: Any) -> FileContent:
|
|
55
|
+
"""
|
|
56
|
+
Helper method to serialize the content into the required format.
|
|
57
|
+
By default, does nothing and returns the input as is.
|
|
58
|
+
"""
|
|
59
|
+
return content
|
|
60
|
+
|
|
61
|
+
def _deserialize(self, content: FileContent) -> Any:
|
|
62
|
+
"""
|
|
63
|
+
Helper method to deserialize the content from the required format
|
|
64
|
+
into the working format. By default, does nothing and returns the
|
|
65
|
+
input as is.
|
|
66
|
+
"""
|
|
67
|
+
return content
|
|
68
|
+
|
|
69
|
+
# ===== Context Manager Methods =====
|
|
70
|
+
def __enter__(self) -> "FileManager":
|
|
71
|
+
self._open()
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
75
|
+
self._close()
|
|
76
|
+
|
|
77
|
+
# ===== File Reading Methods =====
|
|
78
|
+
def read(self, size: int = -1) -> str | bytes:
|
|
79
|
+
"""
|
|
80
|
+
Reads content from the file
|
|
81
|
+
"""
|
|
82
|
+
self._verify_file_status(should_be_open=True)
|
|
83
|
+
return self._deserialize(self._fp.read(size))
|
|
84
|
+
|
|
85
|
+
def readline(self) -> str | bytes:
|
|
86
|
+
"""
|
|
87
|
+
Reads a single line from the file
|
|
88
|
+
"""
|
|
89
|
+
self._verify_file_status(should_be_open=True)
|
|
90
|
+
return self._deserialize(self._fp.readline())
|
|
91
|
+
|
|
92
|
+
def stream(self) -> Iterator[str | bytes]:
|
|
93
|
+
"""
|
|
94
|
+
Streams the file line by line
|
|
95
|
+
"""
|
|
96
|
+
self._verify_file_status(should_be_open=True)
|
|
97
|
+
for line in self._fp:
|
|
98
|
+
yield self._deserialize(line)
|
|
99
|
+
|
|
100
|
+
# ===== File Writing Methods =====
|
|
101
|
+
def write(self, content: FileContent, newline: bool = False, skip_verification: bool = False) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Writes content to the file
|
|
104
|
+
"""
|
|
105
|
+
if not skip_verification:
|
|
106
|
+
self._verify_file_status(should_be_open=True)
|
|
107
|
+
if newline:
|
|
108
|
+
content += "\n"
|
|
109
|
+
self._fp.write(self._serialize(content))
|
|
110
|
+
|
|
111
|
+
def writelines(self, lines: list[FileContent], newline: bool = True) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Writes multiple lines to the file
|
|
114
|
+
"""
|
|
115
|
+
self._verify_file_status(should_be_open=True)
|
|
116
|
+
for line in lines:
|
|
117
|
+
self.write(line, newline=newline, skip_verification=True)
|
corekit/files/enum.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from corekit.schemas.enum import StringEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FileMode(StringEnum):
|
|
5
|
+
APPEND = "a"
|
|
6
|
+
APPEND_BINARY = "ab"
|
|
7
|
+
APPEND_WRITE = "a+"
|
|
8
|
+
APPEND_WRITE_BINARY = "a+b"
|
|
9
|
+
READ = "r"
|
|
10
|
+
READ_BINARY = "rb"
|
|
11
|
+
READ_WRITE = "r+"
|
|
12
|
+
READ_WRITE_BINARY = "r+b"
|
|
13
|
+
WRITE = "w"
|
|
14
|
+
WRITE_BINARY = "wb"
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def get_default(cls, binary: bool = False) -> "FileMode":
|
|
18
|
+
if binary:
|
|
19
|
+
return cls.READ_BINARY
|
|
20
|
+
return cls.READ
|
|
21
|
+
|
|
22
|
+
def can_write(self) -> bool:
|
|
23
|
+
if self in {FileMode.READ, FileMode.READ_BINARY}:
|
|
24
|
+
return False
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FileError(StringEnum):
|
|
29
|
+
NOT_OPEN = "File is not open"
|
|
30
|
+
OPEN = "File is open"
|
corekit/files/json.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import json as _json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from corekit.files.base import FileContent, FileManager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JsonFileManager(FileManager):
|
|
8
|
+
def _serialize(self, content: Any) -> FileContent:
|
|
9
|
+
return _json.dumps(content)
|
|
10
|
+
|
|
11
|
+
def _deserialize(self, content: FileContent) -> Any:
|
|
12
|
+
return _json.loads(content)
|
corekit/files/pickle.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import pickle
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from corekit.files.base import FileContent, FileManager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PickleFileManager(FileManager):
|
|
8
|
+
def _serialize(self, content: Any) -> FileContent:
|
|
9
|
+
return pickle.dumps(content)
|
|
10
|
+
|
|
11
|
+
def _deserialize(self, content: FileContent) -> Any:
|
|
12
|
+
return pickle.loads(content)
|
corekit/files/toml.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reading TOML files.
|
|
3
|
+
|
|
4
|
+
with TomlFileManager("corekit.toml") as file:
|
|
5
|
+
settings = file.read()
|
|
6
|
+
|
|
7
|
+
Read-only: ``tomllib`` is the standard library's parser and it does not write.
|
|
8
|
+
Attempting to write raises rather than silently doing nothing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from corekit.files.base import FileContent, FileManager
|
|
15
|
+
|
|
16
|
+
__all__ = ["TomlFileManager"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TomlFileManager(FileManager):
|
|
20
|
+
"""
|
|
21
|
+
A FileManager that parses TOML on read.
|
|
22
|
+
|
|
23
|
+
TOML must be read as bytes: ``tomllib.load`` decodes the file itself, so
|
|
24
|
+
that it can honour the encoding the format mandates.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, file_path: str, **kwargs: Any) -> None:
|
|
28
|
+
kwargs.setdefault("binary", True)
|
|
29
|
+
super().__init__(file_path, **kwargs)
|
|
30
|
+
|
|
31
|
+
def _deserialize(self, content: FileContent) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Parse TOML text or bytes into a mapping.
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(content, str):
|
|
36
|
+
content = content.encode("utf-8")
|
|
37
|
+
return tomllib.loads(content.decode("utf-8"))
|
|
38
|
+
|
|
39
|
+
def _serialize(self, content: Any) -> FileContent:
|
|
40
|
+
"""
|
|
41
|
+
Not supported: the standard library has no TOML writer.
|
|
42
|
+
"""
|
|
43
|
+
raise NotImplementedError("TomlFileManager is read-only; tomllib does not write TOML.")
|
corekit/http/__init__.py
ADDED
|
File without changes
|
corekit/http/client.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A small HTTP client with retries.
|
|
3
|
+
|
|
4
|
+
Subclass and give it a base URL::
|
|
5
|
+
|
|
6
|
+
class GithubClient(BaseApiClient):
|
|
7
|
+
'''
|
|
8
|
+
Talks to the GitHub API.
|
|
9
|
+
'''
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def base_url(self) -> str:
|
|
13
|
+
return "https://api.github.com"
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def headers(self) -> dict[str, str]:
|
|
17
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
18
|
+
|
|
19
|
+
client = GithubClient()
|
|
20
|
+
response = client.get("/user") # or await client.async_get("/user")
|
|
21
|
+
response.data["login"]
|
|
22
|
+
|
|
23
|
+
Every response comes back as a ``BaseApiResponse``, so callers see one shape
|
|
24
|
+
regardless of what the endpoint returned. Requests that fail with a retryable
|
|
25
|
+
status are retried with exponential backoff.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from http import HTTPMethod
|
|
29
|
+
from typing import Any
|
|
30
|
+
from urllib.parse import urljoin
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
from corekit.http.exponential_backoff import ExponentialBackoff
|
|
35
|
+
from corekit.http.response import BaseApiResponse
|
|
36
|
+
from corekit.observability.benchmarkable import Benchmarkable
|
|
37
|
+
|
|
38
|
+
__all__ = ["BaseApiClient", "URLMismatchError"]
|
|
39
|
+
|
|
40
|
+
# Statuses worth retrying: the server is busy or briefly unavailable, so the
|
|
41
|
+
# same request may well succeed shortly. A 4xx other than 429 will not.
|
|
42
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class URLMismatchError(Exception):
|
|
46
|
+
"""
|
|
47
|
+
Raised when an absolute URL is passed that does not share the base URL.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BaseApiClient(Benchmarkable):
|
|
52
|
+
"""
|
|
53
|
+
Base for API clients. Override ``base_url`` and, usually, ``headers``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, retries: int = 3, timeout: float = 30.0) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.retries = retries
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def headers(self) -> dict[str, str]:
|
|
63
|
+
"""
|
|
64
|
+
Headers sent with every request.
|
|
65
|
+
"""
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def base_url(self) -> str:
|
|
70
|
+
"""
|
|
71
|
+
The root every relative path is resolved against.
|
|
72
|
+
"""
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
def _build_url(self, url: str) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Resolve a path against the base URL, accepting an absolute URL that
|
|
78
|
+
already matches it.
|
|
79
|
+
"""
|
|
80
|
+
if url.startswith(self.base_url):
|
|
81
|
+
return url
|
|
82
|
+
if url.startswith("http"):
|
|
83
|
+
raise URLMismatchError(f"URL {url} does not match base URL {self.base_url}")
|
|
84
|
+
return urljoin(self.base_url, url)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _to_response(raw: Any) -> BaseApiResponse:
|
|
88
|
+
"""
|
|
89
|
+
Normalize an httpx response into a BaseApiResponse.
|
|
90
|
+
|
|
91
|
+
The body is decoded as JSON when it parses, and left as text otherwise,
|
|
92
|
+
so a caller never has to guard against a non-JSON error page.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
data = raw.json()
|
|
96
|
+
except ValueError:
|
|
97
|
+
data = {}
|
|
98
|
+
if not isinstance(data, dict):
|
|
99
|
+
data = {"data": data}
|
|
100
|
+
|
|
101
|
+
return BaseApiResponse(
|
|
102
|
+
status_code=raw.status_code,
|
|
103
|
+
text=raw.text,
|
|
104
|
+
data=data,
|
|
105
|
+
headers=dict(raw.headers),
|
|
106
|
+
cookies=dict(raw.cookies),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _new_backoff(self) -> ExponentialBackoff:
|
|
110
|
+
return ExponentialBackoff(retries=self.retries)
|
|
111
|
+
|
|
112
|
+
def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
113
|
+
"""
|
|
114
|
+
Send a request, retrying retryable statuses.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
target = self._build_url(url)
|
|
118
|
+
backoff = self._new_backoff()
|
|
119
|
+
|
|
120
|
+
while True:
|
|
121
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
122
|
+
raw = client.request(str(method), target, headers=self.headers, **kwargs)
|
|
123
|
+
|
|
124
|
+
if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
|
|
125
|
+
return self._to_response(raw)
|
|
126
|
+
|
|
127
|
+
self.warning(f"{method} {target} returned {raw.status_code}; retrying")
|
|
128
|
+
backoff.wait()
|
|
129
|
+
|
|
130
|
+
async def async_request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
131
|
+
"""
|
|
132
|
+
Send a request asynchronously, retrying retryable statuses.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
target = self._build_url(url)
|
|
136
|
+
backoff = self._new_backoff()
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
140
|
+
raw = await client.request(str(method), target, headers=self.headers, **kwargs)
|
|
141
|
+
|
|
142
|
+
if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
|
|
143
|
+
return self._to_response(raw)
|
|
144
|
+
|
|
145
|
+
self.warning(f"{method} {target} returned {raw.status_code}; retrying")
|
|
146
|
+
await backoff.async_wait()
|
|
147
|
+
|
|
148
|
+
def get(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
149
|
+
return self.request(HTTPMethod.GET, url, **kwargs)
|
|
150
|
+
|
|
151
|
+
def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
152
|
+
return self.request(HTTPMethod.POST, url, **kwargs)
|
|
153
|
+
|
|
154
|
+
def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
155
|
+
return self.request(HTTPMethod.PUT, url, **kwargs)
|
|
156
|
+
|
|
157
|
+
def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
158
|
+
return self.request(HTTPMethod.PATCH, url, **kwargs)
|
|
159
|
+
|
|
160
|
+
def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
161
|
+
return self.request(HTTPMethod.DELETE, url, **kwargs)
|
|
162
|
+
|
|
163
|
+
async def async_get(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
164
|
+
return await self.async_request(HTTPMethod.GET, url, **kwargs)
|
|
165
|
+
|
|
166
|
+
async def async_post(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
167
|
+
return await self.async_request(HTTPMethod.POST, url, **kwargs)
|
|
168
|
+
|
|
169
|
+
async def async_put(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
170
|
+
return await self.async_request(HTTPMethod.PUT, url, **kwargs)
|
|
171
|
+
|
|
172
|
+
async def async_patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
173
|
+
return await self.async_request(HTTPMethod.PATCH, url, **kwargs)
|
|
174
|
+
|
|
175
|
+
async def async_delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
176
|
+
return await self.async_request(HTTPMethod.DELETE, url, **kwargs)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exponential backoff with jitter.
|
|
3
|
+
|
|
4
|
+
backoff = ExponentialBackoff(retries=5)
|
|
5
|
+
while True:
|
|
6
|
+
try:
|
|
7
|
+
return do_something()
|
|
8
|
+
except TransientError:
|
|
9
|
+
backoff.wait() # or `await backoff.async_wait()`
|
|
10
|
+
|
|
11
|
+
Delays double each attempt up to ``max_delay``, with jitter so that several
|
|
12
|
+
clients retrying together do not synchronize.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import random
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from corekit.exceptions.base import ExponentialBackoffTimeoutException
|
|
20
|
+
from corekit.observability.loggable import Loggable
|
|
21
|
+
|
|
22
|
+
__all__ = ["ExponentialBackoff"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExponentialBackoff(Loggable):
|
|
26
|
+
"""
|
|
27
|
+
Tracks retry attempts and sleeps for an increasing delay between them.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
retries: int = 5,
|
|
33
|
+
base_delay: float = 1,
|
|
34
|
+
max_delay: float = 32,
|
|
35
|
+
jitter: bool = True,
|
|
36
|
+
exc_to_raise: BaseException | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""
|
|
39
|
+
:param retries: attempts allowed before giving up.
|
|
40
|
+
:param base_delay: delay before the first retry, in seconds.
|
|
41
|
+
:param max_delay: ceiling for any single delay, in seconds.
|
|
42
|
+
:param jitter: randomize each delay, so concurrent clients desynchronize.
|
|
43
|
+
:param exc_to_raise: raised on exhaustion instead of the default. Use it
|
|
44
|
+
to surface the original failure rather than masking it with a
|
|
45
|
+
timeout that says nothing about what actually went wrong.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.retries = retries
|
|
49
|
+
self.base_delay = base_delay
|
|
50
|
+
self.max_delay = max_delay
|
|
51
|
+
self.jitter = jitter
|
|
52
|
+
self.exc_to_raise = exc_to_raise
|
|
53
|
+
self.attempt_num = 0
|
|
54
|
+
|
|
55
|
+
def did_timeout(self) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Whether the allowed attempts are used up.
|
|
58
|
+
"""
|
|
59
|
+
return self.attempt_num >= self.retries
|
|
60
|
+
|
|
61
|
+
def _get_delay(self) -> float:
|
|
62
|
+
"""
|
|
63
|
+
The delay for the current attempt, raising once attempts are exhausted.
|
|
64
|
+
"""
|
|
65
|
+
if self.did_timeout():
|
|
66
|
+
if self.exc_to_raise is not None:
|
|
67
|
+
raise self.exc_to_raise
|
|
68
|
+
raise ExponentialBackoffTimeoutException(attempts=self.attempt_num)
|
|
69
|
+
|
|
70
|
+
delay = min(self.base_delay * (2**self.attempt_num), self.max_delay)
|
|
71
|
+
if self.jitter:
|
|
72
|
+
delay *= random.uniform(0.5, 1.5)
|
|
73
|
+
return delay
|
|
74
|
+
|
|
75
|
+
def wait(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Block for the current delay, then advance the attempt counter.
|
|
78
|
+
"""
|
|
79
|
+
delay = self._get_delay()
|
|
80
|
+
self.info(f"Attempt {self.attempt_num}, waiting {delay:.2f} seconds...")
|
|
81
|
+
time.sleep(delay)
|
|
82
|
+
self.attempt_num += 1
|
|
83
|
+
|
|
84
|
+
async def async_wait(self) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Await the current delay, then advance the attempt counter.
|
|
87
|
+
|
|
88
|
+
Use this in async code: ``wait`` blocks the event loop, stalling every
|
|
89
|
+
other task in the process.
|
|
90
|
+
"""
|
|
91
|
+
delay = self._get_delay()
|
|
92
|
+
self.info(f"Attempt {self.attempt_num}, waiting {delay:.2f} seconds...")
|
|
93
|
+
await asyncio.sleep(delay)
|
|
94
|
+
self.attempt_num += 1
|
|
95
|
+
|
|
96
|
+
def restart(self) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Reset the attempt counter.
|
|
99
|
+
"""
|
|
100
|
+
self.attempt_num = 0
|
corekit/http/response.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from corekit.schemas.pydantic.fields import DefaultDictField
|
|
4
|
+
from corekit.schemas.types import AnyDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseApiResponse(BaseModel):
|
|
8
|
+
status_code: int = Field(..., description="HTTP status code")
|
|
9
|
+
text: str = Field(..., description="Response text")
|
|
10
|
+
data: AnyDict = DefaultDictField()
|
|
11
|
+
headers: AnyDict = DefaultDictField()
|
|
12
|
+
cookies: AnyDict = DefaultDictField()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Log-driven container automation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.log_monitor.models import (
|
|
6
|
+
Action,
|
|
7
|
+
ActionType,
|
|
8
|
+
ContainerConfig,
|
|
9
|
+
LogMonitorConfig,
|
|
10
|
+
Rule,
|
|
11
|
+
Severity,
|
|
12
|
+
)
|
|
13
|
+
from corekit.log_monitor.service import LogMonitor
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Action",
|
|
17
|
+
"ActionType",
|
|
18
|
+
"ContainerConfig",
|
|
19
|
+
"LogMonitor",
|
|
20
|
+
"LogMonitorConfig",
|
|
21
|
+
"Rule",
|
|
22
|
+
"Severity",
|
|
23
|
+
]
|