python-corekit 0.1.1__py3-none-any.whl → 0.3.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/api/__init__.py +18 -3
- corekit/api/application.py +275 -0
- corekit/api/lifespan.py +233 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +74 -65
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +33 -4
- corekit/connections/sql/connection.py +56 -3
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +9 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +135 -44
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +111 -0
- corekit/connections/sql/operations/statements.py +170 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +33 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/__init__.py +8 -0
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +142 -123
- corekit/data/expressions/expression.py +71 -98
- corekit/data/expressions/operator.py +39 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +162 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +38 -9
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +46 -44
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +27 -13
- corekit/exceptions/__init__.py +33 -0
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +51 -0
- corekit/http/api.py +24 -0
- corekit/http/client.py +100 -73
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +80 -0
- corekit/jobs/task.py +173 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -3
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +188 -0
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +12 -7
- corekit/registry/ordered.py +86 -0
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +70 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +45 -40
- corekit/serialization/__init__.py +24 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +10 -3
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +124 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +112 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
- python_corekit-0.3.0.dist-info/RECORD +145 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.1.1.dist-info/RECORD +0 -125
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import functools
|
|
2
|
+
import inspect
|
|
2
3
|
import logging
|
|
3
4
|
from typing import Any, Callable
|
|
4
5
|
|
|
5
6
|
from corekit.utils import raise_exc, void
|
|
7
|
+
from corekit.utils.coercion import safe_tuple
|
|
6
8
|
|
|
7
9
|
logger = logging.getLogger(__name__)
|
|
8
10
|
|
|
9
11
|
|
|
12
|
+
def _log_text(ex: Exception) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Prefer a backend log line when the exception provides one.
|
|
15
|
+
|
|
16
|
+
Public exceptions stringify to the user-facing copy. Logging that drops
|
|
17
|
+
the internal message.
|
|
18
|
+
"""
|
|
19
|
+
for_log = getattr(ex, "for_log", None)
|
|
20
|
+
if callable(for_log):
|
|
21
|
+
return for_log()
|
|
22
|
+
return str(ex)
|
|
23
|
+
|
|
24
|
+
|
|
10
25
|
def exception_handler(
|
|
11
26
|
ignore: list[type[Exception]] | None = None,
|
|
12
27
|
callback: Callable = raise_exc,
|
|
@@ -15,29 +30,43 @@ def exception_handler(
|
|
|
15
30
|
"""
|
|
16
31
|
A decorator to handle exceptions in a class method or function.
|
|
17
32
|
|
|
33
|
+
Works on both sync and async functions. An async function keeps an async
|
|
34
|
+
wrapper, so awaiting it still works.
|
|
35
|
+
|
|
18
36
|
Args:
|
|
19
37
|
ignore: exception classes to suppress. These do not propagate.
|
|
20
38
|
callback: called when an exception is raised that is not ignored.
|
|
21
39
|
ignore_callback: called when a suppressed exception is raised.
|
|
22
40
|
"""
|
|
23
|
-
exceptions_to_ignore =
|
|
41
|
+
exceptions_to_ignore = safe_tuple(ignore)
|
|
24
42
|
|
|
25
43
|
def decorator(func):
|
|
44
|
+
def handle(ex: Exception, args: tuple, kwargs: dict) -> Any:
|
|
45
|
+
text = _log_text(ex)
|
|
46
|
+
if isinstance(ex, exceptions_to_ignore):
|
|
47
|
+
logger.warning(f"Suppressed exception in {func.__name__}: {text}")
|
|
48
|
+
logger.info(f"Invoking callback function: {ignore_callback.__name__}")
|
|
49
|
+
return ignore_callback(ex, *args, **kwargs)
|
|
50
|
+
|
|
51
|
+
logger.error(f"Exception in {func.__name__}: {text}", exc_info=True)
|
|
52
|
+
return callback(ex, *args, **kwargs)
|
|
53
|
+
|
|
26
54
|
@functools.wraps(func)
|
|
27
55
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
28
56
|
try:
|
|
29
57
|
return func(*args, **kwargs)
|
|
30
58
|
except Exception as ex:
|
|
31
|
-
|
|
32
|
-
if isinstance(ex, exceptions_to_ignore):
|
|
33
|
-
logger.warning(f"Suppressed exception in {func.__name__}: {ex}")
|
|
34
|
-
logger.info(f"Invoking callback function: {ignore_callback.__name__}")
|
|
35
|
-
return ignore_callback(ex, *args, **kwargs)
|
|
59
|
+
return handle(ex, args, kwargs)
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
@functools.wraps(func)
|
|
62
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
63
|
+
try:
|
|
64
|
+
return await func(*args, **kwargs)
|
|
65
|
+
except Exception as ex:
|
|
66
|
+
return handle(ex, args, kwargs)
|
|
40
67
|
|
|
68
|
+
if inspect.iscoroutinefunction(func):
|
|
69
|
+
return async_wrapper
|
|
41
70
|
return wrapper
|
|
42
71
|
|
|
43
72
|
return decorator
|
corekit/docker/watchdog.py
CHANGED
|
@@ -42,11 +42,12 @@ class Watchdog(Benchmarkable):
|
|
|
42
42
|
background daemon, and one unreachable container should not stop it.
|
|
43
43
|
"""
|
|
44
44
|
|
|
45
|
-
def __init__(self, docker_host: str | None = None, enforce_label: bool =
|
|
45
|
+
def __init__(self, docker_host: str | None = None, enforce_label: bool = True) -> None:
|
|
46
46
|
"""
|
|
47
47
|
:param docker_host: Docker endpoint. Defaults to the local socket.
|
|
48
48
|
:param enforce_label: restrict actions to containers carrying the
|
|
49
|
-
managed label.
|
|
49
|
+
managed label. On by default; turn it off only when this watchdog
|
|
50
|
+
is meant to control every container on the host.
|
|
50
51
|
"""
|
|
51
52
|
super().__init__()
|
|
52
53
|
self.docker_host = docker_host or DEFAULT_DOCKER_HOST
|
|
@@ -96,27 +97,29 @@ class Watchdog(Benchmarkable):
|
|
|
96
97
|
return container.labels.get(WATCHDOG_LABEL) == WATCHDOG_MANAGED
|
|
97
98
|
return True
|
|
98
99
|
|
|
99
|
-
def _start_container(self, container: Container) ->
|
|
100
|
+
def _start_container(self, container: Container) -> bool:
|
|
100
101
|
try:
|
|
101
102
|
container.reload()
|
|
102
103
|
if container.status != "running" and self._label_validator(container):
|
|
103
104
|
container.start()
|
|
104
105
|
self.info(f"Started container: {container.name}")
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
return True
|
|
107
|
+
self.info(f"Container {container.name!r} is already running or not managed.")
|
|
107
108
|
except APIError as exc:
|
|
108
109
|
self.error(f"Failed to start container {container.name}: {exc}")
|
|
110
|
+
return False
|
|
109
111
|
|
|
110
|
-
def _stop_container(self, container: Container) ->
|
|
112
|
+
def _stop_container(self, container: Container) -> bool:
|
|
111
113
|
try:
|
|
112
114
|
container.reload()
|
|
113
115
|
if container.status == "running" and self._label_validator(container):
|
|
114
116
|
container.stop()
|
|
115
117
|
self.info(f"Stopped container: {container.name}")
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
return True
|
|
119
|
+
self.info(f"Container {container.name!r} is not running or not managed.")
|
|
118
120
|
except APIError as exc:
|
|
119
121
|
self.error(f"Failed to stop container {container.name}: {exc}")
|
|
122
|
+
return False
|
|
120
123
|
|
|
121
124
|
def _by_name(self, name: str) -> Container | None:
|
|
122
125
|
"""
|
|
@@ -161,62 +164,78 @@ class Watchdog(Benchmarkable):
|
|
|
161
164
|
for container in self._container_iterator(filter_func=self._get_label_filter(label, value), all=True):
|
|
162
165
|
self._start_container(container)
|
|
163
166
|
|
|
164
|
-
def start_container_by_name(self, name: str) ->
|
|
167
|
+
def start_container_by_name(self, name: str) -> bool:
|
|
165
168
|
"""
|
|
166
|
-
Start one container by name.
|
|
169
|
+
Start one container by name. Returns whether it was started.
|
|
167
170
|
"""
|
|
168
171
|
container = self._by_name(name)
|
|
169
|
-
if container is
|
|
170
|
-
|
|
172
|
+
if container is None:
|
|
173
|
+
return False
|
|
174
|
+
return self._start_container(container)
|
|
171
175
|
|
|
172
|
-
def stop_container_by_name(self, name: str) ->
|
|
176
|
+
def stop_container_by_name(self, name: str) -> bool:
|
|
173
177
|
"""
|
|
174
|
-
Stop one container by name.
|
|
178
|
+
Stop one container by name. Returns whether it was stopped.
|
|
175
179
|
"""
|
|
176
180
|
container = self._by_name(name)
|
|
177
|
-
if container is
|
|
178
|
-
|
|
181
|
+
if container is None:
|
|
182
|
+
return False
|
|
183
|
+
return self._stop_container(container)
|
|
179
184
|
|
|
180
|
-
def restart_container_by_name(self, name: str) ->
|
|
185
|
+
def restart_container_by_name(self, name: str) -> bool:
|
|
181
186
|
"""
|
|
182
|
-
|
|
187
|
+
Restart one container by name, honoring the managed label.
|
|
188
|
+
|
|
189
|
+
Returns whether the container was restarted. A missing or unmanaged
|
|
190
|
+
container is not touched.
|
|
183
191
|
"""
|
|
184
192
|
container = self._by_name(name)
|
|
185
193
|
if container is None:
|
|
186
|
-
return
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
container.reload()
|
|
197
|
+
if not self._label_validator(container):
|
|
198
|
+
self.info(f"Container {container.name!r} is not managed.")
|
|
199
|
+
return False
|
|
200
|
+
container.restart()
|
|
201
|
+
self.info(f"Restarted container: {container.name}")
|
|
202
|
+
return True
|
|
203
|
+
except APIError as exc:
|
|
204
|
+
self.error(f"Failed to restart container {name}: {exc}")
|
|
205
|
+
return False
|
|
189
206
|
|
|
190
|
-
def pause_container_by_name(self, name: str) ->
|
|
207
|
+
def pause_container_by_name(self, name: str) -> bool:
|
|
191
208
|
"""
|
|
192
|
-
Pause one running container by name.
|
|
209
|
+
Pause one running container by name. Returns whether it was paused.
|
|
193
210
|
"""
|
|
194
211
|
container = self._by_name(name)
|
|
195
212
|
if container is None:
|
|
196
|
-
return
|
|
213
|
+
return False
|
|
197
214
|
try:
|
|
198
215
|
container.reload()
|
|
199
216
|
if container.status == "running" and self._label_validator(container):
|
|
200
217
|
container.pause()
|
|
201
218
|
self.info(f"Paused container: {container.name}")
|
|
202
|
-
|
|
203
|
-
|
|
219
|
+
return True
|
|
220
|
+
self.info(f"Container {container.name!r} is not running or not managed.")
|
|
204
221
|
except APIError as exc:
|
|
205
222
|
self.error(f"Failed to pause container {name}: {exc}")
|
|
223
|
+
return False
|
|
206
224
|
|
|
207
|
-
def unpause_container_by_name(self, name: str) ->
|
|
225
|
+
def unpause_container_by_name(self, name: str) -> bool:
|
|
208
226
|
"""
|
|
209
|
-
Unpause one paused container by name.
|
|
227
|
+
Unpause one paused container by name. Returns whether it was unpaused.
|
|
210
228
|
"""
|
|
211
229
|
container = self._by_name(name)
|
|
212
230
|
if container is None:
|
|
213
|
-
return
|
|
231
|
+
return False
|
|
214
232
|
try:
|
|
215
233
|
container.reload()
|
|
216
234
|
if container.status == "paused" and self._label_validator(container):
|
|
217
235
|
container.unpause()
|
|
218
236
|
self.info(f"Unpaused container: {container.name}")
|
|
219
|
-
|
|
220
|
-
|
|
237
|
+
return True
|
|
238
|
+
self.info(f"Container {container.name!r} is not paused or not managed.")
|
|
221
239
|
except APIError as exc:
|
|
222
240
|
self.error(f"Failed to unpause container {name}: {exc}")
|
|
241
|
+
return False
|
corekit/etl/__init__.py
CHANGED
|
@@ -18,7 +18,7 @@ The run loop streams, so memory use is bounded by the batch size rather than by
|
|
|
18
18
|
how much data the source holds.
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
-
from corekit.etl.connection import BaseConnection, ConnectionDetails
|
|
21
|
+
from corekit.etl.connection import BaseConnection, ConnectionDetails, ETLSource
|
|
22
22
|
from corekit.etl.extract.extractor import BaseETLExtractor
|
|
23
23
|
from corekit.etl.extract.schemas import BaseExtractedItemModel, ExtractedItem
|
|
24
24
|
from corekit.etl.load.loader import BaseETLLoader
|
|
@@ -30,6 +30,7 @@ from corekit.etl.transform.transformer import BaseETLTransformer
|
|
|
30
30
|
|
|
31
31
|
__all__ = [
|
|
32
32
|
"BaseConnection",
|
|
33
|
+
"ETLSource",
|
|
33
34
|
"BaseETLExtractor",
|
|
34
35
|
"BaseETLLoader",
|
|
35
36
|
"BaseETLOrchestrator",
|
corekit/etl/connection.py
CHANGED
|
@@ -1,44 +1,46 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
from typing import NamedTuple
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class ConnectionDetails(NamedTuple):
|
|
6
|
-
name: str
|
|
7
|
-
description: str
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
""
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConnectionDetails(NamedTuple):
|
|
6
|
+
name: str
|
|
7
|
+
description: str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ETLSource(Enum):
|
|
11
|
+
"""
|
|
12
|
+
A named source label for an extracted item.
|
|
13
|
+
|
|
14
|
+
This is not a connection. It does not open a socket, a session, or a
|
|
15
|
+
``Connectable``. It only names where a row came from so a transformer can
|
|
16
|
+
tell sources apart.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
class UserSource(ETLSource):
|
|
20
|
+
USERS = ConnectionDetails(name="users", description="User records")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_name(cls, name: str) -> "ETLSource":
|
|
25
|
+
# TODO: Make sure this is efficient
|
|
26
|
+
for connection in cls:
|
|
27
|
+
if connection.value.name == name:
|
|
28
|
+
return connection
|
|
29
|
+
raise ValueError(f"Connection {name} not found")
|
|
30
|
+
|
|
31
|
+
def get_name(self) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Helper method for fetching the name of the connection
|
|
34
|
+
"""
|
|
35
|
+
return self.value.name
|
|
36
|
+
|
|
37
|
+
def get_description(self) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Helper method for fetching the name of the connection
|
|
40
|
+
"""
|
|
41
|
+
return self.value.description
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# The old name implied a second connection system. Keep it so existing imports
|
|
45
|
+
# still resolve; new code should use ETLSource.
|
|
46
|
+
BaseConnection = ETLSource
|
corekit/etl/extract/extractor.py
CHANGED
|
@@ -1,30 +1,23 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
2
|
from typing import Any, AsyncIterable, Callable
|
|
3
3
|
|
|
4
|
-
from corekit.etl.connection import BaseConnection
|
|
5
4
|
from corekit.etl.extract.schemas import ExtractedItem
|
|
6
|
-
from corekit.http.client import BaseApiClient
|
|
7
5
|
from corekit.observability.loggable import Loggable
|
|
8
6
|
|
|
9
7
|
|
|
10
8
|
class BaseETLExtractor(Loggable, ABC):
|
|
11
9
|
"""
|
|
12
|
-
|
|
10
|
+
Yields items from a source.
|
|
11
|
+
|
|
12
|
+
The runner constructs this with no arguments, so a subclass that needs a
|
|
13
|
+
client or other dependency should default it or be passed in as an
|
|
14
|
+
instance. ``client`` is optional and unused by the base class.
|
|
13
15
|
"""
|
|
14
16
|
|
|
15
|
-
def __init__(self, client:
|
|
17
|
+
def __init__(self, client: Any | None = None) -> None:
|
|
16
18
|
super().__init__()
|
|
17
19
|
self.client = client
|
|
18
20
|
|
|
19
|
-
@property
|
|
20
|
-
@abstractmethod
|
|
21
|
-
def connection(self) -> BaseConnection:
|
|
22
|
-
"""
|
|
23
|
-
Abstract property that must be implemented by child classes.
|
|
24
|
-
This property should return the integration object that the extractor is associated with
|
|
25
|
-
"""
|
|
26
|
-
raise NotImplementedError
|
|
27
|
-
|
|
28
21
|
@abstractmethod
|
|
29
22
|
def _extraction_methods(self) -> list[Callable[[Any], Any]]:
|
|
30
23
|
"""
|
corekit/etl/orchestrator.py
CHANGED
|
@@ -159,12 +159,19 @@ class BaseETLOrchestrator(Loggable):
|
|
|
159
159
|
transformer: BaseETLTransformer | None = None,
|
|
160
160
|
loader: BaseETLLoader | None = None,
|
|
161
161
|
batch_size: int | None = None,
|
|
162
|
+
on_item_error: str = "raise",
|
|
162
163
|
) -> None:
|
|
163
164
|
"""
|
|
164
165
|
Build a pipeline, optionally overriding any stage with an instance.
|
|
166
|
+
|
|
167
|
+
``on_item_error`` is ``raise`` (stop, leaving earlier batches written)
|
|
168
|
+
or ``skip`` (log the item and continue). Transform is synchronous.
|
|
165
169
|
"""
|
|
170
|
+
if on_item_error not in ("raise", "skip"):
|
|
171
|
+
raise ValueError("on_item_error must be 'raise' or 'skip'")
|
|
166
172
|
super().__init__()
|
|
167
173
|
self.batch_size = batch_size if batch_size is not None else type(self).batch_size
|
|
174
|
+
self.on_item_error = on_item_error
|
|
168
175
|
self._extractor = extractor
|
|
169
176
|
self._transformer = transformer
|
|
170
177
|
self._loader = loader
|
|
@@ -192,10 +199,20 @@ class BaseETLOrchestrator(Loggable):
|
|
|
192
199
|
loader = self._build(self._loader, "loader")
|
|
193
200
|
|
|
194
201
|
count = 0
|
|
202
|
+
skipped = 0
|
|
195
203
|
async for extracted_item in extractor.extract():
|
|
196
|
-
|
|
204
|
+
try:
|
|
205
|
+
# transform is synchronous; do not await it.
|
|
206
|
+
transformed = transformer.transform(extracted_item)
|
|
207
|
+
except Exception:
|
|
208
|
+
if self.on_item_error == "raise":
|
|
209
|
+
raise
|
|
210
|
+
skipped += 1
|
|
211
|
+
self.exception(f"{type(self).__name__} skipped an item")
|
|
212
|
+
continue
|
|
213
|
+
loader.add_item(transformed)
|
|
197
214
|
await loader.load(min_ops=self.batch_size)
|
|
198
215
|
count += 1
|
|
199
216
|
|
|
200
217
|
await loader.flush()
|
|
201
|
-
self.info(f"{type(self).__name__} processed {count} items")
|
|
218
|
+
self.info(f"{type(self).__name__} processed {count} items, skipped {skipped}")
|
corekit/etl/schemas.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
|
|
3
|
-
from corekit.etl.connection import
|
|
3
|
+
from corekit.etl.connection import ETLSource
|
|
4
4
|
from corekit.schemas.types import UnknownDict
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ class BaseItem(BaseModel):
|
|
|
9
9
|
Base model for all ETL items
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
connection:
|
|
12
|
+
connection: ETLSource
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class RawAPIResponse(BaseItem):
|
|
@@ -18,7 +18,10 @@ class BaseETLTransformer(Loggable, ABC):
|
|
|
18
18
|
|
|
19
19
|
def transform(self, item: ExtractedItem) -> TransformedItem:
|
|
20
20
|
"""
|
|
21
|
-
|
|
21
|
+
Turn one extracted item into a loadable one.
|
|
22
|
+
|
|
23
|
+
Synchronous on purpose. The orchestrator calls this directly and does
|
|
24
|
+
not await it.
|
|
22
25
|
"""
|
|
23
26
|
data_type = item.get_data_type()
|
|
24
27
|
mapping = self._extracted_item_type_to_method
|
corekit/events/publisher.py
CHANGED
|
@@ -11,7 +11,7 @@ constant::
|
|
|
11
11
|
EventPublisher.for_resource("minecraft", "server", "survival")
|
|
12
12
|
# -> channel "minecraft:server:survival"
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Redis is a hard dependency of corekit; no extra install is required.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from typing import Any
|
corekit/events/reader.py
CHANGED
|
@@ -8,7 +8,9 @@ disappears -- so no consumer has to repeat it.
|
|
|
8
8
|
Everything here uses Redis's async client. The synchronous client's
|
|
9
9
|
``get_message`` blocks the thread it is called on, which in an async server is
|
|
10
10
|
the event loop, stalling every other request in the process while one client
|
|
11
|
-
waits for an event that may never come.
|
|
11
|
+
waits for an event that may never come. The async client's ``get_message``
|
|
12
|
+
returns immediately unless it is given a timeout, so the wait has to be
|
|
13
|
+
asked for there rather than wrapped around a call that already finished.
|
|
12
14
|
"""
|
|
13
15
|
|
|
14
16
|
import asyncio
|
|
@@ -21,9 +23,6 @@ from corekit.observability.loggable import Loggable
|
|
|
21
23
|
|
|
22
24
|
__all__ = ["RedisChannelReader"]
|
|
23
25
|
|
|
24
|
-
# How long to yield to the event loop when the socket has nothing ready.
|
|
25
|
-
IDLE_SLEEP_SECONDS = 0.01
|
|
26
|
-
|
|
27
26
|
|
|
28
27
|
class RedisChannelReader(Loggable):
|
|
29
28
|
"""
|
|
@@ -88,27 +87,33 @@ class RedisChannelReader(Loggable):
|
|
|
88
87
|
"""
|
|
89
88
|
Return the next decoded payload.
|
|
90
89
|
|
|
91
|
-
Returns ``None`` when nothing arrived before ``timeout
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
Returns ``None`` only when nothing arrived before ``timeout``. The wait
|
|
91
|
+
happens inside Redis ``get_message``: calling it with no timeout
|
|
92
|
+
returns immediately, and wrapping that in ``asyncio.wait_for`` never
|
|
93
|
+
blocks. ``timeout=None`` waits until a payload arrives.
|
|
94
94
|
"""
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
loop = asyncio.get_running_loop()
|
|
96
|
+
deadline = None if timeout is None else loop.time() + timeout
|
|
97
|
+
while True:
|
|
98
|
+
remaining = None if deadline is None else deadline - loop.time()
|
|
99
|
+
if remaining is not None and remaining <= 0:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
message = await self._pubsub.get_message(
|
|
103
|
+
ignore_subscribe_messages=True,
|
|
104
|
+
timeout=remaining,
|
|
99
105
|
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return None
|
|
106
|
+
if message is None:
|
|
107
|
+
# None before the deadline is an ignored subscribe ack, not
|
|
108
|
+
# silence. Only the expired wait is a keepalive.
|
|
109
|
+
if deadline is None or loop.time() < deadline:
|
|
110
|
+
continue
|
|
111
|
+
return None
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
if message.get(PubSubField.TYPE.value) not in (None, PubSubMessageType.MESSAGE.value):
|
|
114
|
+
continue
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
return self._decode(message.get(PubSubField.DATA.value))
|
|
112
117
|
|
|
113
118
|
def _decode(self, raw: Any) -> Any:
|
|
114
119
|
"""
|
corekit/events/sse.py
CHANGED
|
@@ -93,7 +93,10 @@ class SSEStream(Loggable):
|
|
|
93
93
|
while True:
|
|
94
94
|
payload = await self._reader.next_payload(timeout=self.keepalive_interval)
|
|
95
95
|
if payload is None:
|
|
96
|
-
|
|
96
|
+
# None means the wait expired. A disabled keepalive never
|
|
97
|
+
# asks for a timeout, so it must not emit a comment either.
|
|
98
|
+
if self.keepalive_interval is not None:
|
|
99
|
+
yield SSEFrame.comment()
|
|
97
100
|
continue
|
|
98
101
|
yield self._frame_for(payload)
|
|
99
102
|
|
corekit/events/websocket.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import Any
|
|
|
13
13
|
from corekit.events.enum import MessageField
|
|
14
14
|
from corekit.events.reader import RedisChannelReader
|
|
15
15
|
from corekit.observability.loggable import Loggable
|
|
16
|
+
from corekit.utils.coercion import safe_dict, safe_list
|
|
16
17
|
|
|
17
18
|
__all__ = ["WebSocketBridge"]
|
|
18
19
|
|
|
@@ -44,7 +45,7 @@ class WebSocketBridge(Loggable):
|
|
|
44
45
|
super().__init__()
|
|
45
46
|
self.channel = channel
|
|
46
47
|
self.websocket = websocket
|
|
47
|
-
self.terminal_statuses = terminal_statuses
|
|
48
|
+
self.terminal_statuses = safe_list(terminal_statuses)
|
|
48
49
|
self.timeout = timeout
|
|
49
50
|
self._reader = RedisChannelReader(channel, url=url)
|
|
50
51
|
|
|
@@ -56,16 +57,23 @@ class WebSocketBridge(Loggable):
|
|
|
56
57
|
return False
|
|
57
58
|
if payload.get(MessageField.TYPE.value) != STATUS_UPDATE_EVENT:
|
|
58
59
|
return False
|
|
59
|
-
data = payload.get(MessageField.DATA.value)
|
|
60
|
+
data = safe_dict(payload.get(MessageField.DATA.value))
|
|
60
61
|
return data.get(MessageField.STATUS.value) in self.terminal_statuses
|
|
61
62
|
|
|
62
63
|
def _expired(self, started: float) -> bool:
|
|
63
64
|
"""
|
|
64
65
|
Whether the bridge has outlived its timeout.
|
|
65
66
|
"""
|
|
67
|
+
remaining = self._remaining(started)
|
|
68
|
+
return remaining is not None and remaining <= 0
|
|
69
|
+
|
|
70
|
+
def _remaining(self, started: float) -> float | None:
|
|
71
|
+
"""
|
|
72
|
+
Seconds left before the bridge times out, or None if it never does.
|
|
73
|
+
"""
|
|
66
74
|
if self.timeout is None:
|
|
67
|
-
return
|
|
68
|
-
return asyncio.
|
|
75
|
+
return None
|
|
76
|
+
return self.timeout - (asyncio.get_running_loop().time() - started)
|
|
69
77
|
|
|
70
78
|
async def run(self) -> None:
|
|
71
79
|
"""
|
|
@@ -74,21 +82,27 @@ class WebSocketBridge(Loggable):
|
|
|
74
82
|
if not await self._reader.open():
|
|
75
83
|
return
|
|
76
84
|
|
|
77
|
-
started = asyncio.
|
|
85
|
+
started = asyncio.get_running_loop().time()
|
|
78
86
|
try:
|
|
79
87
|
while True:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if self._is_terminal(payload):
|
|
85
|
-
self.info(f"Bridge on {self.channel} reached a terminal status")
|
|
86
|
-
break
|
|
88
|
+
remaining = self._remaining(started)
|
|
89
|
+
if remaining is not None and remaining <= 0:
|
|
90
|
+
self.warning(f"Bridge on {self.channel} timed out")
|
|
91
|
+
break
|
|
87
92
|
|
|
88
|
-
|
|
93
|
+
payload = await self._reader.next_payload(timeout=remaining)
|
|
94
|
+
if payload is None:
|
|
95
|
+
# The wait expired. That is the timeout, not a reason to poll again.
|
|
96
|
+
if remaining is None:
|
|
97
|
+
continue
|
|
89
98
|
self.warning(f"Bridge on {self.channel} timed out")
|
|
90
99
|
break
|
|
91
100
|
|
|
101
|
+
await self.websocket.send_json(payload)
|
|
102
|
+
if self._is_terminal(payload):
|
|
103
|
+
self.info(f"Bridge on {self.channel} reached a terminal status")
|
|
104
|
+
break
|
|
105
|
+
|
|
92
106
|
except asyncio.CancelledError:
|
|
93
107
|
raise
|
|
94
108
|
except Exception as exc:
|
corekit/exceptions/__init__.py
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception base classes.
|
|
3
|
+
|
|
4
|
+
``CoreException`` is abstract. Raise ``InternalCoreException`` for backend-only
|
|
5
|
+
failures, or ``PublicCoreException`` when there is copy that is safe to show
|
|
6
|
+
a user. ``CoreHTTPException`` is the FastAPI-facing public exception.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from corekit.exceptions.base import (
|
|
10
|
+
CoreException,
|
|
11
|
+
CoreHTTPException,
|
|
12
|
+
ExponentialBackoffTimeoutException,
|
|
13
|
+
InternalCoreException,
|
|
14
|
+
NonRetryableCoreHTTPException,
|
|
15
|
+
PublicCoreException,
|
|
16
|
+
RetryableCoreHTTPException,
|
|
17
|
+
)
|
|
18
|
+
from corekit.exceptions.enum import Retryability
|
|
19
|
+
from corekit.exceptions.types import ArbitraryCoreExceptionType, CoreExceptionType, CoreHTTPExceptionType
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ArbitraryCoreExceptionType",
|
|
23
|
+
"CoreException",
|
|
24
|
+
"CoreExceptionType",
|
|
25
|
+
"CoreHTTPException",
|
|
26
|
+
"CoreHTTPExceptionType",
|
|
27
|
+
"ExponentialBackoffTimeoutException",
|
|
28
|
+
"InternalCoreException",
|
|
29
|
+
"NonRetryableCoreHTTPException",
|
|
30
|
+
"PublicCoreException",
|
|
31
|
+
"Retryability",
|
|
32
|
+
"RetryableCoreHTTPException",
|
|
33
|
+
]
|