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.
Files changed (125) hide show
  1. corekit/__init__.py +0 -0
  2. corekit/api/__init__.py +9 -0
  3. corekit/api/handler.py +76 -0
  4. corekit/api/responses.py +40 -0
  5. corekit/api/routers.py +115 -0
  6. corekit/concurrency/__init__.py +9 -0
  7. corekit/concurrency/decorators.py +72 -0
  8. corekit/concurrency/thread_local.py +99 -0
  9. corekit/concurrency/worker.py +65 -0
  10. corekit/config/__init__.py +47 -0
  11. corekit/config/loader.py +153 -0
  12. corekit/config/settings.py +161 -0
  13. corekit/config/sources.py +125 -0
  14. corekit/connections/__init__.py +31 -0
  15. corekit/connections/connectable.py +212 -0
  16. corekit/connections/decorators.py +92 -0
  17. corekit/connections/redis/__init__.py +7 -0
  18. corekit/connections/redis/connection.py +239 -0
  19. corekit/connections/registry.py +80 -0
  20. corekit/connections/sql/__init__.py +10 -0
  21. corekit/connections/sql/connection.py +342 -0
  22. corekit/connections/sql/fields/__init__.py +7 -0
  23. corekit/connections/sql/fields/jsonb.py +67 -0
  24. corekit/connections/sql/migration/__init__.py +57 -0
  25. corekit/connections/sql/migration/base.py +40 -0
  26. corekit/connections/sql/migration/operations.py +416 -0
  27. corekit/connections/sql/migration/registry.py +166 -0
  28. corekit/connections/sql/migration/table.py +27 -0
  29. corekit/connections/sql/query.py +68 -0
  30. corekit/connections/sql/table.py +96 -0
  31. corekit/constants.py +45 -0
  32. corekit/crypto/__init__.py +1 -0
  33. corekit/crypto/constants.py +7 -0
  34. corekit/crypto/enum.py +11 -0
  35. corekit/crypto/hasher.py +89 -0
  36. corekit/data/__init__.py +81 -0
  37. corekit/data/dataset.py +340 -0
  38. corekit/data/expressions/__init__.py +46 -0
  39. corekit/data/expressions/comparison.py +252 -0
  40. corekit/data/expressions/expression.py +98 -0
  41. corekit/data/record.py +147 -0
  42. corekit/data/stats.py +157 -0
  43. corekit/decorators/__init__.py +2 -0
  44. corekit/decorators/exception_handling.py +43 -0
  45. corekit/decorators/warnings.py +35 -0
  46. corekit/docker/__init__.py +7 -0
  47. corekit/docker/watchdog.py +222 -0
  48. corekit/etl/__init__.py +44 -0
  49. corekit/etl/connection.py +44 -0
  50. corekit/etl/extract/__init__.py +0 -0
  51. corekit/etl/extract/extractor.py +48 -0
  52. corekit/etl/extract/schemas.py +18 -0
  53. corekit/etl/load/__init__.py +0 -0
  54. corekit/etl/load/loader.py +53 -0
  55. corekit/etl/load/schemas.py +33 -0
  56. corekit/etl/orchestrator.py +201 -0
  57. corekit/etl/schemas.py +22 -0
  58. corekit/etl/transform/__init__.py +0 -0
  59. corekit/etl/transform/schemas.py +15 -0
  60. corekit/etl/transform/transformer.py +28 -0
  61. corekit/events/__init__.py +38 -0
  62. corekit/events/enum.py +58 -0
  63. corekit/events/frames.py +51 -0
  64. corekit/events/models.py +23 -0
  65. corekit/events/publisher.py +75 -0
  66. corekit/events/reader.py +132 -0
  67. corekit/events/sse.py +109 -0
  68. corekit/events/websocket.py +97 -0
  69. corekit/exceptions/__init__.py +0 -0
  70. corekit/exceptions/base.py +45 -0
  71. corekit/exceptions/custom/__init__.py +0 -0
  72. corekit/exceptions/http/__init__.py +0 -0
  73. corekit/exceptions/http/exceptions.py +37 -0
  74. corekit/exceptions/types.py +17 -0
  75. corekit/files/__init__.py +25 -0
  76. corekit/files/base.py +117 -0
  77. corekit/files/enum.py +30 -0
  78. corekit/files/json.py +12 -0
  79. corekit/files/pickle.py +12 -0
  80. corekit/files/toml.py +43 -0
  81. corekit/http/__init__.py +0 -0
  82. corekit/http/client.py +176 -0
  83. corekit/http/exponential_backoff.py +100 -0
  84. corekit/http/response.py +12 -0
  85. corekit/log_monitor/__init__.py +23 -0
  86. corekit/log_monitor/constants.py +8 -0
  87. corekit/log_monitor/models.py +150 -0
  88. corekit/log_monitor/service.py +418 -0
  89. corekit/notifications/__init__.py +8 -0
  90. corekit/notifications/base.py +51 -0
  91. corekit/notifications/models.py +34 -0
  92. corekit/observability/__init__.py +21 -0
  93. corekit/observability/benchmarkable.py +12 -0
  94. corekit/observability/loggable.py +29 -0
  95. corekit/observability/timing/__init__.py +0 -0
  96. corekit/observability/timing/constants.py +1 -0
  97. corekit/observability/timing/split.py +20 -0
  98. corekit/observability/timing/timer.py +30 -0
  99. corekit/py.typed +0 -0
  100. corekit/registry/__init__.py +12 -0
  101. corekit/registry/registry.py +134 -0
  102. corekit/schemas/__init__.py +0 -0
  103. corekit/schemas/dataclasses/__init__.py +0 -0
  104. corekit/schemas/enum.py +49 -0
  105. corekit/schemas/models/__init__.py +0 -0
  106. corekit/schemas/models/arbitrary.py +11 -0
  107. corekit/schemas/models/date_models.py +18 -0
  108. corekit/schemas/pydantic/__init__.py +0 -0
  109. corekit/schemas/pydantic/fields.py +35 -0
  110. corekit/schemas/types.py +40 -0
  111. corekit/serialization/__init__.py +0 -0
  112. corekit/serialization/enum.py +21 -0
  113. corekit/serialization/serializable.py +42 -0
  114. corekit/serialization/serializer.py +179 -0
  115. corekit/utils/__init__.py +5 -0
  116. corekit/utils/ids.py +5 -0
  117. corekit/utils/raise_exc.py +8 -0
  118. corekit/utils/time.py +21 -0
  119. corekit/utils/validators.py +15 -0
  120. corekit/utils/void.py +8 -0
  121. python_corekit-0.1.0.dist-info/METADATA +417 -0
  122. python_corekit-0.1.0.dist-info/RECORD +125 -0
  123. python_corekit-0.1.0.dist-info/WHEEL +5 -0
  124. python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
  125. 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}")
@@ -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