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,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Docker container lifecycle control.
|
|
3
|
+
|
|
4
|
+
watchdog = Watchdog(enforce_label=True)
|
|
5
|
+
watchdog.start_container_by_name("minecraft")
|
|
6
|
+
watchdog.find_and_stop(label="app", value="staging")
|
|
7
|
+
|
|
8
|
+
``enforce_label`` is a blast-radius limiter: with it on, the watchdog will only
|
|
9
|
+
start, stop, pause or unpause containers carrying the managed label, so a
|
|
10
|
+
mistaken name cannot take down something unrelated. Leave it on unless the
|
|
11
|
+
watchdog is genuinely meant to control every container on the host.
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any, Callable, Iterator
|
|
16
|
+
|
|
17
|
+
from docker import DockerClient
|
|
18
|
+
from docker.errors import APIError, NotFound
|
|
19
|
+
from docker.models.containers import Container
|
|
20
|
+
|
|
21
|
+
from corekit.observability.benchmarkable import Benchmarkable
|
|
22
|
+
|
|
23
|
+
__all__ = ["Watchdog"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock"
|
|
26
|
+
WATCHDOG_LABEL = "watchdog"
|
|
27
|
+
WATCHDOG_MANAGED = "true"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _accept_all(*args: Any, **kwargs: Any) -> bool:
|
|
31
|
+
"""
|
|
32
|
+
Filter that accepts every container.
|
|
33
|
+
"""
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Watchdog(Benchmarkable):
|
|
38
|
+
"""
|
|
39
|
+
Starts, stops, pauses and lists Docker containers.
|
|
40
|
+
|
|
41
|
+
Docker API failures are logged rather than raised: a watchdog is usually a
|
|
42
|
+
background daemon, and one unreachable container should not stop it.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, docker_host: str | None = None, enforce_label: bool = False) -> None:
|
|
46
|
+
"""
|
|
47
|
+
:param docker_host: Docker endpoint. Defaults to the local socket.
|
|
48
|
+
:param enforce_label: restrict actions to containers carrying the
|
|
49
|
+
managed label.
|
|
50
|
+
"""
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.docker_host = docker_host or DEFAULT_DOCKER_HOST
|
|
53
|
+
self.enforce_label = enforce_label
|
|
54
|
+
self._client_instance: DockerClient | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def _client(self) -> DockerClient:
|
|
58
|
+
"""
|
|
59
|
+
The Docker client, connected on first use.
|
|
60
|
+
|
|
61
|
+
Constructing a Watchdog does not reach the daemon, so one can be built
|
|
62
|
+
and inspected on a machine where Docker is not running.
|
|
63
|
+
"""
|
|
64
|
+
if self._client_instance is None:
|
|
65
|
+
self._client_instance = DockerClient(base_url=self.docker_host)
|
|
66
|
+
return self._client_instance
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _get_label_filter(
|
|
70
|
+
label: str = WATCHDOG_LABEL,
|
|
71
|
+
value: str = WATCHDOG_MANAGED,
|
|
72
|
+
) -> Callable[[Container], bool]:
|
|
73
|
+
"""
|
|
74
|
+
Build a filter matching containers whose label equals a value.
|
|
75
|
+
"""
|
|
76
|
+
return lambda container: container.labels.get(label) == value
|
|
77
|
+
|
|
78
|
+
def _container_iterator(
|
|
79
|
+
self, filter_func: Callable[[Container], bool] = _accept_all, **kwargs: Any
|
|
80
|
+
) -> Iterator[Container]:
|
|
81
|
+
"""
|
|
82
|
+
Yield containers matching a filter, logging API errors.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
for container in self._client.containers.list(**kwargs):
|
|
86
|
+
if filter_func(container):
|
|
87
|
+
yield container
|
|
88
|
+
except APIError as exc:
|
|
89
|
+
self.error(f"Docker API error during iteration: {exc}")
|
|
90
|
+
|
|
91
|
+
def _label_validator(self, container: Container) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Whether this watchdog is permitted to act on a container.
|
|
94
|
+
"""
|
|
95
|
+
if self.enforce_label:
|
|
96
|
+
return container.labels.get(WATCHDOG_LABEL) == WATCHDOG_MANAGED
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
def _start_container(self, container: Container) -> None:
|
|
100
|
+
try:
|
|
101
|
+
container.reload()
|
|
102
|
+
if container.status != "running" and self._label_validator(container):
|
|
103
|
+
container.start()
|
|
104
|
+
self.info(f"Started container: {container.name}")
|
|
105
|
+
else:
|
|
106
|
+
self.info(f"Container {container.name!r} is already running or not managed.")
|
|
107
|
+
except APIError as exc:
|
|
108
|
+
self.error(f"Failed to start container {container.name}: {exc}")
|
|
109
|
+
|
|
110
|
+
def _stop_container(self, container: Container) -> None:
|
|
111
|
+
try:
|
|
112
|
+
container.reload()
|
|
113
|
+
if container.status == "running" and self._label_validator(container):
|
|
114
|
+
container.stop()
|
|
115
|
+
self.info(f"Stopped container: {container.name}")
|
|
116
|
+
else:
|
|
117
|
+
self.info(f"Container {container.name!r} is not running or not managed.")
|
|
118
|
+
except APIError as exc:
|
|
119
|
+
self.error(f"Failed to stop container {container.name}: {exc}")
|
|
120
|
+
|
|
121
|
+
def _by_name(self, name: str) -> Container | None:
|
|
122
|
+
"""
|
|
123
|
+
Look a container up by name, logging if it is missing.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
return self._client.containers.get(name)
|
|
128
|
+
except NotFound:
|
|
129
|
+
self.error(f"Container {name!r} not found.")
|
|
130
|
+
except APIError as exc:
|
|
131
|
+
self.error(f"Failed to fetch container {name}: {exc}")
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def list_containers(self, **kwargs: Any) -> list[Container]:
|
|
135
|
+
"""
|
|
136
|
+
Every container, or an empty list if Docker is unreachable.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
return self._client.containers.list(**kwargs)
|
|
140
|
+
except APIError as exc:
|
|
141
|
+
self.error(f"Failed to list containers: {exc}")
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
def list_containers_by_label(self, label: str, value: str) -> list[Container]:
|
|
145
|
+
"""
|
|
146
|
+
Containers whose label equals a value.
|
|
147
|
+
"""
|
|
148
|
+
return list(self._container_iterator(filter_func=self._get_label_filter(label, value), all=True))
|
|
149
|
+
|
|
150
|
+
def find_and_stop(self, label: str, value: str) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Stop every container matching a label.
|
|
153
|
+
"""
|
|
154
|
+
for container in self._container_iterator(filter_func=self._get_label_filter(label, value), all=True):
|
|
155
|
+
self._stop_container(container)
|
|
156
|
+
|
|
157
|
+
def find_and_start(self, label: str, value: str) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Start every container matching a label.
|
|
160
|
+
"""
|
|
161
|
+
for container in self._container_iterator(filter_func=self._get_label_filter(label, value), all=True):
|
|
162
|
+
self._start_container(container)
|
|
163
|
+
|
|
164
|
+
def start_container_by_name(self, name: str) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Start one container by name.
|
|
167
|
+
"""
|
|
168
|
+
container = self._by_name(name)
|
|
169
|
+
if container is not None:
|
|
170
|
+
self._start_container(container)
|
|
171
|
+
|
|
172
|
+
def stop_container_by_name(self, name: str) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Stop one container by name.
|
|
175
|
+
"""
|
|
176
|
+
container = self._by_name(name)
|
|
177
|
+
if container is not None:
|
|
178
|
+
self._stop_container(container)
|
|
179
|
+
|
|
180
|
+
def restart_container_by_name(self, name: str) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Stop and then start one container by name.
|
|
183
|
+
"""
|
|
184
|
+
container = self._by_name(name)
|
|
185
|
+
if container is None:
|
|
186
|
+
return
|
|
187
|
+
self._stop_container(container)
|
|
188
|
+
self._start_container(container)
|
|
189
|
+
|
|
190
|
+
def pause_container_by_name(self, name: str) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Pause one running container by name.
|
|
193
|
+
"""
|
|
194
|
+
container = self._by_name(name)
|
|
195
|
+
if container is None:
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
container.reload()
|
|
199
|
+
if container.status == "running" and self._label_validator(container):
|
|
200
|
+
container.pause()
|
|
201
|
+
self.info(f"Paused container: {container.name}")
|
|
202
|
+
else:
|
|
203
|
+
self.info(f"Container {container.name!r} is not running or not managed.")
|
|
204
|
+
except APIError as exc:
|
|
205
|
+
self.error(f"Failed to pause container {name}: {exc}")
|
|
206
|
+
|
|
207
|
+
def unpause_container_by_name(self, name: str) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Unpause one paused container by name.
|
|
210
|
+
"""
|
|
211
|
+
container = self._by_name(name)
|
|
212
|
+
if container is None:
|
|
213
|
+
return
|
|
214
|
+
try:
|
|
215
|
+
container.reload()
|
|
216
|
+
if container.status == "paused" and self._label_validator(container):
|
|
217
|
+
container.unpause()
|
|
218
|
+
self.info(f"Unpaused container: {container.name}")
|
|
219
|
+
else:
|
|
220
|
+
self.info(f"Container {container.name!r} is not paused or not managed.")
|
|
221
|
+
except APIError as exc:
|
|
222
|
+
self.error(f"Failed to unpause container {name}: {exc}")
|
corekit/etl/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ETL pipelines.
|
|
3
|
+
|
|
4
|
+
Implement an extractor, a transformer and a loader, then declare them on an
|
|
5
|
+
orchestrator::
|
|
6
|
+
|
|
7
|
+
class UserPipeline(BaseETLOrchestrator):
|
|
8
|
+
'''
|
|
9
|
+
Loads users from the API into the warehouse.
|
|
10
|
+
'''
|
|
11
|
+
extractor = UserExtractor
|
|
12
|
+
transformer = UserTransformer
|
|
13
|
+
loader = UserLoader
|
|
14
|
+
|
|
15
|
+
await UserPipeline().run()
|
|
16
|
+
|
|
17
|
+
The run loop streams, so memory use is bounded by the batch size rather than by
|
|
18
|
+
how much data the source holds.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from corekit.etl.connection import BaseConnection, ConnectionDetails
|
|
22
|
+
from corekit.etl.extract.extractor import BaseETLExtractor
|
|
23
|
+
from corekit.etl.extract.schemas import BaseExtractedItemModel, ExtractedItem
|
|
24
|
+
from corekit.etl.load.loader import BaseETLLoader
|
|
25
|
+
from corekit.etl.load.schemas import LoadableBatch
|
|
26
|
+
from corekit.etl.orchestrator import BaseETLOrchestrator
|
|
27
|
+
from corekit.etl.schemas import BaseItem
|
|
28
|
+
from corekit.etl.transform.schemas import BaseTransformedItemModel, TransformedItem
|
|
29
|
+
from corekit.etl.transform.transformer import BaseETLTransformer
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BaseConnection",
|
|
33
|
+
"BaseETLExtractor",
|
|
34
|
+
"BaseETLLoader",
|
|
35
|
+
"BaseETLOrchestrator",
|
|
36
|
+
"BaseETLTransformer",
|
|
37
|
+
"BaseExtractedItemModel",
|
|
38
|
+
"BaseItem",
|
|
39
|
+
"BaseTransformedItemModel",
|
|
40
|
+
"ConnectionDetails",
|
|
41
|
+
"ExtractedItem",
|
|
42
|
+
"LoadableBatch",
|
|
43
|
+
"TransformedItem",
|
|
44
|
+
]
|
|
@@ -0,0 +1,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 BaseConnection(Enum):
|
|
11
|
+
"""
|
|
12
|
+
Base enum for ETL Connections. All ETL connections must inherit from this class.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
class MyConnectionEnum(BaseConnection):
|
|
16
|
+
MY_FIRST_CONNECTION = ConnectionDetails(
|
|
17
|
+
name="my_first_connection",
|
|
18
|
+
description="My First Connection"
|
|
19
|
+
)
|
|
20
|
+
ANOTHER_CONNECTION = ConnectionDetails(
|
|
21
|
+
name="another_connection",
|
|
22
|
+
description="Another Connection"
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_name(cls, name: str) -> "BaseConnection":
|
|
28
|
+
# TODO: Make sure this is efficient
|
|
29
|
+
for connection in cls:
|
|
30
|
+
if connection.value.name == name:
|
|
31
|
+
return connection
|
|
32
|
+
raise ValueError(f"Connection {name} not found")
|
|
33
|
+
|
|
34
|
+
def get_name(self) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Helper method for fetching the name of the connection
|
|
37
|
+
"""
|
|
38
|
+
return self.value.name
|
|
39
|
+
|
|
40
|
+
def get_description(self) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Helper method for fetching the name of the connection
|
|
43
|
+
"""
|
|
44
|
+
return self.value.description
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, AsyncIterable, Callable
|
|
3
|
+
|
|
4
|
+
from corekit.etl.connection import BaseConnection
|
|
5
|
+
from corekit.etl.extract.schemas import ExtractedItem
|
|
6
|
+
from corekit.http.client import BaseApiClient
|
|
7
|
+
from corekit.observability.loggable import Loggable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseETLExtractor(Loggable, ABC):
|
|
11
|
+
"""
|
|
12
|
+
Base class for ETL extractors.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: BaseApiClient) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.client = client
|
|
18
|
+
|
|
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
|
+
@abstractmethod
|
|
29
|
+
def _extraction_methods(self) -> list[Callable[[Any], Any]]:
|
|
30
|
+
"""
|
|
31
|
+
Abstract method that must be implemented by child classes.
|
|
32
|
+
This method should return an ordered list of extraction methods to call
|
|
33
|
+
"""
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def extraction_methods(self) -> list[Callable[[Any], Any]]:
|
|
37
|
+
"""
|
|
38
|
+
Parent class method used to return an ordered list of extraction methods to call
|
|
39
|
+
"""
|
|
40
|
+
return self._extraction_methods()
|
|
41
|
+
|
|
42
|
+
async def extract(self, *args: Any, **kwargs: Any) -> AsyncIterable[ExtractedItem]:
|
|
43
|
+
"""
|
|
44
|
+
Primary method to run the extractor.
|
|
45
|
+
"""
|
|
46
|
+
for extraction_method in self.extraction_methods():
|
|
47
|
+
async for item in extraction_method(*args, **kwargs):
|
|
48
|
+
yield item
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
from corekit.etl.schemas import BaseItem
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseExtractedItemModel(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
Parent model for extracted data. All extracted data models should inherit from this.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExtractedItem(BaseItem):
|
|
15
|
+
extracted_data: BaseExtractedItemModel
|
|
16
|
+
|
|
17
|
+
def get_data_type(self) -> type:
|
|
18
|
+
return type(self.extracted_data)
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from corekit.etl.load.schemas import LoadableBatch
|
|
4
|
+
from corekit.etl.transform.schemas import TransformedItem
|
|
5
|
+
from corekit.observability.loggable import Loggable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseETLLoader(Loggable, ABC):
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
super().__init__()
|
|
11
|
+
self.batch: LoadableBatch = LoadableBatch()
|
|
12
|
+
|
|
13
|
+
def _should_load(self, min_ops: int) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Whether the batch is worth writing out.
|
|
16
|
+
|
|
17
|
+
An empty batch never is: flush() passes min_ops=0 to mean "write
|
|
18
|
+
whatever is left", and without this an exhausted or empty source would
|
|
19
|
+
still cost a write.
|
|
20
|
+
"""
|
|
21
|
+
return bool(len(self.batch)) and len(self.batch) >= min_ops
|
|
22
|
+
|
|
23
|
+
def add_item(self, item: TransformedItem) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Adds an item to the batch
|
|
26
|
+
"""
|
|
27
|
+
self.batch.add_item(item)
|
|
28
|
+
|
|
29
|
+
async def flush(self) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Load whatever is in the batch, however small.
|
|
32
|
+
"""
|
|
33
|
+
await self.load(min_ops=0)
|
|
34
|
+
|
|
35
|
+
async def load(self, min_ops: int) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Load the batch if it has reached ``min_ops`` items.
|
|
38
|
+
|
|
39
|
+
The threshold is checked here so that implementations of ``_load`` only
|
|
40
|
+
have to handle writing a batch out.
|
|
41
|
+
"""
|
|
42
|
+
if not self._should_load(min_ops):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
await self._load(self.batch)
|
|
46
|
+
self.batch.clear()
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def _load(self, batch: LoadableBatch) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Write a batch to its destination.
|
|
52
|
+
"""
|
|
53
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Iterator
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from corekit.etl.transform.schemas import TransformedItem
|
|
6
|
+
from corekit.schemas.pydantic.fields import DefaultListField
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LoadableBatch(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
Parent model for batches of loadable data. All loadable data batch models should inherit from this.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
items: list[TransformedItem] = DefaultListField()
|
|
15
|
+
|
|
16
|
+
def __iter__(self) -> Iterator[TransformedItem]:
|
|
17
|
+
yield from self.items
|
|
18
|
+
|
|
19
|
+
def __len__(self) -> int:
|
|
20
|
+
return len(self.items)
|
|
21
|
+
|
|
22
|
+
def add_item(self, item: TransformedItem) -> None:
|
|
23
|
+
self.items.append(item)
|
|
24
|
+
|
|
25
|
+
def clear(self) -> None:
|
|
26
|
+
self.items.clear()
|
|
27
|
+
|
|
28
|
+
def stream(self) -> Iterator[TransformedItem]:
|
|
29
|
+
"""
|
|
30
|
+
Stream the items in the batch and clear the batch.
|
|
31
|
+
"""
|
|
32
|
+
yield from self
|
|
33
|
+
self.clear()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ETL pipeline orchestration.
|
|
3
|
+
|
|
4
|
+
A pipeline declares its three stages as class attributes and the orchestrator
|
|
5
|
+
assembles them::
|
|
6
|
+
|
|
7
|
+
class UserPipeline(BaseETLOrchestrator):
|
|
8
|
+
'''
|
|
9
|
+
Loads users from the API into the warehouse.
|
|
10
|
+
'''
|
|
11
|
+
extractor = UserExtractor
|
|
12
|
+
transformer = UserTransformer
|
|
13
|
+
loader = UserLoader
|
|
14
|
+
|
|
15
|
+
await UserPipeline().run()
|
|
16
|
+
|
|
17
|
+
The bracket form is shorthand for the same thing::
|
|
18
|
+
|
|
19
|
+
UserPipeline = BaseETLOrchestrator[UserExtractor, UserTransformer, UserLoader]
|
|
20
|
+
|
|
21
|
+
Stages are validated when the class is defined, so a wrong or missing stage is
|
|
22
|
+
reported at import rather than part-way through a run. Instances can still be
|
|
23
|
+
passed explicitly, which is how tests substitute doubles.
|
|
24
|
+
|
|
25
|
+
The run loop streams: items are extracted one at a time and loaded in batches,
|
|
26
|
+
so memory use is bounded by the batch size rather than the size of the source.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, ClassVar
|
|
31
|
+
|
|
32
|
+
from corekit.etl.extract.extractor import BaseETLExtractor
|
|
33
|
+
from corekit.etl.load.loader import BaseETLLoader
|
|
34
|
+
from corekit.etl.transform.transformer import BaseETLTransformer
|
|
35
|
+
from corekit.observability.loggable import Loggable
|
|
36
|
+
from corekit.registry import SmartRegistry
|
|
37
|
+
|
|
38
|
+
__all__ = ["BaseETLOrchestrator"]
|
|
39
|
+
|
|
40
|
+
DEFAULT_BATCH_SIZE = 100
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class PipelineStage:
|
|
45
|
+
"""
|
|
46
|
+
One stage of a pipeline: the attribute naming it, and what it must be.
|
|
47
|
+
|
|
48
|
+
Holds the validation for that stage as well as its description, so the
|
|
49
|
+
orchestrator asks each stage to check itself rather than unpacking a tuple
|
|
50
|
+
and reimplementing the rules inline.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
attribute: str
|
|
54
|
+
base_class: type
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def label(self) -> str:
|
|
58
|
+
"""
|
|
59
|
+
The stage's name as it reads in a message: "Extractor", "Loader".
|
|
60
|
+
"""
|
|
61
|
+
return self.base_class.__name__.removeprefix("BaseETL")
|
|
62
|
+
|
|
63
|
+
def declared_on(self, cls: type) -> Any:
|
|
64
|
+
"""
|
|
65
|
+
The stage as declared directly on a class, ignoring inheritance.
|
|
66
|
+
"""
|
|
67
|
+
return cls.__dict__.get(self.attribute)
|
|
68
|
+
|
|
69
|
+
def validate(self, cls: type) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Confirm a pipeline declares this stage correctly.
|
|
72
|
+
"""
|
|
73
|
+
stage = getattr(cls, self.attribute, None)
|
|
74
|
+
if stage is None:
|
|
75
|
+
raise TypeError(f"{cls.__name__} is missing its {self.attribute}. Set {self.attribute} = Your{self.label}.")
|
|
76
|
+
if not (isinstance(stage, type) and issubclass(stage, self.base_class)):
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"{cls.__name__}.{self.attribute} must be a {self.base_class.__name__} subclass, "
|
|
79
|
+
f"got {stage!r}. Assign the class itself, not an instance."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BaseETLOrchestrator(Loggable):
|
|
84
|
+
"""
|
|
85
|
+
Runs an extract, transform and load pipeline.
|
|
86
|
+
|
|
87
|
+
Subclasses set ``extractor``, ``transformer`` and ``loader`` to the classes
|
|
88
|
+
for each stage. Every concrete pipeline registers itself by name.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
extractor: ClassVar[type[BaseETLExtractor] | None] = None
|
|
92
|
+
transformer: ClassVar[type[BaseETLTransformer] | None] = None
|
|
93
|
+
loader: ClassVar[type[BaseETLLoader] | None] = None
|
|
94
|
+
batch_size: ClassVar[int] = DEFAULT_BATCH_SIZE
|
|
95
|
+
|
|
96
|
+
__registry__: SmartRegistry = SmartRegistry()
|
|
97
|
+
|
|
98
|
+
# Set on the intermediate class produced by the bracket form, which is not
|
|
99
|
+
# itself a usable pipeline and so must skip validation and registration.
|
|
100
|
+
__binding_only__: ClassVar[bool] = False
|
|
101
|
+
|
|
102
|
+
#: The stages every pipeline declares, in the order they run.
|
|
103
|
+
STAGES: ClassVar[list[PipelineStage]] = [
|
|
104
|
+
PipelineStage(attribute="extractor", base_class=BaseETLExtractor),
|
|
105
|
+
PipelineStage(attribute="transformer", base_class=BaseETLTransformer),
|
|
106
|
+
PipelineStage(attribute="loader", base_class=BaseETLLoader),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Validate and register a pipeline at class-definition time.
|
|
112
|
+
"""
|
|
113
|
+
super().__init_subclass__(**kwargs)
|
|
114
|
+
|
|
115
|
+
if cls.__dict__.get("__binding_only__"):
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# A subclass that declares no stages at all is an intermediate base,
|
|
119
|
+
# not a pipeline; only complete ones are validated and registered.
|
|
120
|
+
if not any(stage.declared_on(cls) for stage in cls.STAGES):
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
for stage in cls.STAGES:
|
|
124
|
+
stage.validate(cls)
|
|
125
|
+
|
|
126
|
+
BaseETLOrchestrator.__registry__[cls.__name__] = cls
|
|
127
|
+
|
|
128
|
+
def __class_getitem__(cls, item: tuple[type, ...]) -> Any:
|
|
129
|
+
"""
|
|
130
|
+
Bind the three stages, returning a pipeline class.
|
|
131
|
+
|
|
132
|
+
Sugar for declaring them as attributes; both forms produce the same class.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(item, tuple) or len(item) != 3:
|
|
135
|
+
raise TypeError(
|
|
136
|
+
f"{cls.__name__}[...] takes exactly three stages: {cls.__name__}[Extractor, Transformer, Loader]"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
bound = {stage.attribute: value for stage, value in zip(cls.STAGES, item, strict=True)}
|
|
140
|
+
return type(f"{cls.__name__}_{item[0].__name__}", (cls,), bound)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def get_pipeline_types(cls) -> SmartRegistry:
|
|
144
|
+
"""
|
|
145
|
+
Return the registry of known pipelines.
|
|
146
|
+
"""
|
|
147
|
+
return cls.__registry__
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def get_pipeline_by_name(cls, name: str) -> type["BaseETLOrchestrator"] | None:
|
|
151
|
+
"""
|
|
152
|
+
Look up a pipeline class by name, using the registry's normalization.
|
|
153
|
+
"""
|
|
154
|
+
return cls.__registry__.get(name)
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
extractor: BaseETLExtractor | None = None,
|
|
159
|
+
transformer: BaseETLTransformer | None = None,
|
|
160
|
+
loader: BaseETLLoader | None = None,
|
|
161
|
+
batch_size: int | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Build a pipeline, optionally overriding any stage with an instance.
|
|
165
|
+
"""
|
|
166
|
+
super().__init__()
|
|
167
|
+
self.batch_size = batch_size if batch_size is not None else type(self).batch_size
|
|
168
|
+
self._extractor = extractor
|
|
169
|
+
self._transformer = transformer
|
|
170
|
+
self._loader = loader
|
|
171
|
+
|
|
172
|
+
def _build(self, override: Any, name: str) -> Any:
|
|
173
|
+
"""
|
|
174
|
+
Return an explicitly supplied stage, or construct the declared class.
|
|
175
|
+
"""
|
|
176
|
+
if override is not None:
|
|
177
|
+
return override
|
|
178
|
+
stage_class = getattr(type(self), name, None)
|
|
179
|
+
if stage_class is None:
|
|
180
|
+
raise TypeError(f"{type(self).__name__} has no {name}. Declare one, or pass {name}=...")
|
|
181
|
+
return stage_class()
|
|
182
|
+
|
|
183
|
+
async def run(self) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Extract, transform and load until the source is exhausted.
|
|
186
|
+
|
|
187
|
+
Items are processed one at a time and loaded whenever the batch reaches
|
|
188
|
+
``batch_size``; whatever remains is flushed at the end.
|
|
189
|
+
"""
|
|
190
|
+
extractor = self._build(self._extractor, "extractor")
|
|
191
|
+
transformer = self._build(self._transformer, "transformer")
|
|
192
|
+
loader = self._build(self._loader, "loader")
|
|
193
|
+
|
|
194
|
+
count = 0
|
|
195
|
+
async for extracted_item in extractor.extract():
|
|
196
|
+
loader.add_item(transformer.transform(extracted_item))
|
|
197
|
+
await loader.load(min_ops=self.batch_size)
|
|
198
|
+
count += 1
|
|
199
|
+
|
|
200
|
+
await loader.flush()
|
|
201
|
+
self.info(f"{type(self).__name__} processed {count} items")
|
corekit/etl/schemas.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
from corekit.etl.connection import BaseConnection
|
|
4
|
+
from corekit.schemas.types import UnknownDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseItem(BaseModel):
|
|
8
|
+
"""
|
|
9
|
+
Base model for all ETL items
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
connection: BaseConnection
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RawAPIResponse(BaseItem):
|
|
16
|
+
"""
|
|
17
|
+
Base model for raw API responses
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
data: UnknownDict
|
|
21
|
+
response_time_ms: int
|
|
22
|
+
status_code: int
|