python-corekit 0.2.0__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/application.py +47 -9
- corekit/api/lifespan.py +26 -3
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +9 -0
- 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 +2 -1
- corekit/connections/sql/connection.py +39 -5
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +4 -0
- corekit/connections/sql/migration/operations.py +69 -2
- corekit/connections/sql/operations/base.py +11 -2
- corekit/connections/sql/operations/statements.py +25 -5
- corekit/connections/sql/table.py +7 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +3 -3
- corekit/data/expressions/comparison.py +19 -80
- corekit/data/expressions/expression.py +0 -32
- corekit/data/expressions/operator.py +13 -28
- corekit/data/stats.py +3 -0
- corekit/decorators/exception_handling.py +36 -8
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +14 -12
- 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 +24 -11
- corekit/exceptions/__init__.py +24 -9
- 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 +43 -5
- 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/runner.py +12 -1
- corekit/jobs/task.py +23 -2
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +9 -2
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +55 -2
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +2 -2
- corekit/registry/registry.py +55 -14
- corekit/schemas/enum.py +22 -1
- corekit/schemas/types.py +6 -1
- corekit/serialization/__init__.py +2 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +9 -2
- corekit/utils/collections.py +22 -13
- corekit/utils/payload.py +12 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/METADATA +7 -7
- 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.2.0.dist-info/RECORD +0 -143
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import re
|
|
2
1
|
from typing import Any, Iterable
|
|
3
2
|
|
|
4
3
|
from corekit.data.expressions.expression import Expression
|
|
@@ -18,9 +17,8 @@ class Comparison(Expression):
|
|
|
18
17
|
"""
|
|
19
18
|
Base for comparing two values (Equals, GreaterThan, IsIn, ...).
|
|
20
19
|
|
|
21
|
-
`left`, `right`, and `operator` are public so
|
|
22
|
-
|
|
23
|
-
what each node represents without evaluating it. The class itself
|
|
20
|
+
`left`, `right`, and `operator` are public so a translator can walk the
|
|
21
|
+
tree and read what each node represents without evaluating it. The class itself
|
|
24
22
|
(Equals, GreaterThan, etc.) is the canonical, type-safe identifier of
|
|
25
23
|
*which* operation a node represents, so a translator can dispatch on
|
|
26
24
|
isinstance(node, GreaterThan) rather than string-matching on symbol.
|
|
@@ -73,35 +71,11 @@ class Comparison(Expression):
|
|
|
73
71
|
"""
|
|
74
72
|
return isinstance(self.right, FieldExpression)
|
|
75
73
|
|
|
76
|
-
def
|
|
74
|
+
def _reject_sql(self, reason: str) -> None:
|
|
77
75
|
"""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
Rendering the right side would emit a field's *name* where a value was
|
|
81
|
-
meant, which is a silently wrong query rather than a failed one.
|
|
82
|
-
"""
|
|
83
|
-
if self.compares_fields:
|
|
84
|
-
raise NotImplementedError(f"Elasticsearch cannot express `{self.symbol}` between two fields")
|
|
85
|
-
|
|
86
|
-
def _reference(self, side: Any) -> str:
|
|
87
|
-
"""
|
|
88
|
-
One side as a Mongo field reference, e.g. ``$age``.
|
|
76
|
+
Refuse a form SQL cannot mean the same way Python does.
|
|
89
77
|
"""
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
93
|
-
if self.compares_fields:
|
|
94
|
-
return {"$expr": {self.operator.mongo: [self._reference(self.left), self._reference(self.right)]}}
|
|
95
|
-
return {self.get_left(target=Target.NAME): {self.operator.mongo: self.get_right()}}
|
|
96
|
-
|
|
97
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
98
|
-
self._reject_field_comparison()
|
|
99
|
-
# Elasticsearch range operators are the Mongo spelling without the `$`.
|
|
100
|
-
return {
|
|
101
|
-
self.operator.elasticsearch: {
|
|
102
|
-
self.get_left(target=Target.NAME): {self.operator.mongo.lstrip("$"): self.get_right()}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
78
|
+
raise NotImplementedError(f"SQL cannot express `{self.symbol}`: {reason}")
|
|
105
79
|
|
|
106
80
|
|
|
107
81
|
class Equals(Comparison):
|
|
@@ -110,16 +84,6 @@ class Equals(Comparison):
|
|
|
110
84
|
def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
|
|
111
85
|
return self.get_left(context, *args, **kwargs) == self.get_right(context, *args, **kwargs)
|
|
112
86
|
|
|
113
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
114
|
-
if self.compares_fields:
|
|
115
|
-
return super().to_mongo(*args, **kwargs)
|
|
116
|
-
# Equality is the one operator Mongo spells as a bare value.
|
|
117
|
-
return {self.get_left(target=Target.NAME): self.get_right()}
|
|
118
|
-
|
|
119
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
120
|
-
self._reject_field_comparison()
|
|
121
|
-
return {self.operator.elasticsearch: {self.get_left(target=Target.NAME): self.get_right()}}
|
|
122
|
-
|
|
123
87
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
124
88
|
return self.get_left(context) == self.get_right(context)
|
|
125
89
|
|
|
@@ -130,14 +94,15 @@ class NotEquals(Comparison):
|
|
|
130
94
|
def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
|
|
131
95
|
return self.get_left(context, *args, **kwargs) != self.get_right(context, *args, **kwargs)
|
|
132
96
|
|
|
133
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
134
|
-
self._reject_field_comparison()
|
|
135
|
-
return {
|
|
136
|
-
self.operator.elasticsearch: {self.get_left(target=Target.NAME): {self.operator.mongo: self.get_right()}}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
97
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
140
|
-
|
|
98
|
+
left = self.get_left(context)
|
|
99
|
+
# Python treats None != value as True. SQL treats NULL != value as
|
|
100
|
+
# unknown and drops the row, so a null column must be included.
|
|
101
|
+
if self.right is None:
|
|
102
|
+
return left.is_not(None)
|
|
103
|
+
if isinstance(self.right, FieldExpression):
|
|
104
|
+
return left != self.get_right(context)
|
|
105
|
+
return (left != self.get_right(context)) | left.is_(None)
|
|
141
106
|
|
|
142
107
|
|
|
143
108
|
class LessThan(Comparison):
|
|
@@ -186,17 +151,9 @@ class IsIn(Comparison):
|
|
|
186
151
|
def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
|
|
187
152
|
return self.get_left(context, *args, **kwargs) in self.get_right(context, *args, **kwargs)
|
|
188
153
|
|
|
189
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
190
|
-
if self.compares_fields:
|
|
191
|
-
return {"$expr": {self.operator.mongo: [self._reference(self.left), self._reference(self.right)]}}
|
|
192
|
-
# A list, not the stored frozenset: no driver can serialize a set.
|
|
193
|
-
return {self.get_left(target=Target.NAME): {self.operator.mongo: self._values()}}
|
|
194
|
-
|
|
195
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
196
|
-
self._reject_field_comparison()
|
|
197
|
-
return {self.operator.elasticsearch: {self.get_left(target=Target.NAME): self._values()}}
|
|
198
|
-
|
|
199
154
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
155
|
+
if self.compares_fields:
|
|
156
|
+
self._reject_sql("the right side is a field, and SQL IN needs a list of values")
|
|
200
157
|
return self.get_left(context).in_(self._values(context))
|
|
201
158
|
|
|
202
159
|
def _values(self, context: Any = None) -> list[Any]:
|
|
@@ -219,21 +176,10 @@ class Contains(Comparison):
|
|
|
219
176
|
def __call__(self, context: Any, *args: Any, **kwargs: Any) -> bool:
|
|
220
177
|
return self.get_right(context, *args, **kwargs) in self.get_left(context, *args, **kwargs)
|
|
221
178
|
|
|
222
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
223
|
-
if self.compares_fields:
|
|
224
|
-
# $regex is a query operator, not an aggregation one, so it cannot
|
|
225
|
-
# appear inside the $expr form that comparing two fields needs.
|
|
226
|
-
raise NotImplementedError("Mongo cannot express `contains` between two fields")
|
|
227
|
-
# $regex with the operand escaped, so a value containing regex
|
|
228
|
-
# metacharacters is matched literally rather than as a pattern.
|
|
229
|
-
return {self.get_left(target=Target.NAME): {self.operator.mongo: re.escape(str(self.get_right()))}}
|
|
230
|
-
|
|
231
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
232
|
-
self._reject_field_comparison()
|
|
233
|
-
return {self.operator.elasticsearch: {self.get_left(target=Target.NAME): f"*{self.get_right()}*"}}
|
|
234
|
-
|
|
235
179
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
236
|
-
|
|
180
|
+
# Python is case-sensitive `in`. SQL LIKE is case-insensitive for ASCII
|
|
181
|
+
# on SQLite, so compiling this would match a different set of rows.
|
|
182
|
+
self._reject_sql("substring match is case-sensitive in Python and LIKE is not")
|
|
237
183
|
|
|
238
184
|
|
|
239
185
|
class ValueExpression(Expression):
|
|
@@ -241,8 +187,7 @@ class ValueExpression(Expression):
|
|
|
241
187
|
Expression representing a single value to be compared.
|
|
242
188
|
|
|
243
189
|
The resulting tree (Equals/And/Or/... nodes) is also an inspectable structure,
|
|
244
|
-
not just a callable.
|
|
245
|
-
(SQL, MongoDB, Elasticsearch, etc.)
|
|
190
|
+
not just a callable. SQL is emitted through ``to_sqlalchemy``.
|
|
246
191
|
"""
|
|
247
192
|
|
|
248
193
|
def __init__(self, value: Any) -> None:
|
|
@@ -284,12 +229,6 @@ class ValueExpression(Expression):
|
|
|
284
229
|
"""
|
|
285
230
|
return self.value
|
|
286
231
|
|
|
287
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
288
|
-
return self.value
|
|
289
|
-
|
|
290
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
291
|
-
return self.value
|
|
292
|
-
|
|
293
232
|
|
|
294
233
|
class FieldExpression(ValueExpression):
|
|
295
234
|
"""
|
|
@@ -18,12 +18,6 @@ class Expression:
|
|
|
18
18
|
def __invert__(self) -> "Expression":
|
|
19
19
|
return Not(self)
|
|
20
20
|
|
|
21
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
22
|
-
raise NotImplementedError
|
|
23
|
-
|
|
24
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
25
|
-
raise NotImplementedError
|
|
26
|
-
|
|
27
21
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
28
22
|
"""
|
|
29
23
|
Compile to a SQLAlchemy clause against ``context``, a model class.
|
|
@@ -44,16 +38,6 @@ class And(Expression):
|
|
|
44
38
|
def __repr__(self) -> str:
|
|
45
39
|
return f"({self.left!r} & {self.right!r})"
|
|
46
40
|
|
|
47
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
48
|
-
return {"$and": [self.left.to_mongo(*args, **kwargs), self.right.to_mongo(*args, **kwargs)]}
|
|
49
|
-
|
|
50
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
51
|
-
return {
|
|
52
|
-
"bool": {
|
|
53
|
-
"must": [self.left.to_elasticsearch(*args, **kwargs), self.right.to_elasticsearch(*args, **kwargs)]
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
41
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
58
42
|
return self.left.to_sqlalchemy(context) & self.right.to_sqlalchemy(context)
|
|
59
43
|
|
|
@@ -69,16 +53,6 @@ class Or(Expression):
|
|
|
69
53
|
def __repr__(self) -> str:
|
|
70
54
|
return f"({self.left!r} | {self.right!r})"
|
|
71
55
|
|
|
72
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
73
|
-
return {"$or": [self.left.to_mongo(*args, **kwargs), self.right.to_mongo(*args, **kwargs)]}
|
|
74
|
-
|
|
75
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
76
|
-
return {
|
|
77
|
-
"bool": {
|
|
78
|
-
"should": [self.left.to_elasticsearch(*args, **kwargs), self.right.to_elasticsearch(*args, **kwargs)]
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
56
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
83
57
|
return self.left.to_sqlalchemy(context) | self.right.to_sqlalchemy(context)
|
|
84
58
|
|
|
@@ -93,11 +67,5 @@ class Not(Expression):
|
|
|
93
67
|
def __repr__(self) -> str:
|
|
94
68
|
return f"~{self.expr!r}"
|
|
95
69
|
|
|
96
|
-
def to_mongo(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
97
|
-
return {"$not": self.expr.to_mongo(*args, **kwargs)}
|
|
98
|
-
|
|
99
|
-
def to_elasticsearch(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str:
|
|
100
|
-
return {"bool": {"must_not": self.expr.to_elasticsearch(*args, **kwargs)}}
|
|
101
|
-
|
|
102
70
|
def to_sqlalchemy(self, context: Any) -> Any:
|
|
103
71
|
return ~self.expr.to_sqlalchemy(context)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
The operators a comparison can express
|
|
2
|
+
The operators a comparison can express.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from typing import NamedTuple
|
|
@@ -11,44 +11,29 @@ __all__ = ["Dialect", "Operator"]
|
|
|
11
11
|
|
|
12
12
|
class Dialect(NamedTuple):
|
|
13
13
|
"""
|
|
14
|
-
How one operator is spelled
|
|
14
|
+
How one operator is spelled for humans.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
mongo: The MongoDB query operator.
|
|
19
|
-
elasticsearch: The Elasticsearch clause the operator renders into.
|
|
16
|
+
SQL is compiled through SQLAlchemy, not spelled here. A backend that does
|
|
17
|
+
not exist yet should not get a dialect string until it has a renderer.
|
|
20
18
|
"""
|
|
21
19
|
|
|
22
20
|
symbol: str
|
|
23
|
-
mongo: str
|
|
24
|
-
elasticsearch: str
|
|
25
21
|
|
|
26
22
|
|
|
27
23
|
class Operator(ValidatingEnum):
|
|
28
24
|
"""
|
|
29
|
-
A comparison operator
|
|
30
|
-
|
|
31
|
-
Adding a backend means adding a field to ``Dialect``, not a method to
|
|
32
|
-
every comparison class.
|
|
25
|
+
A comparison operator, identified by the comparison class that uses it.
|
|
33
26
|
"""
|
|
34
27
|
|
|
35
|
-
EQUALS = Dialect("=="
|
|
36
|
-
NOT_EQUALS = Dialect("!="
|
|
37
|
-
LESS_THAN = Dialect("<"
|
|
38
|
-
LESS_THAN_OR_EQUALS = Dialect("<="
|
|
39
|
-
GREATER_THAN = Dialect(">"
|
|
40
|
-
GREATER_THAN_OR_EQUALS = Dialect(">="
|
|
41
|
-
IS_IN = Dialect("is in"
|
|
42
|
-
CONTAINS = Dialect("contains"
|
|
28
|
+
EQUALS = Dialect("==")
|
|
29
|
+
NOT_EQUALS = Dialect("!=")
|
|
30
|
+
LESS_THAN = Dialect("<")
|
|
31
|
+
LESS_THAN_OR_EQUALS = Dialect("<=")
|
|
32
|
+
GREATER_THAN = Dialect(">")
|
|
33
|
+
GREATER_THAN_OR_EQUALS = Dialect(">=")
|
|
34
|
+
IS_IN = Dialect("is in")
|
|
35
|
+
CONTAINS = Dialect("contains")
|
|
43
36
|
|
|
44
37
|
@property
|
|
45
38
|
def symbol(self) -> str:
|
|
46
39
|
return self.value.symbol
|
|
47
|
-
|
|
48
|
-
@property
|
|
49
|
-
def mongo(self) -> str:
|
|
50
|
-
return self.value.mongo
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def elasticsearch(self) -> str:
|
|
54
|
-
return self.value.elasticsearch
|
corekit/data/stats.py
CHANGED
|
@@ -88,6 +88,9 @@ class FieldDescription:
|
|
|
88
88
|
|
|
89
89
|
@property
|
|
90
90
|
def is_numeric(self) -> bool:
|
|
91
|
+
# all([]) is True, which would then call statistics.fmean([]) and crash.
|
|
92
|
+
if not self.non_missing:
|
|
93
|
+
return False
|
|
91
94
|
return all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in self.non_missing)
|
|
92
95
|
|
|
93
96
|
def to_field_stats(self) -> FieldStats:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import functools
|
|
2
|
+
import inspect
|
|
2
3
|
import logging
|
|
3
4
|
from typing import Any, Callable
|
|
4
5
|
|
|
@@ -8,6 +9,19 @@ from corekit.utils.coercion import safe_tuple
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
9
10
|
|
|
10
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
|
+
|
|
11
25
|
def exception_handler(
|
|
12
26
|
ignore: list[type[Exception]] | None = None,
|
|
13
27
|
callback: Callable = raise_exc,
|
|
@@ -16,6 +30,9 @@ def exception_handler(
|
|
|
16
30
|
"""
|
|
17
31
|
A decorator to handle exceptions in a class method or function.
|
|
18
32
|
|
|
33
|
+
Works on both sync and async functions. An async function keeps an async
|
|
34
|
+
wrapper, so awaiting it still works.
|
|
35
|
+
|
|
19
36
|
Args:
|
|
20
37
|
ignore: exception classes to suppress. These do not propagate.
|
|
21
38
|
callback: called when an exception is raised that is not ignored.
|
|
@@ -24,21 +41,32 @@ def exception_handler(
|
|
|
24
41
|
exceptions_to_ignore = safe_tuple(ignore)
|
|
25
42
|
|
|
26
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
|
+
|
|
27
54
|
@functools.wraps(func)
|
|
28
55
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
29
56
|
try:
|
|
30
57
|
return func(*args, **kwargs)
|
|
31
58
|
except Exception as ex:
|
|
32
|
-
|
|
33
|
-
if isinstance(ex, exceptions_to_ignore):
|
|
34
|
-
logger.warning(f"Suppressed exception in {func.__name__}: {ex}")
|
|
35
|
-
logger.info(f"Invoking callback function: {ignore_callback.__name__}")
|
|
36
|
-
return ignore_callback(ex, *args, **kwargs)
|
|
59
|
+
return handle(ex, args, kwargs)
|
|
37
60
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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)
|
|
41
67
|
|
|
68
|
+
if inspect.iscoroutinefunction(func):
|
|
69
|
+
return async_wrapper
|
|
42
70
|
return wrapper
|
|
43
71
|
|
|
44
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
|
@@ -7,24 +7,21 @@ class ConnectionDetails(NamedTuple):
|
|
|
7
7
|
description: str
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
class
|
|
10
|
+
class ETLSource(Enum):
|
|
11
11
|
"""
|
|
12
|
-
|
|
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.
|
|
13
17
|
|
|
14
18
|
Example:
|
|
15
|
-
class
|
|
16
|
-
|
|
17
|
-
name="my_first_connection",
|
|
18
|
-
description="My First Connection"
|
|
19
|
-
)
|
|
20
|
-
ANOTHER_CONNECTION = ConnectionDetails(
|
|
21
|
-
name="another_connection",
|
|
22
|
-
description="Another Connection"
|
|
23
|
-
)
|
|
19
|
+
class UserSource(ETLSource):
|
|
20
|
+
USERS = ConnectionDetails(name="users", description="User records")
|
|
24
21
|
"""
|
|
25
22
|
|
|
26
23
|
@classmethod
|
|
27
|
-
def from_name(cls, name: str) -> "
|
|
24
|
+
def from_name(cls, name: str) -> "ETLSource":
|
|
28
25
|
# TODO: Make sure this is efficient
|
|
29
26
|
for connection in cls:
|
|
30
27
|
if connection.value.name == name:
|
|
@@ -42,3 +39,8 @@ class BaseConnection(Enum):
|
|
|
42
39
|
Helper method for fetching the name of the connection
|
|
43
40
|
"""
|
|
44
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}")
|