wool 0.1rc3__py3-none-any.whl → 0.1rc7__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.

wool/_task.py CHANGED
@@ -4,52 +4,115 @@ import asyncio
4
4
  import logging
5
5
  import traceback
6
6
  from collections.abc import Callable
7
- from contextvars import Context, ContextVar
7
+ from contextvars import Context
8
+ from contextvars import ContextVar
8
9
  from dataclasses import dataclass
9
10
  from functools import wraps
10
11
  from sys import modules
11
- from time import perf_counter_ns
12
+ from types import ModuleType
12
13
  from types import TracebackType
13
- from typing import (
14
- Any,
15
- Coroutine,
16
- Literal,
17
- ParamSpec,
18
- Protocol,
19
- TypeVar,
20
- cast,
21
- )
22
- from uuid import UUID, uuid4
23
-
24
- import wool
25
- from wool._future import WoolFuture
26
- from wool._pool import WoolClient
27
- from wool._typing import PassthroughDecorator
14
+ from typing import Coroutine
15
+ from typing import Dict
16
+ from typing import ParamSpec
17
+ from typing import Protocol
18
+ from typing import SupportsInt
19
+ from typing import Tuple
20
+ from typing import Type
21
+ from typing import TypeVar
22
+ from typing import cast
23
+ from uuid import UUID
24
+ from uuid import uuid4
25
+
26
+ from wool._event import TaskEvent
27
+ from wool._future import Future
28
+ from wool._pool import WorkerPoolSession
29
+ from wool._session import current_session
28
30
 
29
31
  AsyncCallable = Callable[..., Coroutine]
30
32
  C = TypeVar("C", bound=AsyncCallable)
31
33
 
32
- Args = tuple
33
- Kwargs = dict
34
- Timeout = int
35
- Timestamp = int
34
+ Args = Tuple
35
+ Kwargs = Dict
36
+ Timeout = SupportsInt
37
+ Timestamp = SupportsInt
36
38
 
37
39
 
40
+ # PUBLIC
38
41
  def task(fn: C) -> C:
39
42
  """
40
- A decorator to declare a function as remotely executed using a worker pool.
41
- This decorator allows a function to be executed either locally or remotely
42
- using a worker pool. If a worker pool is provided, the function will be
43
- submitted to the pool for remote execution. If no pool is provided, the
44
- function will be executed locally.
45
- The decorator also handles the case where the function is being executed
46
- within a worker, ensuring that it does not resubmit itself to the pool.
43
+ A decorator to declare an asynchronous function as remotely executable by a
44
+ worker pool. When the wrapped function is invoked, it is dispatched to the
45
+ worker pool associated with the current worker pool session context.
46
+
47
+ Tasks behave like coroutines, meaning they can be awaited as well as
48
+ cancelled.
49
+
50
+ :param fn: The task function.
51
+ :return: A Wool task declaration.
52
+
53
+ **Best practices and considerations for designing tasks:**
54
+
55
+ 1. **Picklability**:
56
+ - Task arguments and return values must be picklable, as they are
57
+ serialized and transferred between processes. Avoid passing
58
+ unpicklable objects such as open file handles, database
59
+ connections, or lambda functions.
60
+ - Ensure that any custom objects used as arguments or return values
61
+ implement the necessary methods for pickling (e.g.,
62
+ ``__getstate__`` and ``__setstate__``).
63
+
64
+ 2. **Synchronization**:
65
+ - Tasks are not guaranteed to execute on the same process between
66
+ invocations. Each invocation may run on a different worker process.
67
+ - Standard ``asyncio`` synchronization primitives (e.g.,
68
+ ``asyncio.Lock``) will not behave as expected in a multi-process
69
+ environment, as they are designed for single-process applications.
70
+ Use the specialized ``wool.locking`` synchronization primitives to
71
+ achieve inter-worker and inter-pool synchronization.
72
+
73
+ 3. **Statelessness and idempotency**:
74
+ - Design tasks to be stateless and idemptoent. Avoid relying on global
75
+ variables or shared mutable state. This ensures predictable
76
+ behavior, avoids race conditions, and enables safe retries.
77
+
78
+ 4. **Cancellation**:
79
+ - Task cancellation and propagation thereof mimics that of standard
80
+ Python coroutines.
81
+
82
+ 5. **Error propagation**:
83
+ - Wool makes every effort to execute tasks transparently to the user,
84
+ and this includes error propagation. Unhandled exceptions raised
85
+ within a task will be propagated to the caller as they would
86
+ normally.
87
+
88
+ 6. **Performance**:
89
+ - Minimize the size of arguments and return values to reduce
90
+ serialization overhead.
91
+ - For large datasets, consider using shared memory or passing
92
+ references (e.g., file paths) instead of transferring the entire
93
+ data.
94
+
95
+ **Usage**::
96
+
97
+ .. code-block:: python
98
+
99
+ import wool
100
+
101
+
102
+ @wool.task
103
+ async def foo(...):
104
+ ...
47
105
  """
48
106
 
49
107
  @wraps(fn)
50
- def wrapper(*args, __wool_remote__: bool = False, __wool_client__: WoolClient | None = None, **kwargs) -> Coroutine:
108
+ def wrapper(
109
+ *args,
110
+ __wool_remote__: bool = False,
111
+ __wool_session__: WorkerPoolSession | None = None,
112
+ **kwargs,
113
+ ) -> Coroutine:
51
114
  # Handle static and class methods in a picklable way.
52
- parent, function = resolve(fn)
115
+ parent, function = _resolve(fn)
53
116
  assert parent is not None
54
117
  assert callable(function)
55
118
 
@@ -59,7 +122,7 @@ def task(fn: C) -> C:
59
122
  else:
60
123
  # Otherwise, submit the task to the pool.
61
124
  return _put(
62
- __wool_client__ or wool.__wool_client__.get(),
125
+ __wool_session__ or current_session(),
63
126
  wrapper.__module__,
64
127
  wrapper.__qualname__,
65
128
  function,
@@ -71,14 +134,15 @@ def task(fn: C) -> C:
71
134
 
72
135
 
73
136
  def _put(
74
- client: WoolClient,
137
+ session: WorkerPoolSession,
75
138
  module: str,
76
139
  qualname: str,
77
140
  function: AsyncCallable,
78
141
  *args,
79
142
  **kwargs,
80
143
  ) -> Coroutine:
81
- assert client.connected
144
+ if not session.connected:
145
+ session.connect()
82
146
 
83
147
  # Skip self argument if function is a method.
84
148
  _args = args[1:] if hasattr(function, "__self__") else args
@@ -93,28 +157,29 @@ def _put(
93
157
  # pool, so we set the `__wool_remote__` flag to true.
94
158
  kwargs["__wool_remote__"] = True
95
159
 
96
- task = WoolTask(
160
+ task = Task(
97
161
  id=uuid4(),
98
162
  callable=function,
99
163
  args=args,
100
164
  kwargs=kwargs,
101
165
  tag=f"{module}.{qualname}({signature})",
102
166
  )
103
- assert isinstance(client, WoolClient)
104
- future: WoolFuture = client.put(task)
167
+ assert isinstance(session, WorkerPoolSession)
168
+ future: Future = session.put(task)
105
169
 
106
- async def coroutine(future):
170
+ @wraps(function)
171
+ async def coroutine(future: Future):
107
172
  try:
108
173
  while not future.done():
109
174
  await asyncio.sleep(0)
110
175
  else:
111
- try:
112
- return future.result()
113
- except Exception as e:
114
- raise Exception().with_traceback(e.__traceback__) from e
176
+ return future.result()
177
+ except ConnectionResetError as e:
178
+ raise asyncio.CancelledError from e
115
179
  except asyncio.CancelledError:
116
- logging.debug("Cancelling...")
117
- future.cancel()
180
+ if not future.done():
181
+ logging.debug("Cancelling...")
182
+ future.cancel()
118
183
  raise
119
184
 
120
185
  return coroutine(future)
@@ -128,50 +193,78 @@ def _execute(fn: AsyncCallable, parent, *args, **kwargs):
128
193
 
129
194
 
130
195
  # PUBLIC
131
- def current_task() -> WoolTask | None:
196
+ def current_task() -> Task | None:
132
197
  """
133
198
  Get the current task from the context variable if we are inside a task
134
199
  context, otherwise return None.
200
+
201
+ :return: The current task or None if no task is active.
135
202
  """
136
203
  return _current_task.get()
137
204
 
138
205
 
139
206
  # PUBLIC
140
207
  @dataclass
141
- class WoolTask:
208
+ class Task:
209
+ """
210
+ Represents a task to be executed in the worker pool.
211
+
212
+ :param id: The unique identifier for the task.
213
+ :param callable: The asynchronous function to execute.
214
+ :param args: Positional arguments for the function.
215
+ :param kwargs: Keyword arguments for the function.
216
+ :param timeout: The timeout for the task in seconds.
217
+ Defaults to 0 (no timeout).
218
+ :param caller: The ID of the calling task, if any.
219
+ :param exception: The exception raised during task execution, if any.
220
+ :param filename: The filename where the task was defined.
221
+ :param function: The name of the function being executed.
222
+ :param line_no: The line number where the task was defined.
223
+ :param tag: An optional tag for the task.
224
+ """
225
+
142
226
  id: UUID
143
227
  callable: AsyncCallable
144
228
  args: Args
145
229
  kwargs: Kwargs
146
230
  timeout: Timeout = 0
147
231
  caller: UUID | None = None
148
- exception: WoolTaskException | None = None
232
+ exception: TaskException | None = None
149
233
  filename: str | None = None
150
234
  function: str | None = None
151
235
  line_no: int | None = None
152
236
  tag: str | None = None
153
237
 
154
238
  def __post_init__(self, **kwargs):
239
+ """
240
+ Initialize the task and emit a "task-created" event.
241
+
242
+ :param kwargs: Additional keyword arguments.
243
+ """
155
244
  if caller := _current_task.get():
156
245
  self.caller = caller.id
157
- WoolTaskEvent("task-created", task=self).emit()
246
+ TaskEvent("task-created", task=self).emit()
158
247
 
159
248
  def __enter__(self) -> Callable[[], Coroutine]:
160
- logging.info(f"Entering {self.__class__.__name__} with ID {self.id}")
161
- WoolTaskEvent("task-queued", task=self).emit()
249
+ """
250
+ Enter the context of the task.
251
+
252
+ :return: The task's run method.
253
+ """
254
+ logging.debug(f"Entering {self.__class__.__name__} with ID {self.id}")
162
255
  return self.run
163
256
 
164
257
  def __exit__(
165
258
  self,
166
- exception_type: type,
167
- exception_value: Exception,
168
- exception_traceback: TracebackType,
259
+ exception_type: type[BaseException] | None,
260
+ exception_value: BaseException | None,
261
+ exception_traceback: TracebackType | None,
169
262
  ):
170
- logging.info(f"Exiting {self.__class__.__name__} with ID {self.id}")
263
+ logging.debug(f"Exiting {self.__class__.__name__} with ID {self.id}")
171
264
  if exception_value:
172
265
  this = asyncio.current_task()
173
266
  assert this
174
- self.exception = WoolTaskException(
267
+ self.exception = TaskException(
175
268
  exception_type.__qualname__,
176
269
  traceback=[
177
270
  y
@@ -184,9 +277,14 @@ class WoolTask:
184
277
  this.add_done_callback(self._finish, context=Context())
185
278
 
186
279
  def _finish(self, _):
187
- WoolTaskEvent("task-completed", task=self).emit()
280
+ TaskEvent("task-completed", task=self).emit()
188
281
 
189
282
  def run(self) -> Coroutine:
283
+ """
284
+ Execute the task's callable with its arguments.
285
+
286
+ :return: A coroutine representing the task execution.
287
+ """
190
288
  work = self._with_task(self.callable)
191
289
  return work(*self.args, **self.kwargs)
192
290
 
@@ -205,67 +303,29 @@ class WoolTask:
205
303
 
206
304
 
207
305
  # PUBLIC
208
- class WoolTaskEvent:
209
- """
210
- Task events are emitted when a task is created, queued, started, stopped,
211
- and completed. Tasks can be started and stopped multiple times by the event
212
- loop. The cumulative time between start and stop events can be used to
213
- determine a task's CPU utilization.
306
+ @dataclass
307
+ class TaskException:
214
308
  """
309
+ Represents an exception raised during task execution.
215
310
 
216
- type: WoolTaskEventType
217
- task: WoolTask
218
-
219
- _handlers: dict[str, list[WoolTaskEventCallback]] = {}
220
-
221
- def __init__(self, type: WoolTaskEventType, /, task: WoolTask) -> None:
222
- self.type = type
223
- self.task = task
224
-
225
- @classmethod
226
- def handler(
227
- cls, *event_types: WoolTaskEventType
228
- ) -> PassthroughDecorator[WoolTaskEventCallback]:
229
- def _handler(
230
- fn: WoolTaskEventCallback,
231
- ) -> WoolTaskEventCallback:
232
- for event_type in event_types:
233
- cls._handlers.setdefault(event_type, []).append(fn)
234
- return fn
235
-
236
- return _handler
237
-
238
- def emit(self):
239
- logging.debug(f"Emitting {self.type} event for task {self.task.id}")
240
- if handlers := self._handlers.get(self.type):
241
- timestamp = perf_counter_ns()
242
- for handler in handlers:
243
- handler(self, timestamp)
244
-
311
+ :param type: The type of the exception.
312
+ :param traceback: The traceback of the exception.
313
+ """
245
314
 
246
- # PUBLIC
247
- WoolTaskEventType = Literal[
248
- "task-created",
249
- "task-queued",
250
- "task-started",
251
- "task-stopped",
252
- "task-completed",
253
- ]
315
+ type: str
316
+ traceback: list[str]
254
317
 
255
318
 
256
319
  # PUBLIC
257
- class WoolTaskEventCallback(Protocol):
258
- def __call__(self, event: WoolTaskEvent, timestamp: Timestamp) -> None: ...
259
-
320
+ class TaskEventCallback(Protocol):
321
+ """
322
+ Protocol for WoolTaskEvent callback functions.
323
+ """
260
324
 
261
- # PUBLIC
262
- @dataclass
263
- class WoolTaskException:
264
- type: str
265
- traceback: list[str]
325
+ def __call__(self, event: TaskEvent, timestamp: Timestamp) -> None: ...
266
326
 
267
327
 
268
- _current_task: ContextVar[WoolTask | None] = ContextVar(
328
+ _current_task: ContextVar[Task | None] = ContextVar(
269
329
  "_current_task", default=None
270
330
  )
271
331
 
@@ -274,11 +334,11 @@ def _run(fn):
274
334
  @wraps(fn)
275
335
  def wrapper(self, *args, **kwargs):
276
336
  if current_task := self._context.get(_current_task):
277
- WoolTaskEvent("task-started", task=current_task).emit()
337
+ TaskEvent("task-started", task=current_task).emit()
278
338
  try:
279
339
  result = fn(self, *args, **kwargs)
280
340
  finally:
281
- WoolTaskEvent("task-stopped", task=current_task).emit()
341
+ TaskEvent("task-stopped", task=current_task).emit()
282
342
  return result
283
343
  else:
284
344
  return fn(self, *args, **kwargs)
@@ -293,13 +353,14 @@ P = ParamSpec("P")
293
353
  R = TypeVar("R")
294
354
 
295
355
 
296
- def resolve(method: Callable[P, R]) -> tuple[Any, Callable[P, R]]:
297
- """
298
- Make static and class methods picklable from within their decorators.
299
- """
356
+ def _resolve(
357
+ method: Callable[P, R],
358
+ ) -> Tuple[Type | ModuleType | None, Callable[P, R]]:
300
359
  scope = modules[method.__module__]
301
360
  parent = None
302
361
  for name in method.__qualname__.split("."):
303
362
  parent = scope
304
363
  scope = getattr(scope, name)
364
+ assert scope
365
+ assert isinstance(parent, (Type, ModuleType))
305
366
  return parent, cast(Callable[P, R], scope)
wool/_typing.py CHANGED
@@ -1,4 +1,8 @@
1
- from typing import Annotated, Callable, Literal, SupportsInt, TypeVar
1
+ from typing import Annotated
2
+ from typing import Callable
3
+ from typing import Literal
4
+ from typing import SupportsInt
5
+ from typing import TypeVar
2
6
 
3
7
  from annotated_types import Gt
4
8
 
wool/_utils.py CHANGED
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import threading
4
- from typing import Callable, Final, Generic, TypeVar
4
+ from typing import Callable
5
+ from typing import Final
6
+ from typing import Generic
7
+ from typing import TypeVar
5
8
 
6
9
 
7
10
  class UndefinedType:
@@ -23,12 +26,6 @@ Undefined: Final = UndefinedType()
23
26
 
24
27
 
25
28
  class PredicatedEvent(threading.Event):
26
- """
27
- This class extends the threading.Event class and adds functionality to
28
- automatically set the event if the given predicate function returns True
29
- when the event's state is checked.
30
- """
31
-
32
29
  def __init__(self, predicate: Callable[[], bool]):
33
30
  self._predicate = predicate
34
31
  super().__init__()
@@ -53,18 +50,14 @@ class Property(Generic[T]):
53
50
  self._default = default
54
51
 
55
52
  def get(self) -> T:
56
- if self._value is Undefined and self._default is Undefined:
57
- raise LookupError
58
- elif self._value is Undefined:
59
- assert not isinstance(self._default, UndefinedType)
53
+ if isinstance(self._value, UndefinedType):
54
+ if isinstance(self._default, UndefinedType):
55
+ raise ValueError("Property value is undefined")
60
56
  return self._default
61
- else:
62
- assert not isinstance(self._value, UndefinedType)
63
- return self._value
57
+ return self._value
64
58
 
65
59
  def set(self, value: T) -> None:
66
- if self._value is Undefined:
67
- self._value = value
60
+ self._value = value
68
61
 
69
- def unset(self) -> None:
62
+ def reset(self) -> None:
70
63
  self._value = Undefined