wool 0.1rc6__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,57 +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
108
  def wrapper(
51
109
  *args,
52
110
  __wool_remote__: bool = False,
53
- __wool_client__: WoolClient | None = None,
111
+ __wool_session__: WorkerPoolSession | None = None,
54
112
  **kwargs,
55
113
  ) -> Coroutine:
56
114
  # Handle static and class methods in a picklable way.
57
- parent, function = resolve(fn)
115
+ parent, function = _resolve(fn)
58
116
  assert parent is not None
59
117
  assert callable(function)
60
118
 
@@ -64,7 +122,7 @@ def task(fn: C) -> C:
64
122
  else:
65
123
  # Otherwise, submit the task to the pool.
66
124
  return _put(
67
- __wool_client__ or wool.__wool_client__.get(),
125
+ __wool_session__ or current_session(),
68
126
  wrapper.__module__,
69
127
  wrapper.__qualname__,
70
128
  function,
@@ -76,14 +134,15 @@ def task(fn: C) -> C:
76
134
 
77
135
 
78
136
  def _put(
79
- client: WoolClient,
137
+ session: WorkerPoolSession,
80
138
  module: str,
81
139
  qualname: str,
82
140
  function: AsyncCallable,
83
141
  *args,
84
142
  **kwargs,
85
143
  ) -> Coroutine:
86
- assert client.connected
144
+ if not session.connected:
145
+ session.connect()
87
146
 
88
147
  # Skip self argument if function is a method.
89
148
  _args = args[1:] if hasattr(function, "__self__") else args
@@ -98,28 +157,29 @@ def _put(
98
157
  # pool, so we set the `__wool_remote__` flag to true.
99
158
  kwargs["__wool_remote__"] = True
100
159
 
101
- task = WoolTask(
160
+ task = Task(
102
161
  id=uuid4(),
103
162
  callable=function,
104
163
  args=args,
105
164
  kwargs=kwargs,
106
165
  tag=f"{module}.{qualname}({signature})",
107
166
  )
108
- assert isinstance(client, WoolClient)
109
- future: WoolFuture = client.put(task)
167
+ assert isinstance(session, WorkerPoolSession)
168
+ future: Future = session.put(task)
110
169
 
111
- async def coroutine(future):
170
+ @wraps(function)
171
+ async def coroutine(future: Future):
112
172
  try:
113
173
  while not future.done():
114
174
  await asyncio.sleep(0)
115
175
  else:
116
- try:
117
- return future.result()
118
- except Exception as e:
119
- raise Exception().with_traceback(e.__traceback__) from e
176
+ return future.result()
177
+ except ConnectionResetError as e:
178
+ raise asyncio.CancelledError from e
120
179
  except asyncio.CancelledError:
121
- logging.debug("Cancelling...")
122
- future.cancel()
180
+ if not future.done():
181
+ logging.debug("Cancelling...")
182
+ future.cancel()
123
183
  raise
124
184
 
125
185
  return coroutine(future)
@@ -133,50 +193,78 @@ def _execute(fn: AsyncCallable, parent, *args, **kwargs):
133
193
 
134
194
 
135
195
  # PUBLIC
136
- def current_task() -> WoolTask | None:
196
+ def current_task() -> Task | None:
137
197
  """
138
198
  Get the current task from the context variable if we are inside a task
139
199
  context, otherwise return None.
200
+
201
+ :return: The current task or None if no task is active.
140
202
  """
141
203
  return _current_task.get()
142
204
 
143
205
 
144
206
  # PUBLIC
145
207
  @dataclass
146
- 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
+
147
226
  id: UUID
148
227
  callable: AsyncCallable
149
228
  args: Args
150
229
  kwargs: Kwargs
151
230
  timeout: Timeout = 0
152
231
  caller: UUID | None = None
153
- exception: WoolTaskException | None = None
232
+ exception: TaskException | None = None
154
233
  filename: str | None = None
155
234
  function: str | None = None
156
235
  line_no: int | None = None
157
236
  tag: str | None = None
158
237
 
159
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
+ """
160
244
  if caller := _current_task.get():
161
245
  self.caller = caller.id
162
- WoolTaskEvent("task-created", task=self).emit()
246
+ TaskEvent("task-created", task=self).emit()
163
247
 
164
248
  def __enter__(self) -> Callable[[], Coroutine]:
165
- logging.info(f"Entering {self.__class__.__name__} with ID {self.id}")
166
- 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}")
167
255
  return self.run
168
256
 
169
257
  def __exit__(
170
258
  self,
171
- exception_type: type,
172
- exception_value: Exception,
173
- exception_traceback: TracebackType,
259
+ exception_type: type[BaseException] | None,
260
+ exception_value: BaseException | None,
261
+ exception_traceback: TracebackType | None,
174
262
  ):
175
- logging.info(f"Exiting {self.__class__.__name__} with ID {self.id}")
263
+ logging.debug(f"Exiting {self.__class__.__name__} with ID {self.id}")
176
264
  if exception_value:
177
265
  this = asyncio.current_task()
178
266
  assert this
179
- self.exception = WoolTaskException(
267
+ self.exception = TaskException(
180
268
  exception_type.__qualname__,
181
269
  traceback=[
182
270
  y
@@ -189,9 +277,14 @@ class WoolTask:
189
277
  this.add_done_callback(self._finish, context=Context())
190
278
 
191
279
  def _finish(self, _):
192
- WoolTaskEvent("task-completed", task=self).emit()
280
+ TaskEvent("task-completed", task=self).emit()
193
281
 
194
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
+ """
195
288
  work = self._with_task(self.callable)
196
289
  return work(*self.args, **self.kwargs)
197
290
 
@@ -210,67 +303,29 @@ class WoolTask:
210
303
 
211
304
 
212
305
  # PUBLIC
213
- class WoolTaskEvent:
214
- """
215
- Task events are emitted when a task is created, queued, started, stopped,
216
- and completed. Tasks can be started and stopped multiple times by the event
217
- loop. The cumulative time between start and stop events can be used to
218
- determine a task's CPU utilization.
306
+ @dataclass
307
+ class TaskException:
219
308
  """
309
+ Represents an exception raised during task execution.
220
310
 
221
- type: WoolTaskEventType
222
- task: WoolTask
223
-
224
- _handlers: dict[str, list[WoolTaskEventCallback]] = {}
225
-
226
- def __init__(self, type: WoolTaskEventType, /, task: WoolTask) -> None:
227
- self.type = type
228
- self.task = task
229
-
230
- @classmethod
231
- def handler(
232
- cls, *event_types: WoolTaskEventType
233
- ) -> PassthroughDecorator[WoolTaskEventCallback]:
234
- def _handler(
235
- fn: WoolTaskEventCallback,
236
- ) -> WoolTaskEventCallback:
237
- for event_type in event_types:
238
- cls._handlers.setdefault(event_type, []).append(fn)
239
- return fn
240
-
241
- return _handler
242
-
243
- def emit(self):
244
- logging.debug(f"Emitting {self.type} event for task {self.task.id}")
245
- if handlers := self._handlers.get(self.type):
246
- timestamp = perf_counter_ns()
247
- for handler in handlers:
248
- handler(self, timestamp)
249
-
311
+ :param type: The type of the exception.
312
+ :param traceback: The traceback of the exception.
313
+ """
250
314
 
251
- # PUBLIC
252
- WoolTaskEventType = Literal[
253
- "task-created",
254
- "task-queued",
255
- "task-started",
256
- "task-stopped",
257
- "task-completed",
258
- ]
315
+ type: str
316
+ traceback: list[str]
259
317
 
260
318
 
261
319
  # PUBLIC
262
- class WoolTaskEventCallback(Protocol):
263
- def __call__(self, event: WoolTaskEvent, timestamp: Timestamp) -> None: ...
264
-
320
+ class TaskEventCallback(Protocol):
321
+ """
322
+ Protocol for WoolTaskEvent callback functions.
323
+ """
265
324
 
266
- # PUBLIC
267
- @dataclass
268
- class WoolTaskException:
269
- type: str
270
- traceback: list[str]
325
+ def __call__(self, event: TaskEvent, timestamp: Timestamp) -> None: ...
271
326
 
272
327
 
273
- _current_task: ContextVar[WoolTask | None] = ContextVar(
328
+ _current_task: ContextVar[Task | None] = ContextVar(
274
329
  "_current_task", default=None
275
330
  )
276
331
 
@@ -279,11 +334,11 @@ def _run(fn):
279
334
  @wraps(fn)
280
335
  def wrapper(self, *args, **kwargs):
281
336
  if current_task := self._context.get(_current_task):
282
- WoolTaskEvent("task-started", task=current_task).emit()
337
+ TaskEvent("task-started", task=current_task).emit()
283
338
  try:
284
339
  result = fn(self, *args, **kwargs)
285
340
  finally:
286
- WoolTaskEvent("task-stopped", task=current_task).emit()
341
+ TaskEvent("task-stopped", task=current_task).emit()
287
342
  return result
288
343
  else:
289
344
  return fn(self, *args, **kwargs)
@@ -298,13 +353,14 @@ P = ParamSpec("P")
298
353
  R = TypeVar("R")
299
354
 
300
355
 
301
- def resolve(method: Callable[P, R]) -> tuple[Any, Callable[P, R]]:
302
- """
303
- Make static and class methods picklable from within their decorators.
304
- """
356
+ def _resolve(
357
+ method: Callable[P, R],
358
+ ) -> Tuple[Type | ModuleType | None, Callable[P, R]]:
305
359
  scope = modules[method.__module__]
306
360
  parent = None
307
361
  for name in method.__qualname__.split("."):
308
362
  parent = scope
309
363
  scope = getattr(scope, name)
364
+ assert scope
365
+ assert isinstance(parent, (Type, ModuleType))
310
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