asynk 0.1.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.
- asynk/__init__.py +78 -0
- asynk/_extras.py +28 -0
- asynk/_redis.py +39 -0
- asynk/app.py +297 -0
- asynk/cli.py +162 -0
- asynk/codec/__init__.py +48 -0
- asynk/codec/json.py +89 -0
- asynk/cron.py +261 -0
- asynk/envelope.py +125 -0
- asynk/errors.py +62 -0
- asynk/hooks.py +182 -0
- asynk/logging.py +25 -0
- asynk/options.py +47 -0
- asynk/probes.py +95 -0
- asynk/py.typed +0 -0
- asynk/recorder.py +103 -0
- asynk/results.py +178 -0
- asynk/retry/__init__.py +103 -0
- asynk/retry/backoff.py +82 -0
- asynk/runner.py +66 -0
- asynk/scheduled_task.py +109 -0
- asynk/scheduler.py +312 -0
- asynk/signals.py +34 -0
- asynk/store/__init__.py +68 -0
- asynk/store/memory.py +93 -0
- asynk/store/null.py +35 -0
- asynk/store/polling.py +33 -0
- asynk/store/redis/__init__.py +9 -0
- asynk/store/redis/store.py +85 -0
- asynk/task.py +131 -0
- asynk/transport/__init__.py +128 -0
- asynk/transport/memory.py +164 -0
- asynk/transport/redis/__init__.py +9 -0
- asynk/transport/redis/transport.py +353 -0
- asynk/worker.py +387 -0
- asynk-0.1.0.dist-info/METADATA +109 -0
- asynk-0.1.0.dist-info/RECORD +40 -0
- asynk-0.1.0.dist-info/WHEEL +4 -0
- asynk-0.1.0.dist-info/entry_points.txt +3 -0
- asynk-0.1.0.dist-info/licenses/LICENSE +21 -0
asynk/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""asynk: an asyncio task framework."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from asynk.app import App
|
|
6
|
+
from asynk.codec import Codec
|
|
7
|
+
from asynk.codec.json import JsonCodec
|
|
8
|
+
from asynk.envelope import Envelope
|
|
9
|
+
from asynk.errors import (
|
|
10
|
+
AsynkError,
|
|
11
|
+
CronError,
|
|
12
|
+
DecodeError,
|
|
13
|
+
DeliveryFailedError,
|
|
14
|
+
EncodeError,
|
|
15
|
+
InvalidEnvelopeError,
|
|
16
|
+
NotConfiguredError,
|
|
17
|
+
TaskError,
|
|
18
|
+
TaskNotFoundError,
|
|
19
|
+
)
|
|
20
|
+
from asynk.hooks import Next, TaskCall, TaskResult, TaskStatus
|
|
21
|
+
from asynk.options import TaskOptions
|
|
22
|
+
from asynk.results import TaskHandle, TaskState
|
|
23
|
+
from asynk.retry import Backoff, Exponential, RetryPolicy
|
|
24
|
+
from asynk.runner import Runner
|
|
25
|
+
from asynk.scheduled_task import ScheduledTask
|
|
26
|
+
from asynk.scheduler import Scheduler
|
|
27
|
+
from asynk.store import Store
|
|
28
|
+
from asynk.store.memory import MemoryStore
|
|
29
|
+
from asynk.store.null import NullStore
|
|
30
|
+
from asynk.task import Task
|
|
31
|
+
from asynk.transport import Consumer, Delivery, Producer, PublishOptions
|
|
32
|
+
from asynk.worker import Worker
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = version("asynk")
|
|
36
|
+
except PackageNotFoundError:
|
|
37
|
+
# Imported from a source tree without an installed distribution.
|
|
38
|
+
__version__ = "0+unknown"
|
|
39
|
+
"""The installed asynk version."""
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"__version__",
|
|
43
|
+
"App",
|
|
44
|
+
"AsynkError",
|
|
45
|
+
"Backoff",
|
|
46
|
+
"Codec",
|
|
47
|
+
"Consumer",
|
|
48
|
+
"CronError",
|
|
49
|
+
"DecodeError",
|
|
50
|
+
"Delivery",
|
|
51
|
+
"DeliveryFailedError",
|
|
52
|
+
"Envelope",
|
|
53
|
+
"InvalidEnvelopeError",
|
|
54
|
+
"JsonCodec",
|
|
55
|
+
"EncodeError",
|
|
56
|
+
"Exponential",
|
|
57
|
+
"NotConfiguredError",
|
|
58
|
+
"Next",
|
|
59
|
+
"Producer",
|
|
60
|
+
"PublishOptions",
|
|
61
|
+
"RetryPolicy",
|
|
62
|
+
"Runner",
|
|
63
|
+
"Task",
|
|
64
|
+
"TaskCall",
|
|
65
|
+
"TaskResult",
|
|
66
|
+
"TaskStatus",
|
|
67
|
+
"ScheduledTask",
|
|
68
|
+
"Scheduler",
|
|
69
|
+
"Worker",
|
|
70
|
+
"Store",
|
|
71
|
+
"MemoryStore",
|
|
72
|
+
"NullStore",
|
|
73
|
+
"TaskHandle",
|
|
74
|
+
"TaskOptions",
|
|
75
|
+
"TaskState",
|
|
76
|
+
"TaskError",
|
|
77
|
+
"TaskNotFoundError",
|
|
78
|
+
]
|
asynk/_extras.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Import-time guard shared by the packages that need an optional extra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def require_extra(package: str, extra: str) -> None:
|
|
9
|
+
"""Imports `package`, or raises an ImportError that names the extra to install.
|
|
10
|
+
|
|
11
|
+
Call it at the top of the package that needs the extra.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
package: Top-level name of the third-party package the extra installs.
|
|
15
|
+
extra: Name of the extra, as in `pip install 'asynk[<extra>]'`.
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
ImportError: If `package` is not installed. An error raised by one of
|
|
19
|
+
its own dependencies is passed on unchanged.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
importlib.import_module(package)
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
if exc.name != package:
|
|
25
|
+
raise
|
|
26
|
+
raise ImportError(
|
|
27
|
+
f"asynk[{extra}] needs the {package} package: pip install 'asynk[{extra}]'"
|
|
28
|
+
) from exc
|
asynk/_redis.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The Redis client a Redis store or transport uses, and who closes it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from asynk._extras import require_extra
|
|
6
|
+
|
|
7
|
+
require_extra("redis", "redis")
|
|
8
|
+
|
|
9
|
+
from redis.asyncio import Redis # noqa: E402
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RedisConnection:
|
|
13
|
+
"""A Redis client, either given by the caller or created from a URL."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, url: str | None, client: Redis | None, *, owner: str) -> None:
|
|
16
|
+
"""Initializes the connection.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
url: Redis URL; the connection creates its own client and closes it.
|
|
20
|
+
client: Redis client the caller owns and closes.
|
|
21
|
+
owner: Name of the class using the connection, for error messages.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If not exactly one of `url` and `client` is given.
|
|
25
|
+
"""
|
|
26
|
+
if (url is None) == (client is None):
|
|
27
|
+
raise ValueError(f"{owner} needs exactly one of url and client")
|
|
28
|
+
if client is None:
|
|
29
|
+
assert url is not None
|
|
30
|
+
client = Redis.from_url(url, socket_timeout=5.0, socket_connect_timeout=5.0)
|
|
31
|
+
self._owns_client = True
|
|
32
|
+
else:
|
|
33
|
+
self._owns_client = False
|
|
34
|
+
self.client = client
|
|
35
|
+
|
|
36
|
+
async def close(self) -> None:
|
|
37
|
+
"""Closes the client if the connection created it."""
|
|
38
|
+
if self._owns_client:
|
|
39
|
+
await self.client.aclose()
|
asynk/app.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""The `App`: tasks, transport, store, codec and hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, ParamSpec, TypeVar
|
|
9
|
+
|
|
10
|
+
from asynk.codec import Codec
|
|
11
|
+
from asynk.codec.json import JsonCodec
|
|
12
|
+
from asynk.hooks import (
|
|
13
|
+
Hooks,
|
|
14
|
+
LifecycleHook,
|
|
15
|
+
Middleware,
|
|
16
|
+
)
|
|
17
|
+
from asynk.options import TaskOptions
|
|
18
|
+
from asynk.results import TaskHandle
|
|
19
|
+
from asynk.retry import RetryPolicy
|
|
20
|
+
from asynk.scheduled_task import Schedule, ScheduledTask, resolve_timezone
|
|
21
|
+
from asynk.store import Store
|
|
22
|
+
from asynk.store.null import NullStore
|
|
23
|
+
from asynk.task import Task
|
|
24
|
+
from asynk.transport import Consumer, Producer
|
|
25
|
+
|
|
26
|
+
P = ParamSpec("P")
|
|
27
|
+
R = TypeVar("R")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class App:
|
|
31
|
+
"""Registry of tasks plus the producer and consumer that move their messages.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
name: Name of the app.
|
|
35
|
+
producer: Used by `Task.delay`; None if the app only consumes.
|
|
36
|
+
consumer: Used by `Worker`; None if the app only produces.
|
|
37
|
+
store: Key-value store for task states, task results and scheduler claim keys.
|
|
38
|
+
codec: Codec that encodes messages, task states and results.
|
|
39
|
+
tz: Default timezone for scheduled tasks.
|
|
40
|
+
options: Default options of the app's tasks.
|
|
41
|
+
hooks: The hooks and middleware registered through the `before_*`, `after_*` and
|
|
42
|
+
`middleware` decorators.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
name: str,
|
|
48
|
+
*,
|
|
49
|
+
producer: Producer | None = None,
|
|
50
|
+
consumer: Consumer | None = None,
|
|
51
|
+
store: Store | None = None,
|
|
52
|
+
codec: Codec | None = None,
|
|
53
|
+
tz: str = "UTC",
|
|
54
|
+
options: TaskOptions | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Initializes the app.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
name: Name of the app.
|
|
60
|
+
producer: Used by `Task.delay`; None if the app only consumes.
|
|
61
|
+
consumer: Used by `Worker`; None if the app only produces.
|
|
62
|
+
store: Key-value store for task states, task results and scheduler
|
|
63
|
+
claim keys; defaults to a `NullStore`.
|
|
64
|
+
codec: Codec that encodes messages, task states and results;
|
|
65
|
+
defaults to a `JsonCodec`.
|
|
66
|
+
tz: Default timezone for scheduled tasks.
|
|
67
|
+
options: Default options for the app's tasks.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
TypeError: If `options` is not a `TaskOptions`.
|
|
71
|
+
ValueError: If the timezone is unknown.
|
|
72
|
+
"""
|
|
73
|
+
self.name = name
|
|
74
|
+
self.tz = resolve_timezone(tz, f"app {name!r}")
|
|
75
|
+
if options is not None and not isinstance(options, TaskOptions):
|
|
76
|
+
raise TypeError(f"options must be a TaskOptions or None, got {options!r}")
|
|
77
|
+
self.options = options if options is not None else TaskOptions()
|
|
78
|
+
self.producer = producer
|
|
79
|
+
self.consumer = consumer
|
|
80
|
+
self.store: Store = store if store is not None else NullStore()
|
|
81
|
+
self.codec: Codec = codec if codec is not None else JsonCodec()
|
|
82
|
+
self._tasks: dict[str, Task[..., Any]] = {}
|
|
83
|
+
self._scheduled_tasks: list[ScheduledTask[..., Any]] = []
|
|
84
|
+
self.hooks = Hooks()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def tasks(self) -> Mapping[str, Task[..., Any]]:
|
|
88
|
+
"""The registered tasks, by name, as a read-only mapping."""
|
|
89
|
+
return MappingProxyType(self._tasks)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def scheduled_tasks(self) -> Sequence[ScheduledTask[..., Any]]:
|
|
93
|
+
"""The registered scheduled tasks, in registration order."""
|
|
94
|
+
return tuple(self._scheduled_tasks)
|
|
95
|
+
|
|
96
|
+
def task(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
name: str | None = None,
|
|
100
|
+
options: TaskOptions | None = None,
|
|
101
|
+
) -> Callable[[Callable[P, R]], Task[P, R]]:
|
|
102
|
+
"""Decorator that registers a function as a task.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
name: Task name; defaults to the app name and the function's qualified name.
|
|
106
|
+
options: Options of this task; they override the app's defaults.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A decorator producing a `Task`.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
TypeError: If `options` is not a `TaskOptions`.
|
|
113
|
+
ValueError: If the name is already registered.
|
|
114
|
+
"""
|
|
115
|
+
policy = self._task_policy(options)
|
|
116
|
+
|
|
117
|
+
def decorator(func: Callable[P, R]) -> Task[P, R]:
|
|
118
|
+
task_name = self._claim_name(name, func)
|
|
119
|
+
task = Task(self, func, task_name, policy)
|
|
120
|
+
self._tasks[task_name] = task
|
|
121
|
+
return task
|
|
122
|
+
|
|
123
|
+
return decorator
|
|
124
|
+
|
|
125
|
+
def scheduled_task(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
cron: str,
|
|
129
|
+
tz: str | None = None,
|
|
130
|
+
args: Sequence[object] = (),
|
|
131
|
+
kwargs: Mapping[str, object] | None = None,
|
|
132
|
+
name: str | None = None,
|
|
133
|
+
options: TaskOptions | None = None,
|
|
134
|
+
) -> Callable[[Callable[P, R]], ScheduledTask[P, R]]:
|
|
135
|
+
"""Decorator that registers a function as a task published on a cron schedule.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
cron: Five-field cron expression.
|
|
139
|
+
tz: IANA timezone for the expression; defaults to the app's.
|
|
140
|
+
args: Positional arguments for each published call.
|
|
141
|
+
kwargs: Keyword arguments for each published call.
|
|
142
|
+
name: Task name; defaults to the app name and the function's qualified name.
|
|
143
|
+
options: Options of this task; they override the app's defaults.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A decorator producing a `ScheduledTask`.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
CronError: If the cron expression is invalid.
|
|
150
|
+
TypeError: If `options` is not a `TaskOptions`.
|
|
151
|
+
ValueError: If the timezone is unknown, or the name is already registered.
|
|
152
|
+
"""
|
|
153
|
+
# Built now so a bad expression fails at the call.
|
|
154
|
+
schedule = Schedule.build(cron, tz=self.tz, args=args, kwargs=kwargs)
|
|
155
|
+
policy = self._task_policy(options)
|
|
156
|
+
if tz is not None and name is not None:
|
|
157
|
+
resolve_timezone(tz, f"scheduled task {name!r}")
|
|
158
|
+
|
|
159
|
+
def decorator(func: Callable[P, R]) -> ScheduledTask[P, R]:
|
|
160
|
+
task_name = self._claim_name(name, func)
|
|
161
|
+
zone = self.tz if tz is None else resolve_timezone(tz, f"scheduled task {task_name!r}")
|
|
162
|
+
task = ScheduledTask(self, func, task_name, policy, replace(schedule, tz=zone))
|
|
163
|
+
self._tasks[task_name] = task
|
|
164
|
+
self._scheduled_tasks.append(task)
|
|
165
|
+
return task
|
|
166
|
+
|
|
167
|
+
return decorator
|
|
168
|
+
|
|
169
|
+
def _task_policy(self, options: TaskOptions | None) -> RetryPolicy | None:
|
|
170
|
+
if options is not None and not isinstance(options, TaskOptions):
|
|
171
|
+
raise TypeError(f"options must be a TaskOptions or None, got {options!r}")
|
|
172
|
+
return (options or TaskOptions()).with_defaults(self.options).policy
|
|
173
|
+
|
|
174
|
+
def _claim_name(self, name: str | None, func: Callable[..., Any]) -> str:
|
|
175
|
+
task_name = name or f"{self.name}.{func.__qualname__}"
|
|
176
|
+
if task_name in self._tasks:
|
|
177
|
+
raise ValueError(f"task {task_name!r} is already registered")
|
|
178
|
+
return task_name
|
|
179
|
+
|
|
180
|
+
def registered_task(self, name: str) -> Task[..., Any]:
|
|
181
|
+
"""Looks up a registered task.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
name: The task name.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The task.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
KeyError: If no task has that name.
|
|
191
|
+
"""
|
|
192
|
+
return self._tasks[name]
|
|
193
|
+
|
|
194
|
+
def get_task(self, task_id: str) -> TaskHandle:
|
|
195
|
+
"""Returns a handle for reading the state and result of a published task.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
task_id: The task's id (`Envelope.id`).
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A `TaskHandle` reading this app's store with this app's codec.
|
|
202
|
+
"""
|
|
203
|
+
return TaskHandle(task_id, self.store, self.codec)
|
|
204
|
+
|
|
205
|
+
def before_startup(self, hook: LifecycleHook) -> LifecycleHook:
|
|
206
|
+
"""Registers a hook run after the store starts and before serving begins.
|
|
207
|
+
|
|
208
|
+
It runs before a worker's consumer or a scheduler's loop starts. Lifecycle
|
|
209
|
+
hooks may be sync or async and receive the `Runner` they run in, a `Worker`
|
|
210
|
+
or a `Scheduler`; `runner.role` tells them apart. Registering the same hook
|
|
211
|
+
twice has no effect.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
Initialize telemetry in every process, but open a database pool only
|
|
215
|
+
in workers:
|
|
216
|
+
|
|
217
|
+
@app.before_startup
|
|
218
|
+
async def startup(runner: Runner) -> None:
|
|
219
|
+
init_sentry()
|
|
220
|
+
if runner.role == "worker":
|
|
221
|
+
await database.connect()
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
hook: The hook to register.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The hook, unchanged.
|
|
228
|
+
"""
|
|
229
|
+
return self.hooks.before_startup.add(hook)
|
|
230
|
+
|
|
231
|
+
def after_startup(self, hook: LifecycleHook) -> LifecycleHook:
|
|
232
|
+
"""Registers a hook run once a worker or scheduler has started.
|
|
233
|
+
|
|
234
|
+
Same rules as `before_startup`.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
hook: The hook to register.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
The hook, unchanged.
|
|
241
|
+
"""
|
|
242
|
+
return self.hooks.after_startup.add(hook)
|
|
243
|
+
|
|
244
|
+
def before_shutdown(self, hook: LifecycleHook) -> LifecycleHook:
|
|
245
|
+
"""Registers a hook run when a worker or scheduler begins to shut down.
|
|
246
|
+
|
|
247
|
+
A worker runs it when it stops fetching, before draining. Same rules as
|
|
248
|
+
`before_startup`.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
hook: The hook to register.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The hook, unchanged.
|
|
255
|
+
"""
|
|
256
|
+
return self.hooks.before_shutdown.add(hook)
|
|
257
|
+
|
|
258
|
+
def after_shutdown(self, hook: LifecycleHook) -> LifecycleHook:
|
|
259
|
+
"""Registers a hook run after a worker or scheduler has stopped, before the store stops.
|
|
260
|
+
|
|
261
|
+
Same rules as `before_startup`.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
hook: The hook to register.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
The hook, unchanged.
|
|
268
|
+
"""
|
|
269
|
+
return self.hooks.after_shutdown.add(hook)
|
|
270
|
+
|
|
271
|
+
def middleware(self, layer: Middleware) -> Middleware:
|
|
272
|
+
"""Registers a middleware layer around each task.
|
|
273
|
+
|
|
274
|
+
A layer is an async function `(call: TaskCall, next: Next) -> TaskResult`.
|
|
275
|
+
It receives the call and `next`, which runs the rest of the chain and the
|
|
276
|
+
task, and returns a `TaskResult`: the one from `next` or another one, such
|
|
277
|
+
as `call.skip()`, the `SKIPPED` result that vetoes the task. The last
|
|
278
|
+
registered layer is the outermost. Registering the same layer twice has no
|
|
279
|
+
effect.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
layer: The middleware layer to register.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The layer, unchanged.
|
|
286
|
+
|
|
287
|
+
Example:
|
|
288
|
+
Log how long each task takes:
|
|
289
|
+
|
|
290
|
+
@app.middleware
|
|
291
|
+
async def timed(call: TaskCall, next: Next) -> TaskResult:
|
|
292
|
+
started = time.monotonic()
|
|
293
|
+
result = await next(call)
|
|
294
|
+
logger.info("%s took %.3fs", call.envelope.task, time.monotonic() - started)
|
|
295
|
+
return result
|
|
296
|
+
"""
|
|
297
|
+
return self.hooks.middleware.add(layer)
|
asynk/cli.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The `asynk` command line: `worker`, `scheduler` and `health`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from asynk.app import App
|
|
15
|
+
from asynk.errors import AsynkError
|
|
16
|
+
from asynk.logging import configure_logging
|
|
17
|
+
from asynk.probes import check_probe, default_probe_dir
|
|
18
|
+
from asynk.scheduler import Scheduler
|
|
19
|
+
from asynk.worker import Worker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CliError(AsynkError):
|
|
23
|
+
"""Raised for user-facing command-line errors."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_app(path: str) -> App:
|
|
27
|
+
"""Imports an app from a `module:attr` path.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
path: Import path of the form `module:attr`.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The app.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
CliError: If the path is malformed, cannot be imported, or is not an `App`.
|
|
37
|
+
"""
|
|
38
|
+
module_name, sep, attr = path.partition(":")
|
|
39
|
+
if not sep or not module_name or not attr:
|
|
40
|
+
raise CliError(f"invalid app path {path!r}: expected 'module:attr'")
|
|
41
|
+
try:
|
|
42
|
+
module = importlib.import_module(module_name)
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
raise CliError(f"cannot import {module_name!r}: {exc}") from exc
|
|
45
|
+
try:
|
|
46
|
+
obj = getattr(module, attr)
|
|
47
|
+
except AttributeError:
|
|
48
|
+
raise CliError(f"module {module_name!r} has no attribute {attr!r}") from None
|
|
49
|
+
if not isinstance(obj, App):
|
|
50
|
+
raise CliError(f"{path!r} is a {type(obj).__name__}, not an asynk App")
|
|
51
|
+
return obj
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
55
|
+
parser = argparse.ArgumentParser(prog="asynk")
|
|
56
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
57
|
+
|
|
58
|
+
worker = commands.add_parser("worker", help="run a worker for an app")
|
|
59
|
+
worker.add_argument("app", metavar="MODULE:APP")
|
|
60
|
+
worker.add_argument("--concurrency", type=int)
|
|
61
|
+
worker.add_argument("--shutdown-timeout", type=float)
|
|
62
|
+
worker.add_argument("--heartbeat-interval", type=float)
|
|
63
|
+
worker.add_argument("--probe-dir", type=Path, default=default_probe_dir())
|
|
64
|
+
worker.add_argument(
|
|
65
|
+
"--log-level",
|
|
66
|
+
default="INFO",
|
|
67
|
+
type=str.upper,
|
|
68
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
scheduler = commands.add_parser("scheduler", help="publish an app's cron scheduled tasks")
|
|
72
|
+
scheduler.add_argument("app", metavar="MODULE:APP")
|
|
73
|
+
scheduler.add_argument("--heartbeat-interval", type=float)
|
|
74
|
+
scheduler.add_argument("--probe-dir", type=Path, default=default_probe_dir())
|
|
75
|
+
scheduler.add_argument(
|
|
76
|
+
"--log-level",
|
|
77
|
+
default="INFO",
|
|
78
|
+
type=str.upper,
|
|
79
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
health = commands.add_parser("health", help="check a worker or scheduler probe file")
|
|
83
|
+
health.add_argument("probe", choices=["liveness", "readiness"])
|
|
84
|
+
health.add_argument("--max-age", type=float, required=True)
|
|
85
|
+
health.add_argument("--probe-dir", type=Path, default=default_probe_dir())
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run_worker(args: argparse.Namespace) -> int:
|
|
90
|
+
configure_logging(args.log_level)
|
|
91
|
+
app = load_app(args.app)
|
|
92
|
+
options: dict[str, Any] = {
|
|
93
|
+
key: value
|
|
94
|
+
for key in ("concurrency", "shutdown_timeout", "heartbeat_interval")
|
|
95
|
+
if (value := getattr(args, key)) is not None
|
|
96
|
+
}
|
|
97
|
+
try:
|
|
98
|
+
worker = Worker(app, probe_dir=args.probe_dir, **options)
|
|
99
|
+
except (AsynkError, ValueError) as exc:
|
|
100
|
+
raise CliError(str(exc)) from exc
|
|
101
|
+
logging.getLogger("asynk").info(
|
|
102
|
+
"starting worker for app %r: concurrency=%d shutdown_timeout=%s probe_dir=%s",
|
|
103
|
+
app.name,
|
|
104
|
+
worker.concurrency,
|
|
105
|
+
worker.shutdown_timeout,
|
|
106
|
+
worker.probe_dir,
|
|
107
|
+
)
|
|
108
|
+
asyncio.run(worker.run_with_signals())
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run_scheduler(args: argparse.Namespace) -> int:
|
|
113
|
+
configure_logging(args.log_level)
|
|
114
|
+
app = load_app(args.app)
|
|
115
|
+
options: dict[str, Any] = {}
|
|
116
|
+
if args.heartbeat_interval is not None:
|
|
117
|
+
options["heartbeat_interval"] = args.heartbeat_interval
|
|
118
|
+
try:
|
|
119
|
+
scheduler = Scheduler(app, probe_dir=args.probe_dir, **options)
|
|
120
|
+
except (AsynkError, ValueError) as exc:
|
|
121
|
+
raise CliError(str(exc)) from exc
|
|
122
|
+
logging.getLogger("asynk").info(
|
|
123
|
+
"starting scheduler for app %r: scheduled_tasks=[%s] probe_dir=%s",
|
|
124
|
+
app.name,
|
|
125
|
+
", ".join(
|
|
126
|
+
f"{t.name} ({t.schedule.expression}, {t.schedule.tz_name})" for t in app.scheduled_tasks
|
|
127
|
+
),
|
|
128
|
+
scheduler.probe_dir,
|
|
129
|
+
)
|
|
130
|
+
asyncio.run(scheduler.run_with_signals())
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_health(args: argparse.Namespace) -> int:
|
|
135
|
+
healthy, message = check_probe(args.probe_dir / args.probe, args.max_age)
|
|
136
|
+
print(message)
|
|
137
|
+
return 0 if healthy else 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
141
|
+
"""Runs the `asynk` command line.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
argv: Arguments to parse; defaults to `sys.argv[1:]`.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The process exit code.
|
|
148
|
+
"""
|
|
149
|
+
args = _build_parser().parse_args(argv)
|
|
150
|
+
try:
|
|
151
|
+
if args.command == "worker":
|
|
152
|
+
return _run_worker(args)
|
|
153
|
+
if args.command == "scheduler":
|
|
154
|
+
return _run_scheduler(args)
|
|
155
|
+
return _run_health(args)
|
|
156
|
+
except CliError as exc:
|
|
157
|
+
print(f"asynk: {exc}", file=sys.stderr)
|
|
158
|
+
return 2
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
raise SystemExit(main())
|
asynk/codec/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The `Codec` protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Codec(Protocol):
|
|
9
|
+
"""Converts values to and from bytes."""
|
|
10
|
+
|
|
11
|
+
def validate(self, value: object) -> None:
|
|
12
|
+
"""Checks that `encode` would accept a value, without encoding it.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
value: The value to check.
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
EncodeError: If this codec cannot encode `value`.
|
|
19
|
+
"""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def encode(self, value: object) -> bytes:
|
|
23
|
+
"""Serializes a value.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
value: The value to serialize.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The encoded value.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
EncodeError: If this codec cannot encode `value`.
|
|
33
|
+
"""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def decode(self, data: bytes) -> object:
|
|
37
|
+
"""Deserializes a value.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
data: Bytes previously produced by `encode`.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The decoded value.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
DecodeError: If `data` is not a valid encoded value.
|
|
47
|
+
"""
|
|
48
|
+
...
|