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 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())
@@ -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
+ ...