pydocket 0.15.3__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.
- docket/__init__.py +55 -0
- docket/__main__.py +3 -0
- docket/_uuid7.py +99 -0
- docket/agenda.py +202 -0
- docket/annotations.py +81 -0
- docket/cli.py +1185 -0
- docket/dependencies.py +808 -0
- docket/docket.py +1062 -0
- docket/execution.py +1370 -0
- docket/instrumentation.py +225 -0
- docket/py.typed +0 -0
- docket/tasks.py +59 -0
- docket/testing.py +235 -0
- docket/worker.py +1071 -0
- pydocket-0.15.3.dist-info/METADATA +160 -0
- pydocket-0.15.3.dist-info/RECORD +19 -0
- pydocket-0.15.3.dist-info/WHEEL +4 -0
- pydocket-0.15.3.dist-info/entry_points.txt +2 -0
- pydocket-0.15.3.dist-info/licenses/LICENSE +9 -0
docket/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
docket - A distributed background task system for Python functions.
|
|
3
|
+
|
|
4
|
+
docket focuses on scheduling future work as seamlessly and efficiently as immediate work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
|
|
9
|
+
__version__ = version("pydocket")
|
|
10
|
+
|
|
11
|
+
from .agenda import Agenda
|
|
12
|
+
from .annotations import Logged
|
|
13
|
+
from .dependencies import (
|
|
14
|
+
ConcurrencyLimit,
|
|
15
|
+
CurrentDocket,
|
|
16
|
+
CurrentExecution,
|
|
17
|
+
CurrentWorker,
|
|
18
|
+
Depends,
|
|
19
|
+
ExponentialRetry,
|
|
20
|
+
Perpetual,
|
|
21
|
+
Progress,
|
|
22
|
+
Retry,
|
|
23
|
+
TaskArgument,
|
|
24
|
+
TaskKey,
|
|
25
|
+
TaskLogger,
|
|
26
|
+
Timeout,
|
|
27
|
+
)
|
|
28
|
+
from .docket import Docket
|
|
29
|
+
from .execution import Execution, ExecutionState
|
|
30
|
+
from .worker import Worker
|
|
31
|
+
from . import testing
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"__version__",
|
|
35
|
+
"Agenda",
|
|
36
|
+
"ConcurrencyLimit",
|
|
37
|
+
"CurrentDocket",
|
|
38
|
+
"CurrentExecution",
|
|
39
|
+
"CurrentWorker",
|
|
40
|
+
"Depends",
|
|
41
|
+
"Docket",
|
|
42
|
+
"Execution",
|
|
43
|
+
"ExecutionState",
|
|
44
|
+
"ExponentialRetry",
|
|
45
|
+
"Logged",
|
|
46
|
+
"Perpetual",
|
|
47
|
+
"Progress",
|
|
48
|
+
"Retry",
|
|
49
|
+
"TaskArgument",
|
|
50
|
+
"TaskKey",
|
|
51
|
+
"TaskLogger",
|
|
52
|
+
"testing",
|
|
53
|
+
"Timeout",
|
|
54
|
+
"Worker",
|
|
55
|
+
]
|
docket/__main__.py
ADDED
docket/_uuid7.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UUID v7 polyfill for Python < 3.14.
|
|
3
|
+
|
|
4
|
+
On Python 3.14+, we use the stdlib implementation. On older versions,
|
|
5
|
+
we use a simplified vendored implementation.
|
|
6
|
+
|
|
7
|
+
The vendored code can be removed once Python 3.13 support is dropped.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
__all__ = ("uuid7",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Vendored implementation for Python < 3.14
|
|
20
|
+
# From Stephen Simmons' implementation (v0.1.0, 2021-12-27)
|
|
21
|
+
# Original: https://github.com/stevesimmons/uuid7
|
|
22
|
+
# Adapted to match stdlib signature (no parameters)
|
|
23
|
+
|
|
24
|
+
# Module-level state for sequence counter (maintains monotonicity within same timestamp)
|
|
25
|
+
_last = [0, 0, 0, 0]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _vendored_uuid7() -> uuid.UUID:
|
|
29
|
+
"""
|
|
30
|
+
Generate a UUID v7 with embedded timestamp and sequence counter.
|
|
31
|
+
|
|
32
|
+
This implementation matches stdlib behavior by using a sequence counter
|
|
33
|
+
to guarantee monotonic ordering when multiple UUIDs are generated within
|
|
34
|
+
the same timestamp tick.
|
|
35
|
+
|
|
36
|
+
The 128 bits in the UUID are allocated as follows:
|
|
37
|
+
- 36 bits of whole seconds
|
|
38
|
+
- 24 bits of fractional seconds, giving approx 50ns resolution
|
|
39
|
+
- 14 bits of sequence counter (increments when time unchanged)
|
|
40
|
+
- 48 bits of randomness
|
|
41
|
+
plus, at locations defined by RFC4122, 4 bits for the
|
|
42
|
+
uuid version (0b0111) and 2 bits for the uuid variant (0b10).
|
|
43
|
+
|
|
44
|
+
0 1 2 3
|
|
45
|
+
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
46
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
47
|
+
t1 | unixts (secs since epoch) |
|
|
48
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
49
|
+
t2/t3 |unixts | frac secs (12 bits) | ver | frac secs (12 bits) |
|
|
50
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
51
|
+
t4/rand |var| seq (14 bits) | rand (16 bits) |
|
|
52
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
53
|
+
rand | rand (32 bits) |
|
|
54
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
55
|
+
"""
|
|
56
|
+
# Get current time in nanoseconds
|
|
57
|
+
ns = time.time_ns()
|
|
58
|
+
|
|
59
|
+
# Split into seconds and fractional parts for high-precision timestamp
|
|
60
|
+
# Treat the first 8 bytes of the uuid as a long (t1) and two ints
|
|
61
|
+
# (t2 and t3) holding 36 bits of whole seconds and 24 bits of
|
|
62
|
+
# fractional seconds.
|
|
63
|
+
# This gives a nominal 60ns resolution, comparable to the
|
|
64
|
+
# timestamp precision in Linux (~200ns) and Windows (100ns ticks).
|
|
65
|
+
sixteen_secs = 16_000_000_000
|
|
66
|
+
t1, rest1 = divmod(ns, sixteen_secs)
|
|
67
|
+
t2, rest2 = divmod(rest1 << 16, sixteen_secs)
|
|
68
|
+
t3, _ = divmod(rest2 << 12, sixteen_secs)
|
|
69
|
+
t3 |= 7 << 12 # Put uuid version in top 4 bits, which are 0 in t3
|
|
70
|
+
|
|
71
|
+
# The next two bytes are an int (t4) with two bits for
|
|
72
|
+
# the variant 2 and a 14 bit sequence counter which increments
|
|
73
|
+
# if the time is unchanged.
|
|
74
|
+
if t1 == _last[0] and t2 == _last[1] and t3 == _last[2]:
|
|
75
|
+
# Stop the seq counter wrapping past 0x3FFF.
|
|
76
|
+
# This won't happen in practice, but if it does,
|
|
77
|
+
# uuids after the 16383rd with that same timestamp
|
|
78
|
+
# will not longer be correctly ordered but
|
|
79
|
+
# are still unique due to the 6 random bytes.
|
|
80
|
+
if _last[3] < 0x3FFF:
|
|
81
|
+
_last[3] += 1
|
|
82
|
+
else:
|
|
83
|
+
_last[:] = (t1, t2, t3, 0)
|
|
84
|
+
t4 = (2 << 14) | _last[3] # Put variant 0b10 in top two bits
|
|
85
|
+
|
|
86
|
+
# Six random bytes for the lower part of the uuid
|
|
87
|
+
rand = os.urandom(6)
|
|
88
|
+
|
|
89
|
+
# Build the UUID from components
|
|
90
|
+
r = int.from_bytes(rand, "big")
|
|
91
|
+
uuid_int = (t1 << 96) + (t2 << 80) + (t3 << 64) + (t4 << 48) + r
|
|
92
|
+
return uuid.UUID(int=uuid_int)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# On Python 3.14+, use stdlib uuid7; otherwise use vendored implementation
|
|
96
|
+
if sys.version_info >= (3, 14):
|
|
97
|
+
from uuid import uuid7
|
|
98
|
+
else:
|
|
99
|
+
uuid7: Callable[[], uuid.UUID] = _vendored_uuid7
|
docket/agenda.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agenda - A collection of tasks that can be scheduled together.
|
|
3
|
+
|
|
4
|
+
The Agenda class provides a way to collect multiple tasks and then scatter them
|
|
5
|
+
evenly over a time period to avoid overwhelming the system with immediate work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import Any, Awaitable, Callable, Iterator, ParamSpec, TypeVar, overload
|
|
11
|
+
|
|
12
|
+
from ._uuid7 import uuid7
|
|
13
|
+
|
|
14
|
+
from .docket import Docket
|
|
15
|
+
from .execution import Execution, TaskFunction
|
|
16
|
+
|
|
17
|
+
P = ParamSpec("P")
|
|
18
|
+
R = TypeVar("R")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Agenda:
|
|
22
|
+
"""A collection of tasks to be scheduled together on a Docket.
|
|
23
|
+
|
|
24
|
+
The Agenda allows you to build up a collection of tasks with their arguments,
|
|
25
|
+
then schedule them all at once using various timing strategies like scattering.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
>>> agenda = Agenda()
|
|
29
|
+
>>> agenda.add(process_item)(item1)
|
|
30
|
+
>>> agenda.add(process_item)(item2)
|
|
31
|
+
>>> agenda.add(send_email)(email)
|
|
32
|
+
>>> await agenda.scatter(docket, over=timedelta(minutes=50))
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
"""Initialize an empty Agenda."""
|
|
37
|
+
self._tasks: list[
|
|
38
|
+
tuple[TaskFunction | str, tuple[Any, ...], dict[str, Any]]
|
|
39
|
+
] = []
|
|
40
|
+
|
|
41
|
+
def __len__(self) -> int:
|
|
42
|
+
"""Return the number of tasks in the agenda."""
|
|
43
|
+
return len(self._tasks)
|
|
44
|
+
|
|
45
|
+
def __iter__(
|
|
46
|
+
self,
|
|
47
|
+
) -> Iterator[tuple[TaskFunction | str, tuple[Any, ...], dict[str, Any]]]:
|
|
48
|
+
"""Iterate over tasks in the agenda."""
|
|
49
|
+
return iter(self._tasks)
|
|
50
|
+
|
|
51
|
+
@overload
|
|
52
|
+
def add(
|
|
53
|
+
self,
|
|
54
|
+
function: Callable[P, Awaitable[R]],
|
|
55
|
+
) -> Callable[P, None]:
|
|
56
|
+
"""Add a task function to the agenda.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
function: The task function to add.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A callable that accepts the task arguments.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
@overload
|
|
66
|
+
def add(
|
|
67
|
+
self,
|
|
68
|
+
function: str,
|
|
69
|
+
) -> Callable[..., None]:
|
|
70
|
+
"""Add a task by name to the agenda.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
function: The name of a registered task.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A callable that accepts the task arguments.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def add(
|
|
80
|
+
self,
|
|
81
|
+
function: Callable[P, Awaitable[R]] | str,
|
|
82
|
+
) -> Callable[..., None]:
|
|
83
|
+
"""Add a task to the agenda.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
function: The task function or name to add.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A callable that accepts the task arguments and adds them to the agenda.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def scheduler(*args: Any, **kwargs: Any) -> None:
|
|
93
|
+
self._tasks.append((function, args, kwargs))
|
|
94
|
+
|
|
95
|
+
return scheduler
|
|
96
|
+
|
|
97
|
+
def clear(self) -> None:
|
|
98
|
+
"""Clear all tasks from the agenda."""
|
|
99
|
+
self._tasks.clear()
|
|
100
|
+
|
|
101
|
+
async def scatter(
|
|
102
|
+
self,
|
|
103
|
+
docket: Docket,
|
|
104
|
+
over: timedelta,
|
|
105
|
+
start: datetime | None = None,
|
|
106
|
+
jitter: timedelta | None = None,
|
|
107
|
+
) -> list[Execution]:
|
|
108
|
+
"""Scatter the tasks in this agenda over a time period.
|
|
109
|
+
|
|
110
|
+
Tasks are distributed evenly across the specified time window,
|
|
111
|
+
optionally with random jitter to prevent thundering herd effects.
|
|
112
|
+
|
|
113
|
+
If an error occurs during scheduling, some tasks may have already been
|
|
114
|
+
scheduled successfully before the failure occurred.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
docket: The Docket to schedule tasks on.
|
|
118
|
+
over: Time period to scatter tasks over (required).
|
|
119
|
+
start: When to start scattering from. Defaults to now.
|
|
120
|
+
jitter: Maximum random offset to add/subtract from each scheduled time.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of Execution objects for the scheduled tasks.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
KeyError: If any task name is not registered with the docket.
|
|
127
|
+
ValueError: If any task is stricken or 'over' is not positive.
|
|
128
|
+
"""
|
|
129
|
+
if over.total_seconds() <= 0:
|
|
130
|
+
raise ValueError("'over' parameter must be a positive duration")
|
|
131
|
+
|
|
132
|
+
if not self._tasks:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
if start is None:
|
|
136
|
+
start = datetime.now(timezone.utc)
|
|
137
|
+
|
|
138
|
+
# Calculate even distribution over the time period
|
|
139
|
+
task_count = len(self._tasks)
|
|
140
|
+
|
|
141
|
+
if task_count == 1:
|
|
142
|
+
# Single task goes in the middle of the window
|
|
143
|
+
schedule_times = [start + over / 2]
|
|
144
|
+
else:
|
|
145
|
+
# Distribute tasks evenly across the window
|
|
146
|
+
# For n tasks, we want n points from start to start+over inclusive
|
|
147
|
+
interval = over / (task_count - 1)
|
|
148
|
+
schedule_times = [start + interval * i for i in range(task_count)]
|
|
149
|
+
|
|
150
|
+
# Apply jitter if specified
|
|
151
|
+
if jitter:
|
|
152
|
+
jittered_times: list[datetime] = []
|
|
153
|
+
for schedule_time in schedule_times:
|
|
154
|
+
# Random offset between -jitter and +jitter
|
|
155
|
+
offset = timedelta(
|
|
156
|
+
seconds=random.uniform(
|
|
157
|
+
-jitter.total_seconds(), jitter.total_seconds()
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
# Ensure the jittered time doesn't go before start
|
|
161
|
+
jittered_time = max(schedule_time + offset, start)
|
|
162
|
+
jittered_times.append(jittered_time)
|
|
163
|
+
schedule_times = jittered_times
|
|
164
|
+
|
|
165
|
+
# Build all Execution objects first, validating as we go
|
|
166
|
+
executions: list[Execution] = []
|
|
167
|
+
for (task_func, args, kwargs), schedule_time in zip(
|
|
168
|
+
self._tasks, schedule_times
|
|
169
|
+
):
|
|
170
|
+
# Resolve task function if given by name
|
|
171
|
+
if isinstance(task_func, str):
|
|
172
|
+
if task_func not in docket.tasks:
|
|
173
|
+
raise KeyError(f"Task '{task_func}' is not registered")
|
|
174
|
+
resolved_func = docket.tasks[task_func]
|
|
175
|
+
else:
|
|
176
|
+
# Ensure task is registered
|
|
177
|
+
if task_func not in docket.tasks.values():
|
|
178
|
+
docket.register(task_func)
|
|
179
|
+
resolved_func = task_func
|
|
180
|
+
|
|
181
|
+
# Create execution with unique key
|
|
182
|
+
key = str(uuid7())
|
|
183
|
+
execution = Execution(
|
|
184
|
+
docket=docket,
|
|
185
|
+
function=resolved_func,
|
|
186
|
+
args=args,
|
|
187
|
+
kwargs=kwargs,
|
|
188
|
+
key=key,
|
|
189
|
+
when=schedule_time,
|
|
190
|
+
attempt=1,
|
|
191
|
+
)
|
|
192
|
+
executions.append(execution)
|
|
193
|
+
|
|
194
|
+
# Schedule all tasks - if any fail, some tasks may have been scheduled
|
|
195
|
+
for execution in executions:
|
|
196
|
+
scheduler = docket.add(
|
|
197
|
+
execution.function, when=execution.when, key=execution.key
|
|
198
|
+
)
|
|
199
|
+
# Actually schedule the task - if this fails, earlier tasks remain scheduled
|
|
200
|
+
await scheduler(*execution.args, **execution.kwargs)
|
|
201
|
+
|
|
202
|
+
return executions
|
docket/annotations.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any, Iterable, Mapping
|
|
4
|
+
|
|
5
|
+
from typing_extensions import Self
|
|
6
|
+
|
|
7
|
+
from .instrumentation import CACHE_SIZE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Annotation(abc.ABC):
|
|
11
|
+
_cache: dict[tuple[type[Self], inspect.Signature], Mapping[str, Self]] = {}
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def annotated_parameters(cls, signature: inspect.Signature) -> Mapping[str, Self]:
|
|
15
|
+
key = (cls, signature)
|
|
16
|
+
if key in cls._cache:
|
|
17
|
+
CACHE_SIZE.set(len(cls._cache), {"cache": "annotation"})
|
|
18
|
+
return cls._cache[key]
|
|
19
|
+
|
|
20
|
+
annotated: dict[str, Self] = {}
|
|
21
|
+
|
|
22
|
+
for param_name, param in signature.parameters.items():
|
|
23
|
+
if param.annotation == inspect.Parameter.empty:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
metadata: Iterable[Any] = param.annotation.__metadata__
|
|
28
|
+
except AttributeError:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
for arg_type in metadata:
|
|
32
|
+
if isinstance(arg_type, cls):
|
|
33
|
+
annotated[param_name] = arg_type
|
|
34
|
+
elif isinstance(arg_type, type) and issubclass(arg_type, cls):
|
|
35
|
+
annotated[param_name] = arg_type()
|
|
36
|
+
|
|
37
|
+
cls._cache[key] = annotated
|
|
38
|
+
CACHE_SIZE.set(len(cls._cache), {"cache": "annotation"})
|
|
39
|
+
return annotated
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Logged(Annotation):
|
|
43
|
+
"""Instructs docket to include arguments to this parameter in the log.
|
|
44
|
+
|
|
45
|
+
If `length_only` is `True`, only the length of the argument will be included in
|
|
46
|
+
the log.
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
@task
|
|
52
|
+
def setup_new_customer(
|
|
53
|
+
customer_id: Annotated[int, Logged],
|
|
54
|
+
addresses: Annotated[list[Address], Logged(length_only=True)],
|
|
55
|
+
password: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
In the logs, you's see the task referenced as:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
setup_new_customer(customer_id=123, addresses[len 2], password=...)
|
|
64
|
+
```
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
length_only: bool = False
|
|
68
|
+
|
|
69
|
+
def __init__(self, length_only: bool = False) -> None:
|
|
70
|
+
self.length_only = length_only
|
|
71
|
+
|
|
72
|
+
def format(self, argument: Any) -> str:
|
|
73
|
+
if self.length_only:
|
|
74
|
+
if isinstance(argument, (dict, set)):
|
|
75
|
+
return f"{{len {len(argument)}}}"
|
|
76
|
+
elif isinstance(argument, tuple):
|
|
77
|
+
return f"(len {len(argument)})"
|
|
78
|
+
elif hasattr(argument, "__len__"):
|
|
79
|
+
return f"[len {len(argument)}]"
|
|
80
|
+
|
|
81
|
+
return repr(argument)
|