python-corekit 0.1.1__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/__init__.py +18 -3
- corekit/api/application.py +275 -0
- corekit/api/lifespan.py +233 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +74 -65
- 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 +33 -4
- corekit/connections/sql/connection.py +56 -3
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +9 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +135 -44
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +111 -0
- corekit/connections/sql/operations/statements.py +170 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +33 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/__init__.py +8 -0
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +142 -123
- corekit/data/expressions/expression.py +71 -98
- corekit/data/expressions/operator.py +39 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +162 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +38 -9
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +46 -44
- 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 +27 -13
- corekit/exceptions/__init__.py +33 -0
- 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 +51 -0
- 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/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +80 -0
- corekit/jobs/task.py +173 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -3
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +188 -0
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +12 -7
- corekit/registry/ordered.py +86 -0
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +70 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +45 -40
- corekit/serialization/__init__.py +24 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +10 -3
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +124 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +112 -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.3.0.dist-info}/METADATA +103 -97
- 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.1.1.dist-info/RECORD +0 -125
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP-specific exceptions.
|
|
3
|
+
|
|
4
|
+
These live here, not in ``corekit.exceptions``, so the exception package does
|
|
5
|
+
not have to import this layer. The bases (``CoreHTTPException`` and the
|
|
6
|
+
retryable / non-retryable variants) still come from there.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from http import HTTPMethod
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from corekit.exceptions.base import (
|
|
13
|
+
InternalCoreException,
|
|
14
|
+
NonRetryableCoreHTTPException,
|
|
15
|
+
RetryableCoreHTTPException,
|
|
16
|
+
)
|
|
17
|
+
from corekit.exceptions.enum import Retryability
|
|
18
|
+
from corekit.http.status import HTTPStatusCode
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BadGatewayException",
|
|
22
|
+
"BadRequestException",
|
|
23
|
+
"ConflictErrorException",
|
|
24
|
+
"ForbiddenException",
|
|
25
|
+
"GatewayTimeoutException",
|
|
26
|
+
"InternalServerErrorException",
|
|
27
|
+
"NotFoundException",
|
|
28
|
+
"ServiceUnavailableException",
|
|
29
|
+
"TooManyRequestsException",
|
|
30
|
+
"UnauthorizedException",
|
|
31
|
+
"UnprocessableEntityException",
|
|
32
|
+
"UnsupportedMediaTypeException",
|
|
33
|
+
"UnsupportedMethodError",
|
|
34
|
+
"URLMismatchError",
|
|
35
|
+
"RETRYABLE_STATUS_CODES",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _CodedHTTPException:
|
|
40
|
+
"""
|
|
41
|
+
Sets ``status_code`` from the class. The code is the type, so a caller
|
|
42
|
+
cannot pass a different one and have a ``GatewayTimeoutException`` that is
|
|
43
|
+
actually a 200.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
code: HTTPStatusCode
|
|
47
|
+
|
|
48
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
49
|
+
if "status_code" in kwargs:
|
|
50
|
+
raise TypeError(f"{type(self).__name__} does not accept status_code; it is always {int(self.code)}")
|
|
51
|
+
super().__init__(status_code=self.code, **kwargs)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BadRequestException(_CodedHTTPException, NonRetryableCoreHTTPException):
|
|
55
|
+
code = HTTPStatusCode.BAD_REQUEST
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UnauthorizedException(_CodedHTTPException, NonRetryableCoreHTTPException):
|
|
59
|
+
code = HTTPStatusCode.UNAUTHORIZED
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ForbiddenException(_CodedHTTPException, NonRetryableCoreHTTPException):
|
|
63
|
+
code = HTTPStatusCode.FORBIDDEN
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NotFoundException(_CodedHTTPException, NonRetryableCoreHTTPException):
|
|
67
|
+
code = HTTPStatusCode.NOT_FOUND
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConflictErrorException(_CodedHTTPException, NonRetryableCoreHTTPException):
|
|
71
|
+
code = HTTPStatusCode.CONFLICT
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UnsupportedMediaTypeException(_CodedHTTPException, NonRetryableCoreHTTPException):
|
|
75
|
+
code = HTTPStatusCode.UNSUPPORTED_MEDIA_TYPE
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class UnprocessableEntityException(_CodedHTTPException, NonRetryableCoreHTTPException):
|
|
79
|
+
code = HTTPStatusCode.UNPROCESSABLE_ENTITY
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TooManyRequestsException(_CodedHTTPException, RetryableCoreHTTPException):
|
|
83
|
+
code = HTTPStatusCode.TOO_MANY_REQUESTS
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class InternalServerErrorException(_CodedHTTPException, RetryableCoreHTTPException):
|
|
87
|
+
code = HTTPStatusCode.INTERNAL_SERVER_ERROR
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BadGatewayException(_CodedHTTPException, RetryableCoreHTTPException):
|
|
91
|
+
code = HTTPStatusCode.BAD_GATEWAY
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ServiceUnavailableException(_CodedHTTPException, RetryableCoreHTTPException):
|
|
95
|
+
code = HTTPStatusCode.SERVICE_UNAVAILABLE
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GatewayTimeoutException(_CodedHTTPException, RetryableCoreHTTPException):
|
|
99
|
+
code = HTTPStatusCode.GATEWAY_TIMEOUT
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Statuses worth retrying: the server is busy or briefly unavailable, so the
|
|
103
|
+
# same request may well succeed shortly. A 4xx other than 429 will not.
|
|
104
|
+
# Derived from the typed exceptions so this set cannot drift from retryability.
|
|
105
|
+
RETRYABLE_STATUS_CODES = frozenset(
|
|
106
|
+
{
|
|
107
|
+
TooManyRequestsException.code,
|
|
108
|
+
InternalServerErrorException.code,
|
|
109
|
+
BadGatewayException.code,
|
|
110
|
+
ServiceUnavailableException.code,
|
|
111
|
+
GatewayTimeoutException.code,
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class URLMismatchError(InternalCoreException):
|
|
117
|
+
"""
|
|
118
|
+
Raised when an absolute URL does not share the client's base URL.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, url: str, base_url: str) -> None:
|
|
122
|
+
super().__init__(
|
|
123
|
+
message=f"URL {url} does not match base URL {base_url}",
|
|
124
|
+
retryable=Retryability.NON_RETRYABLE,
|
|
125
|
+
)
|
|
126
|
+
self.url = url
|
|
127
|
+
self.base_url = base_url
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class UnsupportedMethodError(InternalCoreException):
|
|
131
|
+
"""
|
|
132
|
+
Raised when a client is asked for an HTTP method it does not implement.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, method: HTTPMethod) -> None:
|
|
136
|
+
super().__init__(
|
|
137
|
+
message=f"Method {method} is not supported",
|
|
138
|
+
retryable=Retryability.NON_RETRYABLE,
|
|
139
|
+
)
|
|
140
|
+
self.method = method
|
corekit/http/response.py
CHANGED
|
@@ -1,12 +1,61 @@
|
|
|
1
1
|
from pydantic import BaseModel, Field
|
|
2
2
|
|
|
3
|
+
from corekit.exceptions import CoreHTTPException
|
|
4
|
+
from corekit.http.exceptions import (
|
|
5
|
+
BadGatewayException,
|
|
6
|
+
ConflictErrorException,
|
|
7
|
+
ForbiddenException,
|
|
8
|
+
GatewayTimeoutException,
|
|
9
|
+
InternalServerErrorException,
|
|
10
|
+
NotFoundException,
|
|
11
|
+
ServiceUnavailableException,
|
|
12
|
+
TooManyRequestsException,
|
|
13
|
+
UnauthorizedException,
|
|
14
|
+
UnprocessableEntityException,
|
|
15
|
+
)
|
|
16
|
+
from corekit.http.status import HTTPStatusCode
|
|
3
17
|
from corekit.schemas.pydantic.fields import DefaultDictField
|
|
4
18
|
from corekit.schemas.types import AnyDict
|
|
5
19
|
|
|
6
20
|
|
|
7
21
|
class BaseApiResponse(BaseModel):
|
|
8
|
-
status_code: int = Field(..., description="HTTP status code")
|
|
22
|
+
status_code: HTTPStatusCode | int = Field(..., description="HTTP status code")
|
|
9
23
|
text: str = Field(..., description="Response text")
|
|
10
24
|
data: AnyDict = DefaultDictField()
|
|
11
25
|
headers: AnyDict = DefaultDictField()
|
|
12
26
|
cookies: AnyDict = DefaultDictField()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def ok(self) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Whether the status is below 400. A failure is still a response.
|
|
32
|
+
"""
|
|
33
|
+
return int(self.status_code) < 400
|
|
34
|
+
|
|
35
|
+
def raise_for_status(self) -> "BaseApiResponse":
|
|
36
|
+
"""
|
|
37
|
+
Raise a typed HTTP exception for a failure status.
|
|
38
|
+
|
|
39
|
+
Opt-in. ``request`` still returns the failure so existing callers that
|
|
40
|
+
read ``status_code`` keep working.
|
|
41
|
+
"""
|
|
42
|
+
if self.ok:
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
typed = {
|
|
46
|
+
int(UnauthorizedException.code): UnauthorizedException,
|
|
47
|
+
int(ForbiddenException.code): ForbiddenException,
|
|
48
|
+
int(NotFoundException.code): NotFoundException,
|
|
49
|
+
int(ConflictErrorException.code): ConflictErrorException,
|
|
50
|
+
int(UnprocessableEntityException.code): UnprocessableEntityException,
|
|
51
|
+
int(TooManyRequestsException.code): TooManyRequestsException,
|
|
52
|
+
int(InternalServerErrorException.code): InternalServerErrorException,
|
|
53
|
+
int(BadGatewayException.code): BadGatewayException,
|
|
54
|
+
int(ServiceUnavailableException.code): ServiceUnavailableException,
|
|
55
|
+
int(GatewayTimeoutException.code): GatewayTimeoutException,
|
|
56
|
+
}
|
|
57
|
+
detail = self.text or f"HTTP {int(self.status_code)}"
|
|
58
|
+
exception_type = typed.get(int(self.status_code))
|
|
59
|
+
if exception_type is None:
|
|
60
|
+
raise CoreHTTPException(status_code=int(self.status_code), detail=detail)
|
|
61
|
+
raise exception_type(detail=detail)
|
corekit/http/status.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP codes
|
|
3
|
+
See HTTP Status Code Registry:
|
|
4
|
+
https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
|
|
5
|
+
|
|
6
|
+
And RFC 2324 - https://tools.ietf.org/html/rfc2324
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from corekit.schemas import IntegerEnum
|
|
10
|
+
|
|
11
|
+
__all__ = ["HTTPStatusCode"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HTTPStatusCode(IntegerEnum):
|
|
15
|
+
CONTINUE = 100
|
|
16
|
+
SWITCHING_PROTOCOLS = 101
|
|
17
|
+
PROCESSING = 102
|
|
18
|
+
EARLY_HINTS = 103
|
|
19
|
+
OK = 200
|
|
20
|
+
CREATED = 201
|
|
21
|
+
ACCEPTED = 202
|
|
22
|
+
NON_AUTHORITATIVE_INFORMATION = 203
|
|
23
|
+
NO_CONTENT = 204
|
|
24
|
+
RESET_CONTENT = 205
|
|
25
|
+
PARTIAL_CONTENT = 206
|
|
26
|
+
MULTI_STATUS = 207
|
|
27
|
+
ALREADY_REPORTED = 208
|
|
28
|
+
IM_USED = 226
|
|
29
|
+
MULTIPLE_CHOICES = 300
|
|
30
|
+
MOVED_PERMANENTLY = 301
|
|
31
|
+
FOUND = 302
|
|
32
|
+
SEE_OTHER = 303
|
|
33
|
+
NOT_MODIFIED = 304
|
|
34
|
+
USE_PROXY = 305
|
|
35
|
+
RESERVED = 306
|
|
36
|
+
TEMPORARY_REDIRECT = 307
|
|
37
|
+
PERMANENT_REDIRECT = 308
|
|
38
|
+
BAD_REQUEST = 400
|
|
39
|
+
UNAUTHORIZED = 401
|
|
40
|
+
PAYMENT_REQUIRED = 402
|
|
41
|
+
FORBIDDEN = 403
|
|
42
|
+
NOT_FOUND = 404
|
|
43
|
+
METHOD_NOT_ALLOWED = 405
|
|
44
|
+
NOT_ACCEPTABLE = 406
|
|
45
|
+
PROXY_AUTHENTICATION_REQUIRED = 407
|
|
46
|
+
REQUEST_TIMEOUT = 408
|
|
47
|
+
CONFLICT = 409
|
|
48
|
+
GONE = 410
|
|
49
|
+
LENGTH_REQUIRED = 411
|
|
50
|
+
PRECONDITION_FAILED = 412
|
|
51
|
+
REQUEST_ENTITY_TOO_LARGE = 413
|
|
52
|
+
REQUEST_URI_TOO_LONG = 414
|
|
53
|
+
UNSUPPORTED_MEDIA_TYPE = 415
|
|
54
|
+
REQUESTED_RANGE_NOT_SATISFIABLE = 416
|
|
55
|
+
EXPECTATION_FAILED = 417
|
|
56
|
+
IM_A_TEAPOT = 418
|
|
57
|
+
MISDIRECTED_REQUEST = 421
|
|
58
|
+
UNPROCESSABLE_ENTITY = 422
|
|
59
|
+
LOCKED = 423
|
|
60
|
+
FAILED_DEPENDENCY = 424
|
|
61
|
+
TOO_EARLY = 425
|
|
62
|
+
UPGRADE_REQUIRED = 426
|
|
63
|
+
PRECONDITION_REQUIRED = 428
|
|
64
|
+
TOO_MANY_REQUESTS = 429
|
|
65
|
+
REQUEST_HEADER_FIELDS_TOO_LARGE = 431
|
|
66
|
+
UNAVAILABLE_FOR_LEGAL_REASONS = 451
|
|
67
|
+
INTERNAL_SERVER_ERROR = 500
|
|
68
|
+
NOT_IMPLEMENTED = 501
|
|
69
|
+
BAD_GATEWAY = 502
|
|
70
|
+
SERVICE_UNAVAILABLE = 503
|
|
71
|
+
GATEWAY_TIMEOUT = 504
|
|
72
|
+
HTTP_VERSION_NOT_SUPPORTED = 505
|
|
73
|
+
VARIANT_ALSO_NEGOTIATES = 506
|
|
74
|
+
INSUFFICIENT_STORAGE = 507
|
|
75
|
+
LOOP_DETECTED = 508
|
|
76
|
+
NOT_EXTENDED = 510
|
|
77
|
+
NETWORK_AUTHENTICATION_REQUIRED = 511
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def coerce(cls, code: int) -> "HTTPStatusCode | int":
|
|
81
|
+
"""
|
|
82
|
+
Return the enum member for a known status, or the raw code otherwise.
|
|
83
|
+
|
|
84
|
+
Proxies and CDNs emit statuses this registry does not name. Callers
|
|
85
|
+
should still get the numeric code rather than a failed response parse.
|
|
86
|
+
"""
|
|
87
|
+
if cls.is_member(code):
|
|
88
|
+
return cls.from_int(code)
|
|
89
|
+
return code
|
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()
|
corekit/jobs/runner.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The worker entrypoint.
|
|
3
|
+
|
|
4
|
+
``run_task`` is what a queue calls. It takes a name and a JSON payload, builds
|
|
5
|
+
the task from the worker's own registry and runs it -- so a queue entry selects
|
|
6
|
+
among the tasks this worker already has, and cannot introduce code.
|
|
7
|
+
|
|
8
|
+
Point the queue at this function rather than at a bound method::
|
|
9
|
+
|
|
10
|
+
queue.enqueue(run_task, "NightlyBackup", task.payload("db"))
|
|
11
|
+
|
|
12
|
+
Enqueueing a bound method makes the queue serialize the instance, which is the
|
|
13
|
+
thing worth avoiding.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import inspect
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from corekit.jobs.registry import task_registry
|
|
22
|
+
from corekit.utils.payload import decode_payload
|
|
23
|
+
|
|
24
|
+
__all__ = ["run_task"]
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_task(task_name: str, payload: str | bytes | None = None) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Build the named task and run it.
|
|
32
|
+
|
|
33
|
+
``success`` runs after ``task_function`` returns; ``failure`` runs if it
|
|
34
|
+
raises, and the original exception is re-raised so the queue records the
|
|
35
|
+
job as failed. A hook that raises does not mask the original error.
|
|
36
|
+
``last_result`` and ``last_error`` are set on the task before the hook
|
|
37
|
+
runs. A coroutine ``task_function`` is awaited. ``timeout`` is not applied
|
|
38
|
+
here; the scheduling adapter owns that.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
task_name: The registered name of the task to run.
|
|
42
|
+
payload: JSON arguments from ``Task.payload``. Empty means no arguments.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Whatever ``task_function`` returns.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
TaskNotRegisteredError: If no task is registered under that name.
|
|
49
|
+
ValueError: If the payload is not the expected JSON object.
|
|
50
|
+
"""
|
|
51
|
+
args, kwargs = decode_payload(payload)
|
|
52
|
+
task = task_registry.build(task_name)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
result = task.task_function(*args, **kwargs)
|
|
56
|
+
if inspect.iscoroutine(result):
|
|
57
|
+
result = asyncio.run(result)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
task.last_result = None
|
|
60
|
+
task.last_error = exc
|
|
61
|
+
_run_hook(task, "failure", args, kwargs)
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
task.last_result = result
|
|
65
|
+
task.last_error = None
|
|
66
|
+
_run_hook(task, "success", args, kwargs)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_hook(task: Any, hook_name: str, args: list[Any], kwargs: dict[str, Any]) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Run a success or failure hook without letting it replace the real outcome.
|
|
73
|
+
|
|
74
|
+
A raising ``failure`` hook would otherwise mask the exception that caused
|
|
75
|
+
it, leaving the job recorded under the wrong error.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
getattr(task, hook_name)(*args, **kwargs)
|
|
79
|
+
except Exception:
|
|
80
|
+
logger.exception(f"Task {task.name} '{hook_name}' hook raised; the task's own outcome stands")
|
corekit/jobs/task.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background tasks, independent of any queue.
|
|
3
|
+
|
|
4
|
+
``Task`` describes work and how to name it; nothing here imports a queue
|
|
5
|
+
library, so the same task runs under RQ, Celery, a cron entry or a test with no
|
|
6
|
+
queue at all. An adapter binds it to whichever backend an application uses --
|
|
7
|
+
see ``corekit.jobs.runner``.
|
|
8
|
+
|
|
9
|
+
A task is identified by name and constructed by the worker from its own code::
|
|
10
|
+
|
|
11
|
+
class NightlyBackup(ScheduledTask):
|
|
12
|
+
interval = 86400
|
|
13
|
+
|
|
14
|
+
def task_function(self, target: str) -> None:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
The queue carries ``("NightlyBackup", '{"args": ["db"], "kwargs": {}}')`` and
|
|
18
|
+
never a serialized object, so writing to the queue cannot run arbitrary code in
|
|
19
|
+
a worker.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import abc
|
|
23
|
+
import logging
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from corekit.jobs.registry import task_registry
|
|
28
|
+
from corekit.utils.payload import encode_payload
|
|
29
|
+
|
|
30
|
+
__all__ = ["ScheduledTask", "Task"]
|
|
31
|
+
|
|
32
|
+
DEFAULT_TASK_TIMEOUT = 180
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _declared_abstract_methods(cls: type) -> tuple[str, ...]:
|
|
36
|
+
"""
|
|
37
|
+
Names marked abstract anywhere in the class's ancestry.
|
|
38
|
+
|
|
39
|
+
Needed because ABCMeta has not yet populated ``__abstractmethods__`` when
|
|
40
|
+
``__init_subclass__`` runs on the class being defined.
|
|
41
|
+
"""
|
|
42
|
+
names = set()
|
|
43
|
+
for klass in cls.__mro__:
|
|
44
|
+
for name, value in vars(klass).items():
|
|
45
|
+
if getattr(value, "__isabstractmethod__", False):
|
|
46
|
+
names.add(name)
|
|
47
|
+
return tuple(names)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Task(abc.ABC):
|
|
51
|
+
"""
|
|
52
|
+
A unit of background work.
|
|
53
|
+
|
|
54
|
+
Subclasses implement ``task_function`` and are registered by class name when
|
|
55
|
+
the class is defined, so a worker can rebuild one from a queued name.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
#: Overrides the class name as the registered identity.
|
|
59
|
+
task_name: str | None = None
|
|
60
|
+
|
|
61
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Register every concrete subclass under its name.
|
|
64
|
+
|
|
65
|
+
Abstract subclasses are skipped -- an intermediate base with methods
|
|
66
|
+
still unimplemented is a shared parent, not a task a worker can run.
|
|
67
|
+
``__abstractmethods__`` is set by ABCMeta *after* this hook runs, so the
|
|
68
|
+
class's own namespace is inspected instead.
|
|
69
|
+
"""
|
|
70
|
+
super().__init_subclass__(**kwargs)
|
|
71
|
+
if not cls._is_abstract():
|
|
72
|
+
task_registry.register(cls)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def _is_abstract(cls) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Whether this class still leaves an abstract method unimplemented.
|
|
78
|
+
"""
|
|
79
|
+
for name in getattr(cls, "__abstractmethods__", ()) or _declared_abstract_methods(cls):
|
|
80
|
+
attribute = getattr(cls, name, None)
|
|
81
|
+
if getattr(attribute, "__isabstractmethod__", False):
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def __init__(self, name: str | None = None) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Build the task. ``name`` overrides the registered identity for logging.
|
|
88
|
+
"""
|
|
89
|
+
self.name = name or self.task_name or type(self).__name__
|
|
90
|
+
self.logger = logging.getLogger(type(self).__module__)
|
|
91
|
+
self.last_result: Any = None
|
|
92
|
+
self.last_error: BaseException | None = None
|
|
93
|
+
|
|
94
|
+
@abc.abstractmethod
|
|
95
|
+
def task_function(self, *args: Any, **kwargs: Any) -> None:
|
|
96
|
+
"""
|
|
97
|
+
The work to perform. Arguments arrive as JSON-decoded data.
|
|
98
|
+
"""
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
def success(self, *args: Any, **kwargs: Any) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Called after ``task_function`` returns. Override to add behaviour.
|
|
104
|
+
|
|
105
|
+
``last_result`` is set before this runs. The call signature stays
|
|
106
|
+
``(*args, **kwargs)`` so an existing override is not broken.
|
|
107
|
+
"""
|
|
108
|
+
self.logger.info(f"Task {self.name} completed successfully")
|
|
109
|
+
|
|
110
|
+
def failure(self, *args: Any, **kwargs: Any) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Called when ``task_function`` raises. Override to add behaviour.
|
|
113
|
+
|
|
114
|
+
``last_error`` is set before this runs. The call signature stays
|
|
115
|
+
``(*args, **kwargs)`` so an existing override is not broken.
|
|
116
|
+
"""
|
|
117
|
+
self.logger.error(f"Task {self.name} failed")
|
|
118
|
+
|
|
119
|
+
def payload(self, *args: Any, **kwargs: Any) -> str:
|
|
120
|
+
"""
|
|
121
|
+
Encode arguments for the queue, raising now if they cannot cross it.
|
|
122
|
+
"""
|
|
123
|
+
return encode_payload(args, kwargs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ScheduledTask(Task):
|
|
127
|
+
"""
|
|
128
|
+
A task that runs on a schedule.
|
|
129
|
+
|
|
130
|
+
``interval``, ``repeat``, ``timeout``, and ``schedule_delay`` are for the
|
|
131
|
+
adapter that talks to a scheduler. ``run_task`` does not apply ``timeout``
|
|
132
|
+
and does not sleep for ``interval`` — wrapping a still-running queue job
|
|
133
|
+
would change production workers. Copy ``schedule_delay`` per instance so
|
|
134
|
+
one task cannot mutate the shared class dict.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
interval: int | None = None
|
|
138
|
+
repeat: int | None = None
|
|
139
|
+
timeout: int = DEFAULT_TASK_TIMEOUT
|
|
140
|
+
schedule_delay: dict[str, int] = {"hours": 1}
|
|
141
|
+
start_time: datetime | None = None
|
|
142
|
+
|
|
143
|
+
#: Default arguments, encoded into the payload when the task is scheduled.
|
|
144
|
+
args: list[Any] | None = None
|
|
145
|
+
kwargs: dict[str, Any] | None = None
|
|
146
|
+
|
|
147
|
+
def __init__(self, name: str | None = None) -> None:
|
|
148
|
+
super().__init__(name)
|
|
149
|
+
# Class attributes are shared. Replace them on the instance so a later
|
|
150
|
+
# in-place edit cannot change every other scheduled task.
|
|
151
|
+
self.schedule_delay = dict(self.schedule_delay)
|
|
152
|
+
if self.args is not None:
|
|
153
|
+
self.args = list(self.args)
|
|
154
|
+
if self.kwargs is not None:
|
|
155
|
+
self.kwargs = dict(self.kwargs)
|
|
156
|
+
|
|
157
|
+
def get_start_time(self) -> datetime:
|
|
158
|
+
"""
|
|
159
|
+
When the first run should happen.
|
|
160
|
+
|
|
161
|
+
``start_time`` if set, otherwise ``schedule_delay`` from now. Timezone
|
|
162
|
+
handling is the scheduler's business, so this returns local time to
|
|
163
|
+
match what schedulers expect.
|
|
164
|
+
"""
|
|
165
|
+
if self.start_time is not None:
|
|
166
|
+
return self.start_time
|
|
167
|
+
return datetime.now() + timedelta(**self.schedule_delay)
|
|
168
|
+
|
|
169
|
+
def scheduled_payload(self) -> str:
|
|
170
|
+
"""
|
|
171
|
+
The encoded arguments this task is scheduled with.
|
|
172
|
+
"""
|
|
173
|
+
return encode_payload(self.args or [], self.kwargs or {})
|
corekit/log_monitor/models.py
CHANGED
|
@@ -75,10 +75,16 @@ class PauseContainerAction(BaseAction):
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
class ExecuteCommandAction(BaseAction):
|
|
78
|
-
"""
|
|
78
|
+
"""
|
|
79
|
+
Run a command as an argument list.
|
|
80
|
+
|
|
81
|
+
``command`` is split like a shell command line, then ``{container}`` is
|
|
82
|
+
filled in. It is not passed to a shell, so pipes and redirects are not
|
|
83
|
+
available.
|
|
84
|
+
"""
|
|
79
85
|
|
|
80
86
|
type: Literal[ActionType.EXECUTE_COMMAND] = ActionType.EXECUTE_COMMAND
|
|
81
|
-
command: str = Field(..., description="
|
|
87
|
+
command: str = Field(..., description="Argument list template with a {container} placeholder")
|
|
82
88
|
|
|
83
89
|
|
|
84
90
|
# Union type for all actions
|