python-corekit 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- corekit/api/__init__.py +18 -3
- corekit/api/application.py +237 -0
- corekit/api/lifespan.py +210 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/worker.py +65 -65
- corekit/connections/sql/__init__.py +31 -3
- corekit/connections/sql/connection.py +19 -0
- corekit/connections/sql/migration/__init__.py +5 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +66 -42
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +102 -0
- corekit/connections/sql/operations/statements.py +150 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +30 -4
- corekit/constants.py +45 -45
- corekit/data/__init__.py +8 -0
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +184 -104
- corekit/data/expressions/expression.py +103 -98
- corekit/data/expressions/operator.py +54 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +159 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +2 -1
- corekit/etl/connection.py +44 -44
- corekit/events/websocket.py +3 -2
- corekit/exceptions/__init__.py +18 -0
- corekit/http/__init__.py +13 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +69 -0
- corekit/jobs/task.py +152 -0
- corekit/observability/__init__.py +5 -3
- corekit/observability/request_context.py +135 -0
- corekit/registry/__init__.py +11 -6
- corekit/registry/ordered.py +86 -0
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +49 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +40 -40
- corekit/serialization/__init__.py +22 -0
- corekit/serialization/serializer.py +1 -1
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +115 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +100 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/METADATA +98 -92
- {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/RECORD +62 -44
- {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.2.0.dist-info}/top_level.txt +0 -0
corekit/data/stats.py
CHANGED
|
@@ -1,157 +1,159 @@
|
|
|
1
|
-
import statistics
|
|
2
|
-
from collections import Counter
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from dataclasses import field as dataclass_field
|
|
5
|
-
from typing import Any, Iterator
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"""
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
1
|
+
import statistics
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import field as dataclass_field
|
|
5
|
+
from typing import Any, Iterator
|
|
6
|
+
|
|
7
|
+
from corekit.utils.coercion import safe_tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class FieldStats:
|
|
12
|
+
"""
|
|
13
|
+
Common shape shared by every field's statistics
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
field: Any
|
|
17
|
+
count: int
|
|
18
|
+
missing: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class NumericFieldStats(FieldStats):
|
|
23
|
+
"""
|
|
24
|
+
Stats for a field where every non-missing value is int/float (not bool)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
mean: float
|
|
28
|
+
min: Any
|
|
29
|
+
max: Any
|
|
30
|
+
stdev: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class CategoricalFieldStats(FieldStats):
|
|
35
|
+
"""
|
|
36
|
+
Stats for any field that isn't purely numeric
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
unique: int
|
|
40
|
+
top: Any
|
|
41
|
+
freq: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class ValueCounts:
|
|
46
|
+
"""
|
|
47
|
+
How often each distinct value of a field occurs, most frequent first
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
field: Any
|
|
51
|
+
counts: dict[Any, int]
|
|
52
|
+
|
|
53
|
+
def most_common(self, n: int | None = None) -> list[tuple[Any, int]]:
|
|
54
|
+
items = list(self.counts.items())
|
|
55
|
+
return items[:n] if n is not None else items
|
|
56
|
+
|
|
57
|
+
def top(self) -> tuple[Any, int] | None:
|
|
58
|
+
return next(iter(self.counts.items()), None)
|
|
59
|
+
|
|
60
|
+
def __iter__(self) -> Iterator[tuple[Any, int]]:
|
|
61
|
+
return iter(self.counts.items())
|
|
62
|
+
|
|
63
|
+
def __len__(self) -> int:
|
|
64
|
+
return len(self.counts)
|
|
65
|
+
|
|
66
|
+
def __getitem__(self, value: Any) -> int:
|
|
67
|
+
return self.counts[value]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True)
|
|
71
|
+
class FieldDescription:
|
|
72
|
+
field: Any
|
|
73
|
+
values: list[Any] = dataclass_field(default_factory=list)
|
|
74
|
+
non_missing: list[Any] = dataclass_field(default_factory=list)
|
|
75
|
+
num_values: int = dataclass_field(default=0)
|
|
76
|
+
num_non_missing: int = dataclass_field(default=0)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_data(cls, data: list[Any], field: Any) -> "FieldDescription":
|
|
80
|
+
instance = cls(field)
|
|
81
|
+
for record in data:
|
|
82
|
+
instance.add(record[field])
|
|
83
|
+
return instance
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def missing(self) -> int:
|
|
87
|
+
return self.num_values - self.num_non_missing
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_numeric(self) -> bool:
|
|
91
|
+
return all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in self.non_missing)
|
|
92
|
+
|
|
93
|
+
def to_field_stats(self) -> FieldStats:
|
|
94
|
+
if self.is_numeric:
|
|
95
|
+
return NumericFieldStats(
|
|
96
|
+
field=self.field,
|
|
97
|
+
count=self.num_non_missing,
|
|
98
|
+
missing=self.missing,
|
|
99
|
+
mean=statistics.fmean(self.non_missing),
|
|
100
|
+
min=min(self.non_missing),
|
|
101
|
+
max=max(self.non_missing),
|
|
102
|
+
stdev=statistics.stdev(self.non_missing) if self.num_non_missing > 1 else 0.0,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
counts = Counter(self.non_missing)
|
|
106
|
+
top_value, top_count = counts.most_common(1)[0] if counts else (None, 0)
|
|
107
|
+
return CategoricalFieldStats(
|
|
108
|
+
field=self.field,
|
|
109
|
+
count=self.num_non_missing,
|
|
110
|
+
missing=self.missing,
|
|
111
|
+
unique=len(counts),
|
|
112
|
+
top=top_value,
|
|
113
|
+
freq=top_count,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def add(self, value: Any) -> None:
|
|
117
|
+
self.values.append(value)
|
|
118
|
+
self.num_values += 1
|
|
119
|
+
if value is not None:
|
|
120
|
+
self.non_missing.append(value)
|
|
121
|
+
self.num_non_missing += 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DatasetStats:
|
|
125
|
+
"""
|
|
126
|
+
Read-only field statistics for a Dataset's current records.
|
|
127
|
+
|
|
128
|
+
Computed fresh on every call from the dataset's public interface (never
|
|
129
|
+
cached), so there's nothing here that can go stale after a mutation --
|
|
130
|
+
the same reasoning that ruled out persistent secondary indexes applies:
|
|
131
|
+
recomputing is cheap and always correct, caching is not free and can lie.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, data: list[Any], schema: tuple[Any, ...] | None = None) -> None:
|
|
135
|
+
self._data = data
|
|
136
|
+
self._schema = safe_tuple(schema)
|
|
137
|
+
|
|
138
|
+
def value_counts(self, field: Any) -> ValueCounts:
|
|
139
|
+
"""
|
|
140
|
+
How often each distinct value of `field` occurs, most frequent first
|
|
141
|
+
"""
|
|
142
|
+
counts = dict(Counter(rec[field] for rec in self._data).most_common())
|
|
143
|
+
return ValueCounts(field=field, counts=counts)
|
|
144
|
+
|
|
145
|
+
def describe(self, field: Any | None = None) -> dict[Any, FieldStats]:
|
|
146
|
+
"""
|
|
147
|
+
Summary statistics per field, pandas-.describe()-style.
|
|
148
|
+
|
|
149
|
+
Numeric fields (int/float, excluding bool) get a NumericFieldStats
|
|
150
|
+
(mean/min/max/stdev). Everything else gets a CategoricalFieldStats
|
|
151
|
+
(unique/top/freq). Pass `field` to describe just one; omit to
|
|
152
|
+
describe every field in the schema.
|
|
153
|
+
"""
|
|
154
|
+
fields = (field,) if field is not None else self._schema
|
|
155
|
+
stats = {}
|
|
156
|
+
for f in fields:
|
|
157
|
+
field_description = FieldDescription.from_data(self._data, f)
|
|
158
|
+
stats[f] = field_description.to_field_stats()
|
|
159
|
+
return stats
|
corekit/decorators/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
from .exception_handling import exception_handler
|
|
2
|
-
from .warnings import deprecated
|
|
1
|
+
from .exception_handling import exception_handler
|
|
2
|
+
from .warnings import deprecated
|
|
@@ -3,6 +3,7 @@ import logging
|
|
|
3
3
|
from typing import Any, Callable
|
|
4
4
|
|
|
5
5
|
from corekit.utils import raise_exc, void
|
|
6
|
+
from corekit.utils.coercion import safe_tuple
|
|
6
7
|
|
|
7
8
|
logger = logging.getLogger(__name__)
|
|
8
9
|
|
|
@@ -20,7 +21,7 @@ def exception_handler(
|
|
|
20
21
|
callback: called when an exception is raised that is not ignored.
|
|
21
22
|
ignore_callback: called when a suppressed exception is raised.
|
|
22
23
|
"""
|
|
23
|
-
exceptions_to_ignore =
|
|
24
|
+
exceptions_to_ignore = safe_tuple(ignore)
|
|
24
25
|
|
|
25
26
|
def decorator(func):
|
|
26
27
|
@functools.wraps(func)
|
corekit/etl/connection.py
CHANGED
|
@@ -1,44 +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
|
|
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
|
corekit/events/websocket.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import Any
|
|
|
13
13
|
from corekit.events.enum import MessageField
|
|
14
14
|
from corekit.events.reader import RedisChannelReader
|
|
15
15
|
from corekit.observability.loggable import Loggable
|
|
16
|
+
from corekit.utils.coercion import safe_dict, safe_list
|
|
16
17
|
|
|
17
18
|
__all__ = ["WebSocketBridge"]
|
|
18
19
|
|
|
@@ -44,7 +45,7 @@ class WebSocketBridge(Loggable):
|
|
|
44
45
|
super().__init__()
|
|
45
46
|
self.channel = channel
|
|
46
47
|
self.websocket = websocket
|
|
47
|
-
self.terminal_statuses = terminal_statuses
|
|
48
|
+
self.terminal_statuses = safe_list(terminal_statuses)
|
|
48
49
|
self.timeout = timeout
|
|
49
50
|
self._reader = RedisChannelReader(channel, url=url)
|
|
50
51
|
|
|
@@ -56,7 +57,7 @@ class WebSocketBridge(Loggable):
|
|
|
56
57
|
return False
|
|
57
58
|
if payload.get(MessageField.TYPE.value) != STATUS_UPDATE_EVENT:
|
|
58
59
|
return False
|
|
59
|
-
data = payload.get(MessageField.DATA.value)
|
|
60
|
+
data = safe_dict(payload.get(MessageField.DATA.value))
|
|
60
61
|
return data.get(MessageField.STATUS.value) in self.terminal_statuses
|
|
61
62
|
|
|
62
63
|
def _expired(self, started: float) -> bool:
|
corekit/exceptions/__init__.py
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception base classes.
|
|
3
|
+
|
|
4
|
+
``CustomException`` carries a message and an optional underlying error;
|
|
5
|
+
``CustomHTTPException`` is its FastAPI-facing counterpart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from corekit.exceptions.base import CustomException, CustomHTTPException, ExponentialBackoffTimeoutException
|
|
9
|
+
from corekit.exceptions.types import ArbitraryCustomExceptionType, CustomExceptionType, CustomHTTPExceptionType
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ArbitraryCustomExceptionType",
|
|
13
|
+
"CustomException",
|
|
14
|
+
"CustomExceptionType",
|
|
15
|
+
"CustomHTTPException",
|
|
16
|
+
"CustomHTTPExceptionType",
|
|
17
|
+
"ExponentialBackoffTimeoutException",
|
|
18
|
+
]
|
corekit/http/__init__.py
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client building blocks.
|
|
3
|
+
|
|
4
|
+
``BaseApiClient`` wraps httpx with retries; ``ExponentialBackoff`` is the retry
|
|
5
|
+
policy behind it and is useful on its own for any operation that should back off
|
|
6
|
+
rather than hammer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from corekit.http.client import BaseApiClient, URLMismatchError
|
|
10
|
+
from corekit.http.exponential_backoff import ExponentialBackoff
|
|
11
|
+
from corekit.http.response import BaseApiResponse
|
|
12
|
+
|
|
13
|
+
__all__ = ["BaseApiClient", "BaseApiResponse", "ExponentialBackoff", "URLMismatchError"]
|
corekit/jobs/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background tasks, independent of any queue.
|
|
3
|
+
|
|
4
|
+
Define a task by subclassing ``Task`` or ``ScheduledTask``; it registers itself
|
|
5
|
+
by name when the class is defined. A worker calls ``run_task(name, payload)``,
|
|
6
|
+
which builds the task from its own registry and runs it.
|
|
7
|
+
|
|
8
|
+
Nothing here imports a queue library. Bind these to RQ, Celery or anything else
|
|
9
|
+
with a small adapter, and keep the tasks themselves portable.
|
|
10
|
+
|
|
11
|
+
Arguments cross the queue as JSON, never as a serialized object: pickle and dill
|
|
12
|
+
execute code while loading, so a queue holding objects turns write access into
|
|
13
|
+
code execution in a worker.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from corekit.jobs.registry import TaskNotRegisteredError, task_registry
|
|
17
|
+
from corekit.jobs.runner import run_task
|
|
18
|
+
from corekit.jobs.task import ScheduledTask, Task
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ScheduledTask",
|
|
22
|
+
"Task",
|
|
23
|
+
"TaskNotRegisteredError",
|
|
24
|
+
"run_task",
|
|
25
|
+
"task_registry",
|
|
26
|
+
]
|
corekit/jobs/registry.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The task registry.
|
|
3
|
+
|
|
4
|
+
A worker rebuilds a task from a name, so the name has to resolve to a class in
|
|
5
|
+
the worker's *own* code. That is the whole security property: the queue chooses
|
|
6
|
+
which registered task runs, never what code exists.
|
|
7
|
+
|
|
8
|
+
``SmartRegistry`` normalizes keys, so ``"NightlyBackup"``, ``"nightly_backup"``
|
|
9
|
+
and ``"nightly backup"`` all address the same task.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from corekit.registry import SmartRegistry
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from corekit.jobs.task import Task
|
|
18
|
+
|
|
19
|
+
__all__ = ["TaskNotRegisteredError", "task_registry"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TaskNotRegisteredError(LookupError):
|
|
23
|
+
"""
|
|
24
|
+
Raised when a queued name matches no registered task.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _TaskRegistry:
|
|
29
|
+
"""
|
|
30
|
+
Every concrete ``Task`` subclass, keyed by name.
|
|
31
|
+
|
|
32
|
+
Registration happens when a subclass is defined, so importing the module
|
|
33
|
+
that declares a task is what makes it runnable. A worker must therefore
|
|
34
|
+
import its tasks before it starts consuming the queue.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._registry = SmartRegistry()
|
|
39
|
+
|
|
40
|
+
def register(self, task_class: type["Task"]) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Register a task class under its name.
|
|
43
|
+
"""
|
|
44
|
+
self._registry[task_class.task_name or task_class.__name__] = task_class
|
|
45
|
+
|
|
46
|
+
def get(self, name: str) -> type["Task"]:
|
|
47
|
+
"""
|
|
48
|
+
Look up a task class by name.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
TaskNotRegisteredError: If nothing is registered under that name.
|
|
52
|
+
Usually means the module declaring the task was never imported
|
|
53
|
+
by this worker.
|
|
54
|
+
"""
|
|
55
|
+
task_class = self._registry.get(name)
|
|
56
|
+
if task_class is None:
|
|
57
|
+
known = ", ".join(sorted(self._registry.keys())) or "none"
|
|
58
|
+
raise TaskNotRegisteredError(
|
|
59
|
+
f"No task registered as '{name}'. Import the module that defines it before "
|
|
60
|
+
f"running the worker. Registered: {known}."
|
|
61
|
+
)
|
|
62
|
+
return task_class
|
|
63
|
+
|
|
64
|
+
def build(self, name: str) -> "Task":
|
|
65
|
+
"""
|
|
66
|
+
Construct the task registered under ``name``.
|
|
67
|
+
"""
|
|
68
|
+
return self.get(name)()
|
|
69
|
+
|
|
70
|
+
def __contains__(self, name: str) -> bool:
|
|
71
|
+
return name in self._registry
|
|
72
|
+
|
|
73
|
+
def __len__(self) -> int:
|
|
74
|
+
return len(self._registry)
|
|
75
|
+
|
|
76
|
+
def __iter__(self) -> Any:
|
|
77
|
+
return iter(self._registry)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def names(self) -> list[str]:
|
|
81
|
+
"""
|
|
82
|
+
The registered task names, normalized and sorted.
|
|
83
|
+
"""
|
|
84
|
+
return sorted(self._registry.keys())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
task_registry = _TaskRegistry()
|