pydocket 0.6.1__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.

Files changed (57) hide show
  1. {pydocket-0.6.1 → pydocket-0.6.3}/PKG-INFO +2 -1
  2. {pydocket-0.6.1 → pydocket-0.6.3}/examples/common.py +0 -2
  3. {pydocket-0.6.1 → pydocket-0.6.3}/examples/find_and_flood.py +3 -1
  4. pydocket-0.6.3/examples/self_perpetuating.py +36 -0
  5. {pydocket-0.6.1 → pydocket-0.6.3}/pyproject.toml +4 -0
  6. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/__init__.py +11 -9
  7. pydocket-0.6.3/src/docket/__main__.py +3 -0
  8. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/cli.py +8 -0
  9. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/dependencies.py +48 -1
  10. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/docket.py +2 -2
  11. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/execution.py +48 -4
  12. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/worker.py +63 -35
  13. {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_dependencies.py +48 -1
  14. pydocket-0.6.3/tests/test_execution.py +63 -0
  15. {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_fundamentals.py +152 -2
  16. {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_worker.py +28 -6
  17. {pydocket-0.6.1 → pydocket-0.6.3}/uv.lock +11 -0
  18. pydocket-0.6.1/src/docket/__main__.py +0 -3
  19. {pydocket-0.6.1 → pydocket-0.6.3}/.cursor/rules/general.mdc +0 -0
  20. {pydocket-0.6.1 → pydocket-0.6.3}/.cursor/rules/python-style.mdc +0 -0
  21. {pydocket-0.6.1 → pydocket-0.6.3}/.github/codecov.yml +0 -0
  22. {pydocket-0.6.1 → pydocket-0.6.3}/.github/workflows/chaos.yml +0 -0
  23. {pydocket-0.6.1 → pydocket-0.6.3}/.github/workflows/ci.yml +0 -0
  24. {pydocket-0.6.1 → pydocket-0.6.3}/.github/workflows/publish.yml +0 -0
  25. {pydocket-0.6.1 → pydocket-0.6.3}/.gitignore +0 -0
  26. {pydocket-0.6.1 → pydocket-0.6.3}/.pre-commit-config.yaml +0 -0
  27. {pydocket-0.6.1 → pydocket-0.6.3}/LICENSE +0 -0
  28. {pydocket-0.6.1 → pydocket-0.6.3}/README.md +0 -0
  29. {pydocket-0.6.1 → pydocket-0.6.3}/chaos/README.md +0 -0
  30. {pydocket-0.6.1 → pydocket-0.6.3}/chaos/__init__.py +0 -0
  31. {pydocket-0.6.1 → pydocket-0.6.3}/chaos/driver.py +0 -0
  32. {pydocket-0.6.1 → pydocket-0.6.3}/chaos/producer.py +0 -0
  33. {pydocket-0.6.1 → pydocket-0.6.3}/chaos/run +0 -0
  34. {pydocket-0.6.1 → pydocket-0.6.3}/chaos/tasks.py +0 -0
  35. {pydocket-0.6.1 → pydocket-0.6.3}/examples/__init__.py +0 -0
  36. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/annotations.py +0 -0
  37. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/instrumentation.py +0 -0
  38. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/py.typed +0 -0
  39. {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/tasks.py +0 -0
  40. {pydocket-0.6.1 → pydocket-0.6.3}/telemetry/.gitignore +0 -0
  41. {pydocket-0.6.1 → pydocket-0.6.3}/telemetry/start +0 -0
  42. {pydocket-0.6.1 → pydocket-0.6.3}/telemetry/stop +0 -0
  43. {pydocket-0.6.1 → pydocket-0.6.3}/tests/__init__.py +0 -0
  44. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/__init__.py +0 -0
  45. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/conftest.py +0 -0
  46. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_module.py +0 -0
  47. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_parsing.py +0 -0
  48. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_snapshot.py +0 -0
  49. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_striking.py +0 -0
  50. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_tasks.py +0 -0
  51. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_version.py +0 -0
  52. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_worker.py +0 -0
  53. {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_workers.py +0 -0
  54. {pydocket-0.6.1 → pydocket-0.6.3}/tests/conftest.py +0 -0
  55. {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_docket.py +0 -0
  56. {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_instrumentation.py +0 -0
  57. {pydocket-0.6.1 → 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.1
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
@@ -43,8 +43,6 @@ async def run_example_workers(workers: int, concurrency: int, tasks: str):
43
43
  await asyncio.create_subprocess_exec(
44
44
  "docket",
45
45
  "worker",
46
- "--name",
47
- f"worker-{i}",
48
46
  "--url",
49
47
  redis_url,
50
48
  "--tasks",
@@ -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, key=f"item-{i}")(i)
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
+ )
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "redis>=4.6",
29
29
  "rich>=13.9.4",
30
30
  "typer>=0.15.1",
31
+ "uuid7>=0.1.0",
31
32
  ]
32
33
 
33
34
  [dependency-groups]
@@ -64,6 +65,9 @@ source = "vcs"
64
65
  [tool.hatch.build.targets.wheel]
65
66
  packages = ["src/docket"]
66
67
 
68
+ [tool.ruff]
69
+ target-version = "py312"
70
+
67
71
  [tool.pytest.ini_options]
68
72
  addopts = [
69
73
  "--numprocesses=logical",
@@ -17,6 +17,7 @@ from .dependencies import (
17
17
  ExponentialRetry,
18
18
  Perpetual,
19
19
  Retry,
20
+ TaskArgument,
20
21
  TaskKey,
21
22
  TaskLogger,
22
23
  Timeout,
@@ -26,19 +27,20 @@ from .execution import Execution
26
27
  from .worker import Worker
27
28
 
28
29
  __all__ = [
29
- "Docket",
30
- "Worker",
31
- "Execution",
30
+ "__version__",
32
31
  "CurrentDocket",
33
- "CurrentWorker",
34
32
  "CurrentExecution",
35
- "TaskKey",
36
- "TaskLogger",
37
- "Retry",
33
+ "CurrentWorker",
34
+ "Depends",
35
+ "Docket",
36
+ "Execution",
38
37
  "ExponentialRetry",
39
38
  "Logged",
40
39
  "Perpetual",
40
+ "Retry",
41
+ "TaskArgument",
42
+ "TaskKey",
43
+ "TaskLogger",
41
44
  "Timeout",
42
- "Depends",
43
- "__version__",
45
+ "Worker",
44
46
  ]
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -245,6 +245,13 @@ def worker(
245
245
  envvar="DOCKET_WORKER_SCHEDULING_RESOLUTION",
246
246
  ),
247
247
  ] = timedelta(milliseconds=250),
248
+ schedule_automatic_tasks: Annotated[
249
+ bool,
250
+ typer.Option(
251
+ "--schedule-automatic-tasks",
252
+ help="Schedule automatic tasks",
253
+ ),
254
+ ] = True,
248
255
  until_finished: Annotated[
249
256
  bool,
250
257
  typer.Option(
@@ -270,6 +277,7 @@ def worker(
270
277
  reconnection_delay=reconnection_delay,
271
278
  minimum_check_interval=minimum_check_interval,
272
279
  scheduling_resolution=scheduling_resolution,
280
+ schedule_automatic_tasks=schedule_automatic_tasks,
273
281
  until_finished=until_finished,
274
282
  metrics_port=metrics_port,
275
283
  tasks=tasks,
@@ -79,6 +79,29 @@ def TaskKey() -> str:
79
79
  return cast(str, _TaskKey())
80
80
 
81
81
 
82
+ class _TaskArgument(Dependency):
83
+ parameter: str | None
84
+ optional: bool
85
+
86
+ def __init__(self, parameter: str | None = None, optional: bool = False) -> None:
87
+ self.parameter = parameter
88
+ self.optional = optional
89
+
90
+ async def __aenter__(self) -> Any:
91
+ assert self.parameter is not None
92
+ execution = self.execution.get()
93
+ try:
94
+ return execution.get_argument(self.parameter)
95
+ except KeyError:
96
+ if self.optional:
97
+ return None
98
+ raise
99
+
100
+
101
+ def TaskArgument(parameter: str | None = None, optional: bool = False) -> Any:
102
+ return cast(Any, _TaskArgument(parameter, optional))
103
+
104
+
82
105
  class _TaskLogger(Dependency):
83
106
  async def __aenter__(self) -> logging.LoggerAdapter[logging.Logger]:
84
107
  execution = self.execution.get()
@@ -275,6 +298,11 @@ class _Depends(Dependency, Generic[R]):
275
298
  parameters = get_dependency_parameters(function)
276
299
 
277
300
  for parameter, dependency in parameters.items():
301
+ # Special case for TaskArguments, they are "magical" and infer the parameter
302
+ # they refer to from the parameter name (unless otherwise specified)
303
+ if isinstance(dependency, _TaskArgument) and not dependency.parameter:
304
+ dependency.parameter = parameter
305
+
278
306
  arguments[parameter] = await stack.enter_async_context(dependency)
279
307
 
280
308
  return arguments
@@ -338,6 +366,12 @@ def validate_dependencies(function: TaskFunction) -> None:
338
366
  )
339
367
 
340
368
 
369
+ class FailedDependency:
370
+ def __init__(self, parameter: str, error: Exception) -> None:
371
+ self.parameter = parameter
372
+ self.error = error
373
+
374
+
341
375
  @asynccontextmanager
342
376
  async def resolved_dependencies(
343
377
  worker: "Worker", execution: Execution
@@ -361,6 +395,19 @@ async def resolved_dependencies(
361
395
  arguments[parameter] = kwargs[parameter]
362
396
  continue
363
397
 
364
- arguments[parameter] = await stack.enter_async_context(dependency)
398
+ # Special case for TaskArguments, they are "magical" and infer the parameter
399
+ # they refer to from the parameter name (unless otherwise specified). At
400
+ # the top-level task function call, it doesn't make sense to specify one
401
+ # _without_ a parameter name, so we'll call that a failed dependency.
402
+ if isinstance(dependency, _TaskArgument) and not dependency.parameter:
403
+ arguments[parameter] = FailedDependency(
404
+ parameter, ValueError("No parameter name specified")
405
+ )
406
+ continue
407
+
408
+ try:
409
+ arguments[parameter] = await stack.enter_async_context(dependency)
410
+ except Exception as error:
411
+ arguments[parameter] = FailedDependency(parameter, error)
365
412
 
366
413
  yield arguments
@@ -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 = f"{function.__name__}:{uuid4()}"
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 Any, Awaitable, Callable, Hashable, Literal, Mapping, Self, cast
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 docket.instrumentation import message_getter
22
+ from .instrumentation import message_getter
15
23
 
16
24
  logger: logging.Logger = logging.getLogger(__name__)
17
25
 
@@ -83,6 +91,11 @@ class Execution:
83
91
  "docket.attempt": self.attempt,
84
92
  }
85
93
 
94
+ def get_argument(self, parameter: str) -> Any:
95
+ signature = get_signature(self.function)
96
+ bound_args = signature.bind(*self.args, **self.kwargs)
97
+ return bound_args.arguments[parameter]
98
+
86
99
  def call_repr(self) -> str:
87
100
  arguments: list[str] = []
88
101
  function_name = self.function.__name__
@@ -112,6 +125,37 @@ class Execution:
112
125
  return [trace.Link(initiating_context)] if initiating_context.is_valid else []
113
126
 
114
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
+
115
159
  class Operator(enum.StrEnum):
116
160
  EQUAL = "=="
117
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,17 +13,15 @@ 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,
24
+ FailedDependency,
25
25
  Perpetual,
26
26
  Retry,
27
27
  Timeout,
@@ -36,6 +36,7 @@ from .docket import (
36
36
  RedisMessageID,
37
37
  RedisReadGroupResponse,
38
38
  )
39
+ from .execution import compact_signature, get_signature
39
40
  from .instrumentation import (
40
41
  QUEUE_DEPTH,
41
42
  REDIS_DISRUPTIONS,
@@ -71,6 +72,7 @@ class Worker:
71
72
  reconnection_delay: timedelta
72
73
  minimum_check_interval: timedelta
73
74
  scheduling_resolution: timedelta
75
+ schedule_automatic_tasks: bool
74
76
 
75
77
  def __init__(
76
78
  self,
@@ -81,14 +83,16 @@ class Worker:
81
83
  reconnection_delay: timedelta = timedelta(seconds=5),
82
84
  minimum_check_interval: timedelta = timedelta(milliseconds=250),
83
85
  scheduling_resolution: timedelta = timedelta(milliseconds=250),
86
+ schedule_automatic_tasks: bool = True,
84
87
  ) -> None:
85
88
  self.docket = docket
86
- self.name = name or f"worker:{uuid4()}"
89
+ self.name = name or f"{socket.gethostname()}#{os.getpid()}"
87
90
  self.concurrency = concurrency
88
91
  self.redelivery_timeout = redelivery_timeout
89
92
  self.reconnection_delay = reconnection_delay
90
93
  self.minimum_check_interval = minimum_check_interval
91
94
  self.scheduling_resolution = scheduling_resolution
95
+ self.schedule_automatic_tasks = schedule_automatic_tasks
92
96
 
93
97
  async def __aenter__(self) -> Self:
94
98
  self._heartbeat_task = asyncio.create_task(self._heartbeat())
@@ -134,6 +138,7 @@ class Worker:
134
138
  reconnection_delay: timedelta = timedelta(seconds=5),
135
139
  minimum_check_interval: timedelta = timedelta(milliseconds=100),
136
140
  scheduling_resolution: timedelta = timedelta(milliseconds=250),
141
+ schedule_automatic_tasks: bool = True,
137
142
  until_finished: bool = False,
138
143
  metrics_port: int | None = None,
139
144
  tasks: list[str] = ["docket.tasks:standard_tasks"],
@@ -151,6 +156,7 @@ class Worker:
151
156
  reconnection_delay=reconnection_delay,
152
157
  minimum_check_interval=minimum_check_interval,
153
158
  scheduling_resolution=scheduling_resolution,
159
+ schedule_automatic_tasks=schedule_automatic_tasks,
154
160
  ) as worker:
155
161
  if until_finished:
156
162
  await worker.run_until_finished()
@@ -199,10 +205,7 @@ class Worker:
199
205
  self._execution_counts = {}
200
206
 
201
207
  async def _run(self, forever: bool = False) -> None:
202
- logger.info("Starting worker %r with the following tasks:", self.name)
203
- for task_name, task in self.docket.tasks.items():
204
- signature = get_signature(task)
205
- logger.info("* %s%s", task_name, signature)
208
+ self._startup_log()
206
209
 
207
210
  while True:
208
211
  try:
@@ -220,7 +223,8 @@ class Worker:
220
223
  async def _worker_loop(self, redis: Redis, forever: bool = False):
221
224
  worker_stopping = asyncio.Event()
222
225
 
223
- await self._schedule_all_automatic_perpetual_tasks()
226
+ if self.schedule_automatic_tasks:
227
+ await self._schedule_all_automatic_perpetual_tasks()
224
228
 
225
229
  scheduler_task = asyncio.create_task(
226
230
  self._scheduler_loop(redis, worker_stopping)
@@ -499,6 +503,8 @@ class Worker:
499
503
  arrow = "↬" if execution.attempt > 1 else "↪"
500
504
  logger.info("%s [%s] %s", arrow, ms(punctuality), call, extra=log_context)
501
505
 
506
+ dependencies: dict[str, Dependency] = {}
507
+
502
508
  with tracer.start_as_current_span(
503
509
  execution.function.__name__,
504
510
  kind=trace.SpanKind.CONSUMER,
@@ -509,17 +515,34 @@ class Worker:
509
515
  },
510
516
  links=execution.incoming_span_links(),
511
517
  ):
512
- async with resolved_dependencies(self, execution) as dependencies:
513
- # Preemptively reschedule the perpetual task for the future, or clear
514
- # the known task key for this task
515
- rescheduled = await self._perpetuate_if_requested(
516
- execution, dependencies
517
- )
518
- if not rescheduled:
519
- async with self.docket.redis() as redis:
520
- await self._delete_known_task(redis, execution)
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
+
529
+ dependency_failures = {
530
+ k: v
531
+ for k, v in dependencies.items()
532
+ if isinstance(v, FailedDependency)
533
+ }
534
+ if dependency_failures:
535
+ raise ExceptionGroup(
536
+ (
537
+ "Failed to resolve dependencies for parameter(s): "
538
+ + ", ".join(dependency_failures.keys())
539
+ ),
540
+ [
541
+ dependency.error
542
+ for dependency in dependency_failures.values()
543
+ ],
544
+ )
521
545
 
522
- try:
523
546
  if timeout := get_single_dependency_of_type(dependencies, Timeout):
524
547
  await self._run_function_with_timeout(
525
548
  execution, dependencies, timeout
@@ -544,24 +567,24 @@ class Worker:
544
567
  logger.info(
545
568
  "%s [%s] %s", arrow, ms(duration), call, extra=log_context
546
569
  )
547
- except Exception:
548
- duration = log_context["duration"] = time.time() - start
549
- TASKS_FAILED.add(1, counter_labels)
550
-
551
- retried = await self._retry_if_requested(execution, dependencies)
552
- if not retried:
553
- retried = await self._perpetuate_if_requested(
554
- execution, dependencies, timedelta(seconds=duration)
555
- )
570
+ except Exception:
571
+ duration = log_context["duration"] = time.time() - start
572
+ TASKS_FAILED.add(1, counter_labels)
556
573
 
557
- arrow = "↫" if retried else "↩"
558
- logger.exception(
559
- "%s [%s] %s", arrow, ms(duration), call, extra=log_context
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)
560
578
  )
561
- finally:
562
- TASKS_RUNNING.add(-1, counter_labels)
563
- TASKS_COMPLETED.add(1, counter_labels)
564
- TASK_DURATION.record(duration, counter_labels)
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)
565
588
 
566
589
  async def _run_function_with_timeout(
567
590
  self,
@@ -641,6 +664,11 @@ class Worker:
641
664
 
642
665
  return True
643
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
+
644
672
  @property
645
673
  def workers_set(self) -> str:
646
674
  return self.docket.workers_set
@@ -1,7 +1,9 @@
1
+ import logging
2
+
1
3
  import pytest
2
4
 
3
5
  from docket import CurrentDocket, CurrentWorker, Docket, Worker
4
- from docket.dependencies import Retry
6
+ from docket.dependencies import Depends, Retry, TaskArgument
5
7
 
6
8
 
7
9
  async def test_dependencies_may_be_duplicated(docket: Docket, worker: Worker):
@@ -91,3 +93,48 @@ async def test_user_provide_retries_are_used(docket: Docket, worker: Worker):
91
93
  await worker.run_until_finished()
92
94
 
93
95
  assert calls == 2
96
+
97
+
98
+ async def test_dependencies_error_for_missing_task_argument(
99
+ docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
100
+ ):
101
+ """A task will fail when asking for a missing task argument"""
102
+
103
+ async def dependency_one(nope: list[str] = TaskArgument()) -> list[str]:
104
+ raise NotImplementedError("This should not be called") # pragma: no cover
105
+
106
+ async def dependent_task(
107
+ a: list[str],
108
+ b: list[str] = TaskArgument("a"),
109
+ c: list[str] = Depends(dependency_one),
110
+ ) -> None:
111
+ raise NotImplementedError("This should not be called") # pragma: no cover
112
+
113
+ await docket.add(dependent_task)(a=["hello", "world"])
114
+
115
+ await worker.run_until_finished()
116
+
117
+ with caplog.at_level(logging.ERROR):
118
+ await worker.run_until_finished()
119
+
120
+ assert "Failed to resolve dependencies for parameter(s): c" in caplog.text
121
+ assert "ExceptionGroup" in caplog.text
122
+ assert "KeyError: 'nope'" in caplog.text
123
+
124
+
125
+ async def test_a_task_argument_cannot_ask_for_itself(
126
+ docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
127
+ ):
128
+ """A task argument cannot ask for itself"""
129
+
130
+ # This task would be nonsense, because it's asking for itself.
131
+ async def dependent_task(a: list[str] = TaskArgument()) -> None:
132
+ raise NotImplementedError("This should not be called") # pragma: no cover
133
+
134
+ await docket.add(dependent_task)()
135
+
136
+ with caplog.at_level(logging.ERROR):
137
+ await worker.run_until_finished()
138
+
139
+ assert "Failed to resolve dependencies for parameter(s): a" in caplog.text
140
+ assert "ValueError: No parameter name specified" in caplog.text
@@ -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
@@ -27,6 +27,7 @@ from docket import (
27
27
  Logged,
28
28
  Perpetual,
29
29
  Retry,
30
+ TaskArgument,
30
31
  TaskKey,
31
32
  TaskLogger,
32
33
  Timeout,
@@ -150,10 +151,10 @@ async def test_rescheduling_by_name(
150
151
 
151
152
  key = f"my-cool-task:{uuid4()}"
152
153
 
153
- soon = now() + timedelta(milliseconds=10)
154
+ soon = now() + timedelta(milliseconds=100)
154
155
  await docket.add(the_task, soon, key=key)("a", "b", c="c")
155
156
 
156
- later = now() + timedelta(milliseconds=100)
157
+ later = now() + timedelta(milliseconds=200)
157
158
  await docket.replace("the_task", later, key=key)("b", "c", c="d")
158
159
 
159
160
  await worker.run_until_finished()
@@ -1383,3 +1384,152 @@ async def test_dependencies_can_ask_for_docket_dependencies(
1383
1384
  await docket.add(dependent_task)()
1384
1385
 
1385
1386
  await worker.run_until_finished()
1387
+
1388
+
1389
+ async def test_dependency_failures_are_task_failures(
1390
+ docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
1391
+ ):
1392
+ """A task dependency failure will cause the task to fail"""
1393
+
1394
+ called: bool = False
1395
+
1396
+ async def dependency_one() -> str:
1397
+ raise ValueError("this one is bad")
1398
+
1399
+ async def dependency_two() -> str:
1400
+ raise ValueError("and so is this one")
1401
+
1402
+ async def dependent_task(
1403
+ a: str = Depends(dependency_one),
1404
+ b: str = Depends(dependency_two),
1405
+ ) -> None:
1406
+ nonlocal called
1407
+ called = True # pragma: no cover
1408
+
1409
+ await docket.add(dependent_task)()
1410
+
1411
+ with caplog.at_level(logging.ERROR):
1412
+ await worker.run_until_finished()
1413
+
1414
+ assert not called
1415
+
1416
+ assert "Failed to resolve dependencies for parameter(s): a, b" in caplog.text
1417
+ assert "ValueError: this one is bad" in caplog.text
1418
+ assert "ValueError: and so is this one" in caplog.text
1419
+
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
+
1480
+ async def test_dependencies_can_ask_for_task_arguments(docket: Docket, worker: Worker):
1481
+ """A task dependency can ask for a task argument"""
1482
+
1483
+ called = 0
1484
+
1485
+ async def dependency_one(a: list[str] = TaskArgument()) -> list[str]:
1486
+ return a
1487
+
1488
+ async def dependency_two(another_name: list[str] = TaskArgument("a")) -> list[str]:
1489
+ return another_name
1490
+
1491
+ async def dependent_task(
1492
+ a: list[str],
1493
+ b: list[str] = TaskArgument("a"),
1494
+ c: list[str] = Depends(dependency_one),
1495
+ d: list[str] = Depends(dependency_two),
1496
+ ) -> None:
1497
+ assert a is b
1498
+ assert a is c
1499
+ assert a is d
1500
+
1501
+ nonlocal called
1502
+ called += 1
1503
+
1504
+ await docket.add(dependent_task)(a=["hello", "world"])
1505
+
1506
+ await worker.run_until_finished()
1507
+
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
- minimum = min(intervals)
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 minimum >= timedelta(milliseconds=45), debug
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(
@@ -491,3 +491,25 @@ def test_formatting_durations():
491
491
  assert ms(1000.000) == " 1000s "
492
492
  assert ms(10000.00) == " 10000s "
493
493
  assert ms(100000.0) == "100000s "
494
+
495
+
496
+ async def test_worker_can_be_told_to_skip_automatic_tasks(docket: Docket):
497
+ """A worker can be told to skip automatic tasks"""
498
+
499
+ called = False
500
+
501
+ async def perpetual_task(
502
+ perpetual: Perpetual = Perpetual(
503
+ every=timedelta(milliseconds=50), automatic=True
504
+ ),
505
+ ):
506
+ nonlocal called
507
+ called = True # pragma: no cover
508
+
509
+ docket.register(perpetual_task)
510
+
511
+ # Without the flag, this would hang because the task would always be scheduled
512
+ async with Worker(docket, schedule_automatic_tasks=False) as worker:
513
+ await worker.run_until_finished()
514
+
515
+ assert not called
@@ -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"
@@ -1,3 +0,0 @@
1
- from docket.cli import app
2
-
3
- app()
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