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.

Files changed (57) hide show
  1. {pydocket-0.6.2 → pydocket-0.6.3}/PKG-INFO +2 -1
  2. {pydocket-0.6.2 → pydocket-0.6.3}/examples/common.py +0 -2
  3. {pydocket-0.6.2 → 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.2 → pydocket-0.6.3}/pyproject.toml +1 -0
  6. pydocket-0.6.3/src/docket/__main__.py +3 -0
  7. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/dependencies.py +11 -4
  8. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/docket.py +2 -2
  9. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/execution.py +43 -4
  10. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/worker.py +38 -34
  11. pydocket-0.6.3/tests/test_execution.py +63 -0
  12. {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_fundamentals.py +88 -2
  13. {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_worker.py +6 -6
  14. {pydocket-0.6.2 → pydocket-0.6.3}/uv.lock +11 -0
  15. pydocket-0.6.2/src/docket/__main__.py +0 -3
  16. {pydocket-0.6.2 → pydocket-0.6.3}/.cursor/rules/general.mdc +0 -0
  17. {pydocket-0.6.2 → pydocket-0.6.3}/.cursor/rules/python-style.mdc +0 -0
  18. {pydocket-0.6.2 → pydocket-0.6.3}/.github/codecov.yml +0 -0
  19. {pydocket-0.6.2 → pydocket-0.6.3}/.github/workflows/chaos.yml +0 -0
  20. {pydocket-0.6.2 → pydocket-0.6.3}/.github/workflows/ci.yml +0 -0
  21. {pydocket-0.6.2 → pydocket-0.6.3}/.github/workflows/publish.yml +0 -0
  22. {pydocket-0.6.2 → pydocket-0.6.3}/.gitignore +0 -0
  23. {pydocket-0.6.2 → pydocket-0.6.3}/.pre-commit-config.yaml +0 -0
  24. {pydocket-0.6.2 → pydocket-0.6.3}/LICENSE +0 -0
  25. {pydocket-0.6.2 → pydocket-0.6.3}/README.md +0 -0
  26. {pydocket-0.6.2 → pydocket-0.6.3}/chaos/README.md +0 -0
  27. {pydocket-0.6.2 → pydocket-0.6.3}/chaos/__init__.py +0 -0
  28. {pydocket-0.6.2 → pydocket-0.6.3}/chaos/driver.py +0 -0
  29. {pydocket-0.6.2 → pydocket-0.6.3}/chaos/producer.py +0 -0
  30. {pydocket-0.6.2 → pydocket-0.6.3}/chaos/run +0 -0
  31. {pydocket-0.6.2 → pydocket-0.6.3}/chaos/tasks.py +0 -0
  32. {pydocket-0.6.2 → pydocket-0.6.3}/examples/__init__.py +0 -0
  33. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/__init__.py +0 -0
  34. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/annotations.py +0 -0
  35. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/cli.py +0 -0
  36. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/instrumentation.py +0 -0
  37. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/py.typed +0 -0
  38. {pydocket-0.6.2 → pydocket-0.6.3}/src/docket/tasks.py +0 -0
  39. {pydocket-0.6.2 → pydocket-0.6.3}/telemetry/.gitignore +0 -0
  40. {pydocket-0.6.2 → pydocket-0.6.3}/telemetry/start +0 -0
  41. {pydocket-0.6.2 → pydocket-0.6.3}/telemetry/stop +0 -0
  42. {pydocket-0.6.2 → pydocket-0.6.3}/tests/__init__.py +0 -0
  43. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/__init__.py +0 -0
  44. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/conftest.py +0 -0
  45. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_module.py +0 -0
  46. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_parsing.py +0 -0
  47. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_snapshot.py +0 -0
  48. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_striking.py +0 -0
  49. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_tasks.py +0 -0
  50. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_version.py +0 -0
  51. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_worker.py +0 -0
  52. {pydocket-0.6.2 → pydocket-0.6.3}/tests/cli/test_workers.py +0 -0
  53. {pydocket-0.6.2 → pydocket-0.6.3}/tests/conftest.py +0 -0
  54. {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_dependencies.py +0 -0
  55. {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_docket.py +0 -0
  56. {pydocket-0.6.2 → pydocket-0.6.3}/tests/test_instrumentation.py +0 -0
  57. {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.2
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]
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -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
- return execution.get_argument(self.parameter)
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 = 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
 
@@ -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"worker:{uuid4()}"
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
- logger.info("Starting worker %r with the following tasks:", self.name)
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
- 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)
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
- except Exception:
572
- duration = log_context["duration"] = time.time() - start
573
- TASKS_FAILED.add(1, counter_labels)
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
- arrow = "↫" if retried else "↩"
582
- logger.exception(
583
- "%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)
584
578
  )
585
- finally:
586
- TASKS_RUNNING.add(-1, counter_labels)
587
- TASKS_COMPLETED.add(1, counter_labels)
588
- 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)
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=10)
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=100)
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
- 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(
@@ -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
File without changes