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.
Files changed (109) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +275 -0
  3. corekit/api/lifespan.py +233 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/__init__.py +2 -2
  7. corekit/concurrency/decorators.py +32 -5
  8. corekit/concurrency/thread_local.py +2 -2
  9. corekit/concurrency/worker.py +74 -65
  10. corekit/config/loader.py +42 -5
  11. corekit/config/settings.py +11 -1
  12. corekit/connections/__init__.py +7 -1
  13. corekit/connections/connectable.py +45 -4
  14. corekit/connections/redis/connection.py +53 -10
  15. corekit/connections/sql/__init__.py +33 -4
  16. corekit/connections/sql/connection.py +56 -3
  17. corekit/connections/sql/fields/__init__.py +2 -2
  18. corekit/connections/sql/fields/jsonb.py +13 -6
  19. corekit/connections/sql/migration/__init__.py +9 -5
  20. corekit/connections/sql/migration/base.py +3 -3
  21. corekit/connections/sql/migration/operations.py +135 -44
  22. corekit/connections/sql/migration/registry.py +2 -2
  23. corekit/connections/sql/operations/__init__.py +24 -0
  24. corekit/connections/sql/operations/base.py +111 -0
  25. corekit/connections/sql/operations/statements.py +170 -0
  26. corekit/connections/sql/query.py +4 -62
  27. corekit/connections/sql/table.py +33 -29
  28. corekit/crypto/__init__.py +3 -1
  29. corekit/crypto/constants.py +2 -2
  30. corekit/crypto/hasher.py +9 -4
  31. corekit/data/__init__.py +8 -0
  32. corekit/data/dataset.py +8 -2
  33. corekit/data/expressions/__init__.py +10 -2
  34. corekit/data/expressions/comparison.py +142 -123
  35. corekit/data/expressions/expression.py +71 -98
  36. corekit/data/expressions/operator.py +39 -0
  37. corekit/data/expressions/target.py +21 -0
  38. corekit/data/record.py +147 -147
  39. corekit/data/stats.py +162 -157
  40. corekit/decorators/__init__.py +2 -2
  41. corekit/decorators/exception_handling.py +38 -9
  42. corekit/docker/watchdog.py +50 -31
  43. corekit/etl/__init__.py +2 -1
  44. corekit/etl/connection.py +46 -44
  45. corekit/etl/extract/extractor.py +6 -13
  46. corekit/etl/orchestrator.py +19 -2
  47. corekit/etl/schemas.py +2 -2
  48. corekit/etl/transform/transformer.py +4 -1
  49. corekit/events/publisher.py +1 -1
  50. corekit/events/reader.py +26 -21
  51. corekit/events/sse.py +4 -1
  52. corekit/events/websocket.py +27 -13
  53. corekit/exceptions/__init__.py +33 -0
  54. corekit/exceptions/base.py +139 -10
  55. corekit/exceptions/enum.py +17 -0
  56. corekit/exceptions/types.py +6 -6
  57. corekit/files/__init__.py +2 -4
  58. corekit/files/base.py +15 -2
  59. corekit/files/enum.py +0 -5
  60. corekit/files/json.py +16 -2
  61. corekit/http/__init__.py +51 -0
  62. corekit/http/api.py +24 -0
  63. corekit/http/client.py +100 -73
  64. corekit/http/exceptions.py +140 -0
  65. corekit/http/response.py +50 -1
  66. corekit/http/status.py +89 -0
  67. corekit/jobs/__init__.py +26 -0
  68. corekit/jobs/registry.py +87 -0
  69. corekit/jobs/runner.py +80 -0
  70. corekit/jobs/task.py +173 -0
  71. corekit/log_monitor/models.py +8 -2
  72. corekit/log_monitor/service.py +77 -38
  73. corekit/notifications/base.py +18 -10
  74. corekit/observability/__init__.py +12 -3
  75. corekit/observability/benchmarkable.py +23 -5
  76. corekit/observability/loggable.py +21 -0
  77. corekit/observability/request_context.py +188 -0
  78. corekit/observability/timing/timer.py +4 -2
  79. corekit/registry/__init__.py +12 -7
  80. corekit/registry/ordered.py +86 -0
  81. corekit/registry/registry.py +55 -14
  82. corekit/schemas/__init__.py +10 -0
  83. corekit/schemas/enum.py +70 -49
  84. corekit/schemas/models/arbitrary.py +11 -11
  85. corekit/schemas/pydantic/fields.py +35 -35
  86. corekit/schemas/types.py +45 -40
  87. corekit/serialization/__init__.py +24 -0
  88. corekit/serialization/pickle_file.py +61 -0
  89. corekit/serialization/serializable.py +22 -2
  90. corekit/serialization/serializer.py +10 -3
  91. corekit/utils/__init__.py +59 -5
  92. corekit/utils/coercion.py +118 -0
  93. corekit/utils/collections.py +124 -0
  94. corekit/utils/ids.py +61 -5
  95. corekit/utils/payload.py +112 -0
  96. corekit/utils/raise_exc.py +8 -8
  97. corekit/utils/text.py +56 -0
  98. corekit/utils/time.py +74 -21
  99. corekit/utils/validators.py +15 -15
  100. corekit/utils/void.py +8 -8
  101. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
  102. python_corekit-0.3.0.dist-info/RECORD +145 -0
  103. corekit/constants.py +0 -45
  104. corekit/exceptions/http/exceptions.py +0 -37
  105. corekit/files/pickle.py +0 -12
  106. python_corekit-0.1.1.dist-info/RECORD +0 -125
  107. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  108. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  109. {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
@@ -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
+ ]
@@ -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 {})
@@ -75,10 +75,16 @@ class PauseContainerAction(BaseAction):
75
75
 
76
76
 
77
77
  class ExecuteCommandAction(BaseAction):
78
- """Execute a shell command"""
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="Command template with {container} placeholder")
87
+ command: str = Field(..., description="Argument list template with a {container} placeholder")
82
88
 
83
89
 
84
90
  # Union type for all actions