wool 0.1rc20__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.
wool/_work.py ADDED
@@ -0,0 +1,554 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ import traceback
7
+ from collections.abc import Callable
8
+ from contextvars import Context
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass
11
+ from functools import wraps
12
+ from sys import modules
13
+ from time import perf_counter_ns
14
+ from types import ModuleType
15
+ from types import TracebackType
16
+ from typing import TYPE_CHECKING
17
+ from typing import Coroutine
18
+ from typing import Dict
19
+ from typing import Literal
20
+ from typing import ParamSpec
21
+ from typing import Protocol
22
+ from typing import SupportsInt
23
+ from typing import Tuple
24
+ from typing import Type
25
+ from typing import TypeVar
26
+ from typing import cast
27
+ from uuid import UUID
28
+ from uuid import uuid4
29
+
30
+ import cloudpickle
31
+
32
+ import wool
33
+ from wool import _context as ctx
34
+ from wool._typing import PassthroughDecorator
35
+ from wool.core import protobuf as pb
36
+
37
+ if TYPE_CHECKING:
38
+ from wool.core.worker.proxy import WorkerProxy
39
+
40
+ AsyncCallable = Callable[..., Coroutine]
41
+ C = TypeVar("C", bound=AsyncCallable)
42
+
43
+ Args = Tuple
44
+ Kwargs = Dict
45
+ Timeout = SupportsInt
46
+ Timestamp = SupportsInt
47
+
48
+
49
+ # public
50
+ def work(fn: C) -> C:
51
+ """Decorator to declare an asynchronous function as remotely executable.
52
+
53
+ Converts an asynchronous function into a distributed task that can be
54
+ executed by a worker pool. When the decorated function is invoked, it
55
+ is dispatched to the worker pool associated with the current worker
56
+ pool session context.
57
+
58
+ :param fn:
59
+ The asynchronous function to convert into a distributed routine.
60
+ :returns:
61
+ The decorated function that dispatches to the worker pool when
62
+ called.
63
+ :raises ValueError:
64
+ If the decorated function is not a coroutine function.
65
+
66
+ .. note::
67
+ Decorated functions behave like regular coroutines and can
68
+ be awaited and cancelled normally. Task execution occurs
69
+ transparently across the distributed worker pool.
70
+
71
+ Best practices and considerations for designing tasks:
72
+
73
+ 1. **Picklability**: Arguments and return values must be picklable for
74
+ serialization between processes. Avoid unpicklable objects like
75
+ open file handles, database connections, or lambda functions.
76
+ Custom objects should implement ``__getstate__`` and
77
+ ``__setstate__`` methods if needed.
78
+
79
+ 2. **Synchronization**: Tasks are not guaranteed to execute on the
80
+ same process between invocations. Standard :mod:`asyncio`
81
+ synchronization primitives will not work across processes.
82
+ Use file-based or other distributed synchronization utilities.
83
+
84
+ 3. **Statelessness**: Design tasks to be stateless and idempotent.
85
+ Avoid global variables or shared mutable state to ensure
86
+ predictable behavior and enable safe retries.
87
+
88
+ 4. **Cancellation**: Task cancellation behaves like standard Python
89
+ coroutine cancellation and is properly propagated across the
90
+ distributed system.
91
+
92
+ 5. **Error propagation**: Unhandled exceptions raised within tasks are
93
+ transparently propagated to the caller as they would be normally.
94
+
95
+ 6. **Performance**: Minimize argument and return value sizes to reduce
96
+ serialization overhead. For large datasets, consider using shared
97
+ memory or passing references instead of the data itself.
98
+
99
+ Example usage:
100
+
101
+ .. code-block:: python
102
+
103
+ import wool
104
+
105
+
106
+ @wool.work
107
+ async def fibonacci(n: int) -> int:
108
+ if n <= 1:
109
+ return n
110
+ return await fibonacci(n - 1) + await fibonacci(n - 2)
111
+
112
+
113
+ async def main():
114
+ async with wool.WorkerPool():
115
+ result = await fibonacci(10)
116
+ """
117
+ if not inspect.iscoroutinefunction(fn):
118
+ raise ValueError("Expected a coroutine function")
119
+
120
+ @wraps(fn)
121
+ def wrapper(*args, **kwargs) -> Coroutine:
122
+ # Handle static and class methods in a picklable way.
123
+ parent, function = _resolve(fn)
124
+ assert parent is not None
125
+ assert callable(function)
126
+
127
+ if _do_dispatch.get():
128
+ proxy = wool.__proxy__.get()
129
+ assert proxy
130
+ stream = _dispatch(
131
+ proxy,
132
+ wrapper.__module__,
133
+ wrapper.__qualname__,
134
+ function,
135
+ *args,
136
+ **kwargs,
137
+ )
138
+ if inspect.iscoroutinefunction(fn):
139
+ return _stream_to_coroutine(stream)
140
+ else:
141
+ raise ValueError("Expected a coroutine function")
142
+ else:
143
+ return _execute(fn, parent, *args, **kwargs)
144
+
145
+ return cast(C, wrapper)
146
+
147
+
148
+ routine = work
149
+
150
+
151
+ def _dispatch(
152
+ proxy: WorkerProxy,
153
+ module: str,
154
+ qualname: str,
155
+ function: AsyncCallable,
156
+ *args,
157
+ **kwargs,
158
+ ):
159
+ # Skip self argument if function is a method.
160
+ args = args[1:] if hasattr(function, "__self__") else args
161
+ signature = ", ".join(
162
+ (
163
+ *(repr(v) for v in args),
164
+ *(f"{k}={repr(v)}" for k, v in kwargs.items()),
165
+ )
166
+ )
167
+ task = WoolTask(
168
+ id=uuid4(),
169
+ callable=function,
170
+ args=args,
171
+ kwargs=kwargs,
172
+ tag=f"{module}.{qualname}({signature})",
173
+ proxy=proxy,
174
+ )
175
+ return proxy.dispatch(task, timeout=ctx.dispatch_timeout.get())
176
+
177
+
178
+ async def _execute(fn: AsyncCallable, parent, *args, **kwargs):
179
+ token = _do_dispatch.set(True)
180
+ try:
181
+ if isinstance(fn, classmethod):
182
+ return await fn.__func__(parent, *args, **kwargs)
183
+ else:
184
+ return await fn(*args, **kwargs)
185
+ finally:
186
+ _do_dispatch.reset(token)
187
+
188
+
189
+ async def _stream_to_coroutine(stream):
190
+ result = None
191
+ async for result in await stream:
192
+ continue
193
+ return result
194
+
195
+
196
+ # public
197
+ def current_task() -> WoolTask | None:
198
+ """
199
+ Get the current task from the context variable if we are inside a task
200
+ context, otherwise return None.
201
+
202
+ :returns:
203
+ The current task or None if no task is active.
204
+ """
205
+ return _current_task.get()
206
+
207
+
208
+ # public
209
+ @dataclass
210
+ class WoolTask:
211
+ """
212
+ Represents a distributed task to be executed in the worker pool.
213
+
214
+ Each task encapsulates a function call along with its arguments and
215
+ execution context. Tasks are created when decorated functions are
216
+ invoked and contain all necessary information for remote execution
217
+ including serialization, routing, and result handling.
218
+
219
+ :param id:
220
+ Unique identifier for this task instance.
221
+ :param callable:
222
+ The asynchronous function to execute.
223
+ :param args:
224
+ Positional arguments for the function.
225
+ :param kwargs:
226
+ Keyword arguments for the function.
227
+ :param proxy:
228
+ Worker proxy for task dispatch and routing.
229
+ :param timeout:
230
+ Task timeout in seconds (0 means no timeout).
231
+ :param caller:
232
+ UUID of the calling task if this is a nested task.
233
+ :param exception:
234
+ Exception information if task execution failed.
235
+ :param filename:
236
+ Source filename where the task was defined.
237
+ :param function:
238
+ Name of the function being executed.
239
+ :param line_no:
240
+ Line number where the task was defined.
241
+ :param tag:
242
+ Optional descriptive tag for the task.
243
+ """
244
+
245
+ id: UUID
246
+ callable: AsyncCallable
247
+ args: Args
248
+ kwargs: Kwargs
249
+ proxy: WorkerProxy
250
+ timeout: Timeout = 0
251
+ caller: UUID | None = None
252
+ exception: WoolTaskException | None = None
253
+ filename: str | None = None
254
+ function: str | None = None
255
+ line_no: int | None = None
256
+ tag: str | None = None
257
+
258
+ def __post_init__(self, **kwargs):
259
+ """
260
+ Initialize the task and emit a "task-created" event.
261
+
262
+ Sets up the task context including caller tracking and event emission
263
+ for monitoring and debugging purposes.
264
+
265
+ :param kwargs:
266
+ Additional keyword arguments (unused).
267
+ """
268
+ if caller := _current_task.get():
269
+ self.caller = caller.id
270
+ WoolTaskEvent("task-created", task=self).emit()
271
+
272
+ def __enter__(self) -> Callable[[], Coroutine]:
273
+ """
274
+ Enter the task context for execution.
275
+
276
+ :returns:
277
+ The task's run method as a callable coroutine.
278
+ """
279
+ logging.debug(f"Entering {self.__class__.__name__} with ID {self.id}")
280
+ return self.run
281
+
282
+ def __exit__(
283
+ self,
284
+ exception_type: type[BaseException] | None,
285
+ exception_value: BaseException | None,
286
+ exception_traceback: TracebackType | None,
287
+ ):
288
+ """Exit the task context and handle any exceptions.
289
+
290
+ Captures exception information for later processing and allows
291
+ exceptions to propagate normally for proper error handling.
292
+
293
+ :param exception_type:
294
+ Type of exception that occurred, if any.
295
+ :param exception_value:
296
+ Exception instance that occurred, if any.
297
+ :param exception_traceback:
298
+ Traceback of the exception, if any.
299
+ :returns:
300
+ False to allow exceptions to propagate.
301
+ """
302
+ logging.debug(f"Exiting {self.__class__.__name__} with ID {self.id}")
303
+ if exception_value:
304
+ this = asyncio.current_task()
305
+ assert this
306
+ self.exception = WoolTaskException(
307
+ exception_type.__qualname__,
308
+ traceback=[
309
+ y
310
+ for x in traceback.format_exception(
311
+ exception_type, exception_value, exception_traceback
312
+ )
313
+ for y in x.split("\n")
314
+ ],
315
+ )
316
+ this.add_done_callback(self._finish, context=Context())
317
+ # Return False to allow exceptions to propagate
318
+ return False
319
+
320
+ @classmethod
321
+ def from_protobuf(cls, task: pb.task.Task) -> WoolTask:
322
+ return cls(
323
+ id=UUID(task.id),
324
+ callable=cloudpickle.loads(task.callable),
325
+ args=cloudpickle.loads(task.args),
326
+ kwargs=cloudpickle.loads(task.kwargs),
327
+ caller=UUID(task.caller) if task.caller else None,
328
+ proxy=cloudpickle.loads(task.proxy),
329
+ )
330
+
331
+ def to_protobuf(self) -> pb.task.Task:
332
+ return pb.task.Task(
333
+ id=str(self.id),
334
+ callable=cloudpickle.dumps(self.callable),
335
+ args=cloudpickle.dumps(self.args),
336
+ kwargs=cloudpickle.dumps(self.kwargs),
337
+ caller=str(self.caller) if self.caller else "",
338
+ proxy=cloudpickle.dumps(self.proxy),
339
+ proxy_id=str(self.proxy.id),
340
+ )
341
+
342
+ async def run(self) -> Coroutine:
343
+ """
344
+ Execute the task's callable with its arguments in proxy context.
345
+
346
+ :returns:
347
+ A coroutine representing the routine execution.
348
+ :raises RuntimeError:
349
+ If no proxy is available for task execution.
350
+ """
351
+ proxy_pool = wool.__proxy_pool__.get()
352
+ assert proxy_pool
353
+ async with proxy_pool.get(self.proxy) as proxy:
354
+ # Set the proxy in context variable for nested task dispatch
355
+ token = wool.__proxy__.set(proxy)
356
+ try:
357
+ work = self._with_self(self.callable)
358
+ return await work(*self.args, **self.kwargs)
359
+ finally:
360
+ wool.__proxy__.reset(token)
361
+
362
+ def _finish(self, _):
363
+ WoolTaskEvent("task-completed", task=self).emit()
364
+
365
+ def _with_self(self, fn: AsyncCallable) -> AsyncCallable:
366
+ @wraps(fn)
367
+ async def wrapper(*args, **kwargs):
368
+ with self:
369
+ current_task_token = _current_task.set(self)
370
+ # Do not re-submit this task, execute it locally
371
+ local_token = _do_dispatch.set(False)
372
+ # Yield to event loop with context set
373
+ await asyncio.sleep(0)
374
+ try:
375
+ result = await fn(*args, **kwargs)
376
+ return result
377
+ finally:
378
+ _current_task.reset(current_task_token)
379
+ _do_dispatch.reset(local_token)
380
+
381
+ return wrapper
382
+
383
+
384
+ # public
385
+ @dataclass
386
+ class WoolTaskException:
387
+ """
388
+ Represents an exception that occurred during distributed task execution.
389
+
390
+ Captures exception information from remote task execution for proper
391
+ error reporting and debugging. The exception details are serialized
392
+ and transmitted back to the calling context.
393
+
394
+ :param type:
395
+ Qualified name of the exception class.
396
+ :param traceback:
397
+ List of formatted traceback lines from the exception.
398
+ """
399
+
400
+ type: str
401
+ traceback: list[str]
402
+
403
+
404
+ # public
405
+ class WoolTaskEvent:
406
+ """
407
+ Represents a lifecycle event for a distributed task.
408
+
409
+ Events are emitted at key points during task execution and can be used
410
+ for monitoring, debugging, and performance analysis. Event handlers can
411
+ be registered to respond to specific event types.
412
+
413
+ :param type:
414
+ The type of task event (e.g., "task-created", "task-scheduled").
415
+ :param task:
416
+ The :class:`WoolTask` instance associated with this event.
417
+ """
418
+
419
+ type: WoolTaskEventType
420
+ task: WoolTask
421
+
422
+ _handlers: dict[str, list[WoolTaskEventCallback]] = {}
423
+
424
+ def __init__(self, type: WoolTaskEventType, /, task: WoolTask) -> None:
425
+ """
426
+ Initialize a WoolTaskEvent instance.
427
+
428
+ :param type:
429
+ The type of the task event.
430
+ :param task:
431
+ The :class:`WoolTask` instance associated with the event.
432
+ """
433
+ self.type = type
434
+ self.task = task
435
+
436
+ @classmethod
437
+ def handler(
438
+ cls, *event_types: WoolTaskEventType
439
+ ) -> PassthroughDecorator[WoolTaskEventCallback]:
440
+ """
441
+ Register a handler function for specific task event types.
442
+
443
+ :param event_types:
444
+ The event types to handle.
445
+ :returns:
446
+ A decorator to register the handler function.
447
+ """
448
+
449
+ def _handler(
450
+ fn: WoolTaskEventCallback,
451
+ ) -> WoolTaskEventCallback:
452
+ for event_type in event_types:
453
+ cls._handlers.setdefault(event_type, []).append(fn)
454
+ return fn
455
+
456
+ return _handler
457
+
458
+ def emit(self):
459
+ """
460
+ Emit the task event, invoking all registered handlers for the
461
+ event type.
462
+
463
+ Handlers are called with the event instance and a timestamp.
464
+
465
+ :raises Exception:
466
+ If any handler raises an exception.
467
+ """
468
+ logging.debug(
469
+ f"Emitting {self.type} event for "
470
+ f"task {self.task.id} "
471
+ f"({self.task.callable.__qualname__})"
472
+ )
473
+ if handlers := self._handlers.get(self.type):
474
+ timestamp = perf_counter_ns()
475
+ for handler in handlers:
476
+ handler(self, timestamp)
477
+
478
+
479
+ # public
480
+ WoolTaskEventType = Literal[
481
+ "task-created",
482
+ "task-queued",
483
+ "task-scheduled",
484
+ "task-started",
485
+ "task-stopped",
486
+ "task-completed",
487
+ ]
488
+ """
489
+ Defines the types of events that can occur during the lifecycle of a Wool
490
+ task.
491
+
492
+ - "task-created":
493
+ Emitted when a task is created.
494
+ - "task-queued":
495
+ Emitted when a task is added to the queue.
496
+ - "task-scheduled":
497
+ Emitted when a task is scheduled for execution in a worker's event
498
+ loop.
499
+ - "task-started":
500
+ Emitted when a task starts execution.
501
+ - "task-stopped":
502
+ Emitted when a task stops execution.
503
+ - "task-completed":
504
+ Emitted when a task completes execution.
505
+ """
506
+
507
+
508
+ # public
509
+ class WoolTaskEventCallback(Protocol):
510
+ """
511
+ Protocol for WoolTaskEvent callback functions.
512
+ """
513
+
514
+ def __call__(self, event: WoolTaskEvent, timestamp: Timestamp) -> None: ...
515
+
516
+
517
+ _do_dispatch: ContextVar[bool] = ContextVar("_do_dispatch", default=True)
518
+ _current_task: ContextVar[WoolTask | None] = ContextVar("_current_task", default=None)
519
+
520
+
521
+ def _run(fn):
522
+ @wraps(fn)
523
+ def wrapper(self, *args, **kwargs):
524
+ if current_task := self._context.get(_current_task):
525
+ WoolTaskEvent("task-started", task=current_task).emit()
526
+ try:
527
+ result = fn(self, *args, **kwargs)
528
+ finally:
529
+ WoolTaskEvent("task-stopped", task=current_task).emit()
530
+ return result
531
+ else:
532
+ return fn(self, *args, **kwargs)
533
+
534
+ return wrapper
535
+
536
+
537
+ asyncio.Handle._run = _run(asyncio.Handle._run)
538
+
539
+
540
+ P = ParamSpec("P")
541
+ R = TypeVar("R")
542
+
543
+
544
+ def _resolve(
545
+ method: Callable[P, R],
546
+ ) -> Tuple[Type | ModuleType | None, Callable[P, R]]:
547
+ scope = modules[method.__module__]
548
+ parent = None
549
+ for name in method.__qualname__.split("."):
550
+ parent = scope
551
+ scope = getattr(scope, name)
552
+ assert scope
553
+ assert isinstance(parent, (Type, ModuleType))
554
+ return parent, cast(Callable[P, R], scope)
wool/core/__init__.py ADDED
File without changes
File without changes