pydocket 0.6.1__py3-none-any.whl → 0.6.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.

Potentially problematic release.


This version of pydocket might be problematic. Click here for more details.

docket/__init__.py CHANGED
@@ -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
  ]
docket/__main__.py CHANGED
@@ -1,3 +1,3 @@
1
- from docket.cli import app
1
+ from .cli import app
2
2
 
3
3
  app()
docket/cli.py CHANGED
@@ -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,
docket/dependencies.py CHANGED
@@ -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
docket/docket.py CHANGED
@@ -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)
docket/execution.py CHANGED
@@ -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 = "!="
docket/worker.py CHANGED
@@ -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,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
@@ -0,0 +1,16 @@
1
+ docket/__init__.py,sha256=sY1T_NVsXQNOmOhOnfYmZ95dcE_52Ov6DSIVIMZp-1w,869
2
+ docket/__main__.py,sha256=wcCrL4PjG51r5wVKqJhcoJPTLfHW0wNbD31DrUN0MWI,28
3
+ docket/annotations.py,sha256=6sCgQxsgOjBN6ithFdXulXq4CPNSdyFocwyJ1gK9v2Q,1688
4
+ docket/cli.py,sha256=znHN7eqaD_PFpSFn7iXa_uZlKzVWDrKkrmOd1CNuZRk,20561
5
+ docket/dependencies.py,sha256=-gruEho5jf07Jx9fEh2YBFg4gDSJFm7X5qhQjArVXjU,11910
6
+ docket/docket.py,sha256=r5TNcGmaQuxST56OVKNjFXDsrU5-Ioz3Y_I38PkLqRM,21411
7
+ docket/execution.py,sha256=6KozjnS96byvyCMTQ2-IkcIrPsqaPIVu2HZU0U4Be9E,14813
8
+ docket/instrumentation.py,sha256=bZlGA02JoJcY0J1WGm5_qXDfY0AXKr0ZLAYu67wkeKY,4611
9
+ docket/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ docket/tasks.py,sha256=RIlSM2omh-YDwVnCz6M5MtmK8T_m_s1w2OlRRxDUs6A,1437
11
+ docket/worker.py,sha256=Xf6_7GyrIUNq1jG8YjbJk5KkRQdvxs0CniF9XW8kdJg,27450
12
+ pydocket-0.6.3.dist-info/METADATA,sha256=LRtykRFP2dcauKjzQDoNpC_xe6aVjvleAN1xS5cSIUY,13120
13
+ pydocket-0.6.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ pydocket-0.6.3.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
15
+ pydocket-0.6.3.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
16
+ pydocket-0.6.3.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- docket/__init__.py,sha256=124XWbyQQHO1lhCoLQ-oheZnu4vNDHIaq4Whb7z3ogI,831
2
- docket/__main__.py,sha256=Vkuh7aJ-Bl7QVpVbbkUksAd_hn05FiLmWbc-8kbhZQ4,34
3
- docket/annotations.py,sha256=6sCgQxsgOjBN6ithFdXulXq4CPNSdyFocwyJ1gK9v2Q,1688
4
- docket/cli.py,sha256=OWql6QFthSbvRCGkIg-ufo26F48z0eCmzRXJYOdyAEc,20309
5
- docket/dependencies.py,sha256=pkjseBZjdSpgW9g2H4cZ_RXIRZ2ZfdngBCXJGUcbmao,10052
6
- docket/docket.py,sha256=KJxgiyOskEHsRQOmfgLpJCYDNNleHI-vEKK3uBPL_K8,21420
7
- docket/execution.py,sha256=f3LLt9bC7ExEZhgde5OBo1faKLYv-8ryfNLXSswo318,13579
8
- docket/instrumentation.py,sha256=bZlGA02JoJcY0J1WGm5_qXDfY0AXKr0ZLAYu67wkeKY,4611
9
- docket/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- docket/tasks.py,sha256=RIlSM2omh-YDwVnCz6M5MtmK8T_m_s1w2OlRRxDUs6A,1437
11
- docket/worker.py,sha256=NrzmfpjHjQaGS8CoTOiKM5Bn88tPh_q2hz9f4hFegSk,26280
12
- pydocket-0.6.1.dist-info/METADATA,sha256=mxI1OHWe9W9bAyi8QiH69eMSsSk1Dm2oDvh301BJFgo,13092
13
- pydocket-0.6.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
- pydocket-0.6.1.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
15
- pydocket-0.6.1.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
16
- pydocket-0.6.1.dist-info/RECORD,,