wool 0.1rc9__py3-none-any.whl → 0.1rc11__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.

Potentially problematic release.


This version of wool might be problematic. Click here for more details.

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