pydocket 0.6.2__tar.gz → 0.6.3__tar.gz
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 pydocket might be problematic. Click here for more details.
- {pydocket-0.6.2 → pydocket-0.6.3}/PKG-INFO +2 -1
- {pydocket-0.6.2 → pydocket-0.6.3}/examples/common.py +0 -2
- {pydocket-0.6.2 → pydocket-0.6.3}/examples/find_and_flood.py +3 -1
- pydocket-0.6.3/examples/self_perpetuating.py +36 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/pyproject.toml +1 -0
- pydocket-0.6.3/src/docket/__main__.py +3 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/dependencies.py +11 -4
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/docket.py +2 -2
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/execution.py +43 -4
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/worker.py +38 -34
- pydocket-0.6.3/tests/test_execution.py +63 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_fundamentals.py +88 -2
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_worker.py +6 -6
- {pydocket-0.6.2 → pydocket-0.6.3}/uv.lock +11 -0
- pydocket-0.6.2/src/docket/__main__.py +0 -3
- {pydocket-0.6.2 → pydocket-0.6.3}/.cursor/rules/general.mdc +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/.cursor/rules/python-style.mdc +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/.github/codecov.yml +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/.github/workflows/chaos.yml +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/.github/workflows/ci.yml +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/.github/workflows/publish.yml +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/.gitignore +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/.pre-commit-config.yaml +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/LICENSE +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/README.md +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/chaos/README.md +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/chaos/__init__.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/chaos/driver.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/chaos/producer.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/chaos/run +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/chaos/tasks.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/examples/__init__.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/__init__.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/annotations.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/cli.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/instrumentation.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/py.typed +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/tasks.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/telemetry/.gitignore +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/telemetry/start +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/telemetry/stop +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/__init__.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/__init__.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/conftest.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_module.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_parsing.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_snapshot.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_striking.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_tasks.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_version.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_worker.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_workers.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/conftest.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_dependencies.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_docket.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_instrumentation.py +0 -0
- {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_striking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydocket
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: A distributed background task system for Python functions
|
|
5
5
|
Project-URL: Homepage, https://github.com/chrisguidry/docket
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/chrisguidry/docket/issues
|
|
@@ -31,6 +31,7 @@ Requires-Dist: python-json-logger>=3.2.1
|
|
|
31
31
|
Requires-Dist: redis>=4.6
|
|
32
32
|
Requires-Dist: rich>=13.9.4
|
|
33
33
|
Requires-Dist: typer>=0.15.1
|
|
34
|
+
Requires-Dist: uuid7>=0.1.0
|
|
34
35
|
Description-Content-Type: text/markdown
|
|
35
36
|
|
|
36
37
|
Docket is a distributed background task system for Python functions with a focus
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import random
|
|
2
3
|
from datetime import timedelta
|
|
3
4
|
from logging import Logger, LoggerAdapter
|
|
4
5
|
from typing import Annotated
|
|
@@ -16,7 +17,7 @@ async def find(
|
|
|
16
17
|
perpetual: Perpetual = Perpetual(every=timedelta(seconds=3), automatic=True),
|
|
17
18
|
) -> None:
|
|
18
19
|
for i in range(1, 10 + 1):
|
|
19
|
-
await docket.add(flood
|
|
20
|
+
await docket.add(flood)(i)
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
async def flood(
|
|
@@ -24,6 +25,7 @@ async def flood(
|
|
|
24
25
|
logger: LoggerAdapter[Logger] = TaskLogger(),
|
|
25
26
|
) -> None:
|
|
26
27
|
logger.info("Working on %s", item)
|
|
28
|
+
await asyncio.sleep(random.uniform(0.5, 2))
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
tasks = [find, flood]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from logging import Logger, LoggerAdapter
|
|
5
|
+
|
|
6
|
+
from docket import Docket
|
|
7
|
+
from docket.dependencies import CurrentDocket, Perpetual, TaskLogger
|
|
8
|
+
|
|
9
|
+
from .common import run_example_workers
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def now() -> datetime:
|
|
13
|
+
return datetime.now(timezone.utc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def bouncey(
|
|
17
|
+
docket: Docket = CurrentDocket(),
|
|
18
|
+
logger: LoggerAdapter[Logger] = TaskLogger(),
|
|
19
|
+
perpetual: Perpetual = Perpetual(every=timedelta(seconds=3), automatic=True),
|
|
20
|
+
) -> None:
|
|
21
|
+
seconds = random.randint(1, 10)
|
|
22
|
+
perpetual.every = timedelta(seconds=seconds)
|
|
23
|
+
logger.info("See you in %s seconds at %s", seconds, now() + perpetual.every)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
tasks = [bouncey]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
asyncio.run(
|
|
31
|
+
run_example_workers(
|
|
32
|
+
workers=3,
|
|
33
|
+
concurrency=8,
|
|
34
|
+
tasks="examples.self_perpetuating:tasks",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
@@ -81,18 +81,25 @@ def TaskKey() -> str:
|
|
|
81
81
|
|
|
82
82
|
class _TaskArgument(Dependency):
|
|
83
83
|
parameter: str | None
|
|
84
|
+
optional: bool
|
|
84
85
|
|
|
85
|
-
def __init__(self, parameter: str | None = None) -> None:
|
|
86
|
+
def __init__(self, parameter: str | None = None, optional: bool = False) -> None:
|
|
86
87
|
self.parameter = parameter
|
|
88
|
+
self.optional = optional
|
|
87
89
|
|
|
88
90
|
async def __aenter__(self) -> Any:
|
|
89
91
|
assert self.parameter is not None
|
|
90
92
|
execution = self.execution.get()
|
|
91
|
-
|
|
93
|
+
try:
|
|
94
|
+
return execution.get_argument(self.parameter)
|
|
95
|
+
except KeyError:
|
|
96
|
+
if self.optional:
|
|
97
|
+
return None
|
|
98
|
+
raise
|
|
92
99
|
|
|
93
100
|
|
|
94
|
-
def TaskArgument(parameter: str | None = None) -> Any:
|
|
95
|
-
return cast(Any, _TaskArgument(parameter))
|
|
101
|
+
def TaskArgument(parameter: str | None = None, optional: bool = False) -> Any:
|
|
102
|
+
return cast(Any, _TaskArgument(parameter, optional))
|
|
96
103
|
|
|
97
104
|
|
|
98
105
|
class _TaskLogger(Dependency):
|
|
@@ -23,12 +23,12 @@ from typing import (
|
|
|
23
23
|
cast,
|
|
24
24
|
overload,
|
|
25
25
|
)
|
|
26
|
-
from uuid import uuid4
|
|
27
26
|
|
|
28
27
|
import redis.exceptions
|
|
29
28
|
from opentelemetry import propagate, trace
|
|
30
29
|
from redis.asyncio import ConnectionPool, Redis
|
|
31
30
|
from redis.asyncio.client import Pipeline
|
|
31
|
+
from uuid_extensions import uuid7
|
|
32
32
|
|
|
33
33
|
from .execution import (
|
|
34
34
|
Execution,
|
|
@@ -254,7 +254,7 @@ class Docket:
|
|
|
254
254
|
when = datetime.now(timezone.utc)
|
|
255
255
|
|
|
256
256
|
if key is None:
|
|
257
|
-
key =
|
|
257
|
+
key = str(uuid7())
|
|
258
258
|
|
|
259
259
|
async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
|
|
260
260
|
execution = Execution(function, args, kwargs, when, key, attempt=1)
|
|
@@ -3,15 +3,23 @@ import enum
|
|
|
3
3
|
import inspect
|
|
4
4
|
import logging
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
Awaitable,
|
|
9
|
+
Callable,
|
|
10
|
+
Hashable,
|
|
11
|
+
Literal,
|
|
12
|
+
Mapping,
|
|
13
|
+
Self,
|
|
14
|
+
cast,
|
|
15
|
+
)
|
|
7
16
|
|
|
8
17
|
import cloudpickle # type: ignore[import]
|
|
9
|
-
|
|
10
|
-
from opentelemetry import trace, propagate
|
|
11
18
|
import opentelemetry.context
|
|
19
|
+
from opentelemetry import propagate, trace
|
|
12
20
|
|
|
13
21
|
from .annotations import Logged
|
|
14
|
-
from
|
|
22
|
+
from .instrumentation import message_getter
|
|
15
23
|
|
|
16
24
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
17
25
|
|
|
@@ -117,6 +125,37 @@ class Execution:
|
|
|
117
125
|
return [trace.Link(initiating_context)] if initiating_context.is_valid else []
|
|
118
126
|
|
|
119
127
|
|
|
128
|
+
def compact_signature(signature: inspect.Signature) -> str:
|
|
129
|
+
from .dependencies import Dependency
|
|
130
|
+
|
|
131
|
+
parameters: list[str] = []
|
|
132
|
+
dependencies: int = 0
|
|
133
|
+
|
|
134
|
+
for parameter in signature.parameters.values():
|
|
135
|
+
if isinstance(parameter.default, Dependency):
|
|
136
|
+
dependencies += 1
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
parameter_definition = parameter.name
|
|
140
|
+
if parameter.annotation is not parameter.empty:
|
|
141
|
+
annotation = parameter.annotation
|
|
142
|
+
if hasattr(annotation, "__origin__"):
|
|
143
|
+
annotation = annotation.__args__[0]
|
|
144
|
+
|
|
145
|
+
type_name = getattr(annotation, "__name__", str(annotation))
|
|
146
|
+
parameter_definition = f"{parameter.name}: {type_name}"
|
|
147
|
+
|
|
148
|
+
if parameter.default is not parameter.empty:
|
|
149
|
+
parameter_definition = f"{parameter_definition} = {parameter.default!r}"
|
|
150
|
+
|
|
151
|
+
parameters.append(parameter_definition)
|
|
152
|
+
|
|
153
|
+
if dependencies > 0:
|
|
154
|
+
parameters.append("...")
|
|
155
|
+
|
|
156
|
+
return ", ".join(parameters)
|
|
157
|
+
|
|
158
|
+
|
|
120
159
|
class Operator(enum.StrEnum):
|
|
121
160
|
EQUAL = "=="
|
|
122
161
|
NOT_EQUAL = "!="
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
3
5
|
import sys
|
|
4
6
|
import time
|
|
5
7
|
from datetime import datetime, timedelta, timezone
|
|
@@ -11,15 +13,12 @@ from typing import (
|
|
|
11
13
|
Self,
|
|
12
14
|
cast,
|
|
13
15
|
)
|
|
14
|
-
from uuid import uuid4
|
|
15
16
|
|
|
16
17
|
from opentelemetry import trace
|
|
17
18
|
from opentelemetry.trace import Tracer
|
|
18
19
|
from redis.asyncio import Redis
|
|
19
20
|
from redis.exceptions import ConnectionError, LockError
|
|
20
21
|
|
|
21
|
-
from docket.execution import get_signature
|
|
22
|
-
|
|
23
22
|
from .dependencies import (
|
|
24
23
|
Dependency,
|
|
25
24
|
FailedDependency,
|
|
@@ -37,6 +36,7 @@ from .docket import (
|
|
|
37
36
|
RedisMessageID,
|
|
38
37
|
RedisReadGroupResponse,
|
|
39
38
|
)
|
|
39
|
+
from .execution import compact_signature, get_signature
|
|
40
40
|
from .instrumentation import (
|
|
41
41
|
QUEUE_DEPTH,
|
|
42
42
|
REDIS_DISRUPTIONS,
|
|
@@ -86,7 +86,7 @@ class Worker:
|
|
|
86
86
|
schedule_automatic_tasks: bool = True,
|
|
87
87
|
) -> None:
|
|
88
88
|
self.docket = docket
|
|
89
|
-
self.name = name or f"
|
|
89
|
+
self.name = name or f"{socket.gethostname()}#{os.getpid()}"
|
|
90
90
|
self.concurrency = concurrency
|
|
91
91
|
self.redelivery_timeout = redelivery_timeout
|
|
92
92
|
self.reconnection_delay = reconnection_delay
|
|
@@ -205,10 +205,7 @@ class Worker:
|
|
|
205
205
|
self._execution_counts = {}
|
|
206
206
|
|
|
207
207
|
async def _run(self, forever: bool = False) -> None:
|
|
208
|
-
|
|
209
|
-
for task_name, task in self.docket.tasks.items():
|
|
210
|
-
signature = get_signature(task)
|
|
211
|
-
logger.info("* %s%s", task_name, signature)
|
|
208
|
+
self._startup_log()
|
|
212
209
|
|
|
213
210
|
while True:
|
|
214
211
|
try:
|
|
@@ -506,6 +503,8 @@ class Worker:
|
|
|
506
503
|
arrow = "↬" if execution.attempt > 1 else "↪"
|
|
507
504
|
logger.info("%s [%s] %s", arrow, ms(punctuality), call, extra=log_context)
|
|
508
505
|
|
|
506
|
+
dependencies: dict[str, Dependency] = {}
|
|
507
|
+
|
|
509
508
|
with tracer.start_as_current_span(
|
|
510
509
|
execution.function.__name__,
|
|
511
510
|
kind=trace.SpanKind.CONSUMER,
|
|
@@ -516,17 +515,17 @@ class Worker:
|
|
|
516
515
|
},
|
|
517
516
|
links=execution.incoming_span_links(),
|
|
518
517
|
):
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
518
|
+
try:
|
|
519
|
+
async with resolved_dependencies(self, execution) as dependencies:
|
|
520
|
+
# Preemptively reschedule the perpetual task for the future, or clear
|
|
521
|
+
# the known task key for this task
|
|
522
|
+
rescheduled = await self._perpetuate_if_requested(
|
|
523
|
+
execution, dependencies
|
|
524
|
+
)
|
|
525
|
+
if not rescheduled:
|
|
526
|
+
async with self.docket.redis() as redis:
|
|
527
|
+
await self._delete_known_task(redis, execution)
|
|
528
528
|
|
|
529
|
-
try:
|
|
530
529
|
dependency_failures = {
|
|
531
530
|
k: v
|
|
532
531
|
for k, v in dependencies.items()
|
|
@@ -568,24 +567,24 @@ class Worker:
|
|
|
568
567
|
logger.info(
|
|
569
568
|
"%s [%s] %s", arrow, ms(duration), call, extra=log_context
|
|
570
569
|
)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
retried = await self._retry_if_requested(execution, dependencies)
|
|
576
|
-
if not retried:
|
|
577
|
-
retried = await self._perpetuate_if_requested(
|
|
578
|
-
execution, dependencies, timedelta(seconds=duration)
|
|
579
|
-
)
|
|
570
|
+
except Exception:
|
|
571
|
+
duration = log_context["duration"] = time.time() - start
|
|
572
|
+
TASKS_FAILED.add(1, counter_labels)
|
|
580
573
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
574
|
+
retried = await self._retry_if_requested(execution, dependencies)
|
|
575
|
+
if not retried:
|
|
576
|
+
retried = await self._perpetuate_if_requested(
|
|
577
|
+
execution, dependencies, timedelta(seconds=duration)
|
|
584
578
|
)
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
579
|
+
|
|
580
|
+
arrow = "↫" if retried else "↩"
|
|
581
|
+
logger.exception(
|
|
582
|
+
"%s [%s] %s", arrow, ms(duration), call, extra=log_context
|
|
583
|
+
)
|
|
584
|
+
finally:
|
|
585
|
+
TASKS_RUNNING.add(-1, counter_labels)
|
|
586
|
+
TASKS_COMPLETED.add(1, counter_labels)
|
|
587
|
+
TASK_DURATION.record(duration, counter_labels)
|
|
589
588
|
|
|
590
589
|
async def _run_function_with_timeout(
|
|
591
590
|
self,
|
|
@@ -665,6 +664,11 @@ class Worker:
|
|
|
665
664
|
|
|
666
665
|
return True
|
|
667
666
|
|
|
667
|
+
def _startup_log(self) -> None:
|
|
668
|
+
logger.info("Starting worker %r with the following tasks:", self.name)
|
|
669
|
+
for task_name, task in self.docket.tasks.items():
|
|
670
|
+
logger.info("* %s(%s)", task_name, compact_signature(get_signature(task)))
|
|
671
|
+
|
|
668
672
|
@property
|
|
669
673
|
def workers_set(self) -> str:
|
|
670
674
|
return self.docket.workers_set
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from docket import Docket, Worker
|
|
6
|
+
from docket.annotations import Logged
|
|
7
|
+
from docket.dependencies import CurrentDocket, CurrentWorker, Depends
|
|
8
|
+
from docket.execution import TaskFunction, compact_signature, get_signature
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def no_args() -> None: ... # pragma: no cover
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def one_arg(a: str) -> None: ... # pragma: no cover
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def two_args(a: str, b: str) -> None: ... # pragma: no cover
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def optional_args(a: str, b: str, c: str = "c") -> None: ... # pragma: no cover
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def logged_args(
|
|
24
|
+
a: Annotated[str, Logged()],
|
|
25
|
+
b: Annotated[str, Logged()] = "foo",
|
|
26
|
+
) -> None: ... # pragma: no cover
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def a_dependency() -> str: ... # pragma: no cover
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def dependencies(
|
|
33
|
+
a: str,
|
|
34
|
+
b: int = 42,
|
|
35
|
+
c: str = Depends(a_dependency),
|
|
36
|
+
docket: Docket = CurrentDocket(),
|
|
37
|
+
worker: Worker = CurrentWorker(),
|
|
38
|
+
) -> None: ... # pragma: no cover
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def only_dependencies(
|
|
42
|
+
a: str = Depends(a_dependency),
|
|
43
|
+
docket: Docket = CurrentDocket(),
|
|
44
|
+
worker: Worker = CurrentWorker(),
|
|
45
|
+
) -> None: ... # pragma: no cover
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"function, expected",
|
|
50
|
+
[
|
|
51
|
+
(no_args, ""),
|
|
52
|
+
(one_arg, "a: str"),
|
|
53
|
+
(two_args, "a: str, b: str"),
|
|
54
|
+
(optional_args, "a: str, b: str, c: str = 'c'"),
|
|
55
|
+
(logged_args, "a: str, b: str = 'foo'"),
|
|
56
|
+
(dependencies, "a: str, b: int = 42, ..."),
|
|
57
|
+
(only_dependencies, "..."),
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
async def test_compact_signature(
|
|
61
|
+
docket: Docket, worker: Worker, function: TaskFunction, expected: str
|
|
62
|
+
):
|
|
63
|
+
assert compact_signature(get_signature(function)) == expected
|
|
@@ -151,10 +151,10 @@ async def test_rescheduling_by_name(
|
|
|
151
151
|
|
|
152
152
|
key = f"my-cool-task:{uuid4()}"
|
|
153
153
|
|
|
154
|
-
soon = now() + timedelta(milliseconds=
|
|
154
|
+
soon = now() + timedelta(milliseconds=100)
|
|
155
155
|
await docket.add(the_task, soon, key=key)("a", "b", c="c")
|
|
156
156
|
|
|
157
|
-
later = now() + timedelta(milliseconds=
|
|
157
|
+
later = now() + timedelta(milliseconds=200)
|
|
158
158
|
await docket.replace("the_task", later, key=key)("b", "c", c="d")
|
|
159
159
|
|
|
160
160
|
await worker.run_until_finished()
|
|
@@ -1418,6 +1418,65 @@ async def test_dependency_failures_are_task_failures(
|
|
|
1418
1418
|
assert "ValueError: and so is this one" in caplog.text
|
|
1419
1419
|
|
|
1420
1420
|
|
|
1421
|
+
async def test_contextual_dependency_before_failures_are_task_failures(
|
|
1422
|
+
docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
|
|
1423
|
+
):
|
|
1424
|
+
"""A contextual task dependency failure will cause the task to fail"""
|
|
1425
|
+
|
|
1426
|
+
called: int = 0
|
|
1427
|
+
|
|
1428
|
+
@asynccontextmanager
|
|
1429
|
+
async def dependency_before() -> AsyncGenerator[str, None]:
|
|
1430
|
+
raise ValueError("this one is bad")
|
|
1431
|
+
yield "this won't be used" # pragma: no cover
|
|
1432
|
+
|
|
1433
|
+
async def dependent_task(
|
|
1434
|
+
a: str = Depends(dependency_before),
|
|
1435
|
+
) -> None:
|
|
1436
|
+
nonlocal called
|
|
1437
|
+
called += 1 # pragma: no cover
|
|
1438
|
+
|
|
1439
|
+
await docket.add(dependent_task)()
|
|
1440
|
+
|
|
1441
|
+
with caplog.at_level(logging.ERROR):
|
|
1442
|
+
await worker.run_until_finished()
|
|
1443
|
+
|
|
1444
|
+
assert not called
|
|
1445
|
+
|
|
1446
|
+
assert "Failed to resolve dependencies for parameter(s): a" in caplog.text
|
|
1447
|
+
assert "ValueError: this one is bad" in caplog.text
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
async def test_contextual_dependency_after_failures_are_task_failures(
|
|
1451
|
+
docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
|
|
1452
|
+
):
|
|
1453
|
+
"""A contextual task dependency failure will cause the task to fail"""
|
|
1454
|
+
|
|
1455
|
+
called: int = 0
|
|
1456
|
+
|
|
1457
|
+
@asynccontextmanager
|
|
1458
|
+
async def dependency_after() -> AsyncGenerator[str, None]:
|
|
1459
|
+
yield "this will be used"
|
|
1460
|
+
raise ValueError("this one is bad")
|
|
1461
|
+
|
|
1462
|
+
async def dependent_task(
|
|
1463
|
+
a: str = Depends(dependency_after),
|
|
1464
|
+
) -> None:
|
|
1465
|
+
assert a == "this will be used"
|
|
1466
|
+
|
|
1467
|
+
nonlocal called
|
|
1468
|
+
called += 1
|
|
1469
|
+
|
|
1470
|
+
await docket.add(dependent_task)()
|
|
1471
|
+
|
|
1472
|
+
with caplog.at_level(logging.ERROR):
|
|
1473
|
+
await worker.run_until_finished()
|
|
1474
|
+
|
|
1475
|
+
assert called == 1
|
|
1476
|
+
|
|
1477
|
+
assert "ValueError: this one is bad" in caplog.text
|
|
1478
|
+
|
|
1479
|
+
|
|
1421
1480
|
async def test_dependencies_can_ask_for_task_arguments(docket: Docket, worker: Worker):
|
|
1422
1481
|
"""A task dependency can ask for a task argument"""
|
|
1423
1482
|
|
|
@@ -1447,3 +1506,30 @@ async def test_dependencies_can_ask_for_task_arguments(docket: Docket, worker: W
|
|
|
1447
1506
|
await worker.run_until_finished()
|
|
1448
1507
|
|
|
1449
1508
|
assert called == 1
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
async def test_task_arguments_may_be_optional(docket: Docket, worker: Worker):
|
|
1512
|
+
"""A task dependency can ask for a task argument optionally"""
|
|
1513
|
+
|
|
1514
|
+
called = 0
|
|
1515
|
+
|
|
1516
|
+
async def dependency_one(
|
|
1517
|
+
a: list[str] | None = TaskArgument(optional=True),
|
|
1518
|
+
) -> list[str] | None:
|
|
1519
|
+
return a
|
|
1520
|
+
|
|
1521
|
+
async def dependent_task(
|
|
1522
|
+
not_a: list[str],
|
|
1523
|
+
b: list[str] | None = Depends(dependency_one),
|
|
1524
|
+
) -> None:
|
|
1525
|
+
assert not_a == ["hello", "world"]
|
|
1526
|
+
assert b is None
|
|
1527
|
+
|
|
1528
|
+
nonlocal called
|
|
1529
|
+
called += 1
|
|
1530
|
+
|
|
1531
|
+
await docket.add(dependent_task)(not_a=["hello", "world"])
|
|
1532
|
+
|
|
1533
|
+
await worker.run_until_finished()
|
|
1534
|
+
|
|
1535
|
+
assert called == 1
|
|
@@ -273,14 +273,14 @@ async def test_worker_announcements(
|
|
|
273
273
|
docket.register(the_task)
|
|
274
274
|
docket.register(another_task)
|
|
275
275
|
|
|
276
|
-
async with Worker(docket) as worker_a:
|
|
276
|
+
async with Worker(docket, name="worker-a") as worker_a:
|
|
277
277
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
278
278
|
|
|
279
279
|
workers = await docket.workers()
|
|
280
280
|
assert len(workers) == 1
|
|
281
281
|
assert worker_a.name in {w.name for w in workers}
|
|
282
282
|
|
|
283
|
-
async with Worker(docket) as worker_b:
|
|
283
|
+
async with Worker(docket, name="worker-b") as worker_b:
|
|
284
284
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
285
285
|
|
|
286
286
|
workers = await docket.workers()
|
|
@@ -315,14 +315,14 @@ async def test_task_announcements(
|
|
|
315
315
|
|
|
316
316
|
docket.register(the_task)
|
|
317
317
|
docket.register(another_task)
|
|
318
|
-
async with Worker(docket) as worker_a:
|
|
318
|
+
async with Worker(docket, name="worker-a") as worker_a:
|
|
319
319
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
320
320
|
|
|
321
321
|
workers = await docket.task_workers("the_task")
|
|
322
322
|
assert len(workers) == 1
|
|
323
323
|
assert worker_a.name in {w.name for w in workers}
|
|
324
324
|
|
|
325
|
-
async with Worker(docket) as worker_b:
|
|
325
|
+
async with Worker(docket, name="worker-b") as worker_b:
|
|
326
326
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
327
327
|
|
|
328
328
|
workers = await docket.task_workers("the_task")
|
|
@@ -422,13 +422,13 @@ async def test_perpetual_tasks_are_scheduled_close_to_target_time(
|
|
|
422
422
|
assert len(timestamps) == 8
|
|
423
423
|
|
|
424
424
|
intervals = [next - previous for previous, next in zip(timestamps, timestamps[1:])]
|
|
425
|
-
|
|
425
|
+
average = sum(intervals, timedelta(0)) / len(intervals)
|
|
426
426
|
|
|
427
427
|
debug = ", ".join([f"{i.total_seconds() * 1000:.2f}ms" for i in intervals])
|
|
428
428
|
|
|
429
429
|
# It's not reliable to assert the maximum duration on different machine setups, but
|
|
430
430
|
# we'll make sure that the minimum is observed (within 5ms), which is the guarantee
|
|
431
|
-
assert
|
|
431
|
+
assert average >= timedelta(milliseconds=50), debug
|
|
432
432
|
|
|
433
433
|
|
|
434
434
|
async def test_worker_can_exit_from_perpetual_tasks_that_queue_further_tasks(
|
|
@@ -723,6 +723,7 @@ dependencies = [
|
|
|
723
723
|
{ name = "redis" },
|
|
724
724
|
{ name = "rich" },
|
|
725
725
|
{ name = "typer" },
|
|
726
|
+
{ name = "uuid7" },
|
|
726
727
|
]
|
|
727
728
|
|
|
728
729
|
[package.dev-dependencies]
|
|
@@ -756,6 +757,7 @@ requires-dist = [
|
|
|
756
757
|
{ name = "redis", specifier = ">=4.6" },
|
|
757
758
|
{ name = "rich", specifier = ">=13.9.4" },
|
|
758
759
|
{ name = "typer", specifier = ">=0.15.1" },
|
|
760
|
+
{ name = "uuid7", specifier = ">=0.1.0" },
|
|
759
761
|
]
|
|
760
762
|
|
|
761
763
|
[package.metadata.requires-dev]
|
|
@@ -1029,6 +1031,15 @@ wheels = [
|
|
|
1029
1031
|
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
|
|
1030
1032
|
]
|
|
1031
1033
|
|
|
1034
|
+
[[package]]
|
|
1035
|
+
name = "uuid7"
|
|
1036
|
+
version = "0.1.0"
|
|
1037
|
+
source = { registry = "https://pypi.org/simple" }
|
|
1038
|
+
sdist = { url = "https://files.pythonhosted.org/packages/5c/19/7472bd526591e2192926247109dbf78692e709d3e56775792fec877a7720/uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c", size = 14052 }
|
|
1039
|
+
wheels = [
|
|
1040
|
+
{ url = "https://files.pythonhosted.org/packages/b5/77/8852f89a91453956582a85024d80ad96f30a41fed4c2b3dce0c9f12ecc7e/uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61", size = 7477 },
|
|
1041
|
+
]
|
|
1042
|
+
|
|
1032
1043
|
[[package]]
|
|
1033
1044
|
name = "virtualenv"
|
|
1034
1045
|
version = "20.29.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|