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.
Files changed (84) hide show
  1. corekit/api/application.py +47 -9
  2. corekit/api/lifespan.py +26 -3
  3. corekit/concurrency/__init__.py +2 -2
  4. corekit/concurrency/decorators.py +32 -5
  5. corekit/concurrency/thread_local.py +2 -2
  6. corekit/concurrency/worker.py +9 -0
  7. corekit/config/loader.py +42 -5
  8. corekit/config/settings.py +11 -1
  9. corekit/connections/__init__.py +7 -1
  10. corekit/connections/connectable.py +45 -4
  11. corekit/connections/redis/connection.py +53 -10
  12. corekit/connections/sql/__init__.py +2 -1
  13. corekit/connections/sql/connection.py +39 -5
  14. corekit/connections/sql/fields/__init__.py +2 -2
  15. corekit/connections/sql/fields/jsonb.py +13 -6
  16. corekit/connections/sql/migration/__init__.py +4 -0
  17. corekit/connections/sql/migration/operations.py +69 -2
  18. corekit/connections/sql/operations/base.py +11 -2
  19. corekit/connections/sql/operations/statements.py +25 -5
  20. corekit/connections/sql/table.py +7 -29
  21. corekit/crypto/__init__.py +3 -1
  22. corekit/crypto/constants.py +2 -2
  23. corekit/crypto/hasher.py +9 -4
  24. corekit/data/dataset.py +8 -2
  25. corekit/data/expressions/__init__.py +3 -3
  26. corekit/data/expressions/comparison.py +19 -80
  27. corekit/data/expressions/expression.py +0 -32
  28. corekit/data/expressions/operator.py +13 -28
  29. corekit/data/stats.py +3 -0
  30. corekit/decorators/exception_handling.py +36 -8
  31. corekit/docker/watchdog.py +50 -31
  32. corekit/etl/__init__.py +2 -1
  33. corekit/etl/connection.py +14 -12
  34. corekit/etl/extract/extractor.py +6 -13
  35. corekit/etl/orchestrator.py +19 -2
  36. corekit/etl/schemas.py +2 -2
  37. corekit/etl/transform/transformer.py +4 -1
  38. corekit/events/publisher.py +1 -1
  39. corekit/events/reader.py +26 -21
  40. corekit/events/sse.py +4 -1
  41. corekit/events/websocket.py +24 -11
  42. corekit/exceptions/__init__.py +24 -9
  43. corekit/exceptions/base.py +139 -10
  44. corekit/exceptions/enum.py +17 -0
  45. corekit/exceptions/types.py +6 -6
  46. corekit/files/__init__.py +2 -4
  47. corekit/files/base.py +15 -2
  48. corekit/files/enum.py +0 -5
  49. corekit/files/json.py +16 -2
  50. corekit/http/__init__.py +43 -5
  51. corekit/http/api.py +24 -0
  52. corekit/http/client.py +100 -73
  53. corekit/http/exceptions.py +140 -0
  54. corekit/http/response.py +50 -1
  55. corekit/http/status.py +89 -0
  56. corekit/jobs/runner.py +12 -1
  57. corekit/jobs/task.py +23 -2
  58. corekit/log_monitor/models.py +8 -2
  59. corekit/log_monitor/service.py +77 -38
  60. corekit/notifications/base.py +18 -10
  61. corekit/observability/__init__.py +9 -2
  62. corekit/observability/benchmarkable.py +23 -5
  63. corekit/observability/loggable.py +21 -0
  64. corekit/observability/request_context.py +55 -2
  65. corekit/observability/timing/timer.py +4 -2
  66. corekit/registry/__init__.py +2 -2
  67. corekit/registry/registry.py +55 -14
  68. corekit/schemas/enum.py +22 -1
  69. corekit/schemas/types.py +6 -1
  70. corekit/serialization/__init__.py +2 -0
  71. corekit/serialization/pickle_file.py +61 -0
  72. corekit/serialization/serializable.py +22 -2
  73. corekit/serialization/serializer.py +9 -2
  74. corekit/utils/collections.py +22 -13
  75. corekit/utils/payload.py +12 -0
  76. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/METADATA +7 -7
  77. python_corekit-0.3.0.dist-info/RECORD +145 -0
  78. corekit/constants.py +0 -45
  79. corekit/exceptions/http/exceptions.py +0 -37
  80. corekit/files/pickle.py +0 -12
  81. python_corekit-0.2.0.dist-info/RECORD +0 -143
  82. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  84. {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 external code -- e.g. a
22
- translator to SQL/Mongo/Elasticsearch -- can walk the tree and read out
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 _reject_field_comparison(self) -> None:
74
+ def _reject_sql(self, reason: str) -> None:
77
75
  """
78
- Guard a dialect whose clauses only compare a field to a literal.
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
- return f"${self.resolve(side, target=Target.NAME)}"
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
- return self.get_left(context) != self.get_right(context)
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
- return self.get_left(context).contains(self.get_right(context))
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. These could be used in the future to emit structured queries
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, and how each dialect spells them.
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 in each backend.
14
+ How one operator is spelled for humans.
15
15
 
16
- Args:
17
- symbol: The human-readable operator, used in ``repr``.
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 and its spelling in every dialect corekit renders to.
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("==", "$eq", "term")
36
- NOT_EQUALS = Dialect("!=", "$ne", "term")
37
- LESS_THAN = Dialect("<", "$lt", "range")
38
- LESS_THAN_OR_EQUALS = Dialect("<=", "$lte", "range")
39
- GREATER_THAN = Dialect(">", "$gt", "range")
40
- GREATER_THAN_OR_EQUALS = Dialect(">=", "$gte", "range")
41
- IS_IN = Dialect("is in", "$in", "terms")
42
- CONTAINS = Dialect("contains", "$regex", "wildcard")
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
- # If in ignore list, suppress and invoke the ignore_callback function
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
- # Log the exception and invoke the callback function, returning its return value (if any)
39
- logger.error(f"Exception in {func.__name__}: {ex}", exc_info=True)
40
- return callback(ex, *args, **kwargs)
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
@@ -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 = False) -> None:
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) -> None:
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
- else:
106
- self.info(f"Container {container.name!r} is already running or not managed.")
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) -> None:
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
- else:
117
- self.info(f"Container {container.name!r} is not running or not managed.")
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) -> None:
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 not None:
170
- self._start_container(container)
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) -> None:
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 not None:
178
- self._stop_container(container)
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) -> None:
185
+ def restart_container_by_name(self, name: str) -> bool:
181
186
  """
182
- Stop and then start one container by name.
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
- self._stop_container(container)
188
- self._start_container(container)
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) -> None:
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
- else:
203
- self.info(f"Container {container.name!r} is not running or not managed.")
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) -> None:
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
- else:
220
- self.info(f"Container {container.name!r} is not paused or not managed.")
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 BaseConnection(Enum):
10
+ class ETLSource(Enum):
11
11
  """
12
- Base enum for ETL Connections. All ETL connections must inherit from this class.
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 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
- )
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) -> "BaseConnection":
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
@@ -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
- Base class for ETL extractors.
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: BaseApiClient) -> None:
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
  """
@@ -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
- loader.add_item(transformer.transform(extracted_item))
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}")