pydocket 0.5.0__tar.gz → 0.5.1__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 (51) hide show
  1. {pydocket-0.5.0 → pydocket-0.5.1}/PKG-INFO +1 -1
  2. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/dependencies.py +42 -2
  3. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/worker.py +68 -29
  4. {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_fundamentals.py +28 -0
  5. {pydocket-0.5.0 → pydocket-0.5.1}/.cursor/rules/general.mdc +0 -0
  6. {pydocket-0.5.0 → pydocket-0.5.1}/.cursor/rules/python-style.mdc +0 -0
  7. {pydocket-0.5.0 → pydocket-0.5.1}/.github/codecov.yml +0 -0
  8. {pydocket-0.5.0 → pydocket-0.5.1}/.github/workflows/chaos.yml +0 -0
  9. {pydocket-0.5.0 → pydocket-0.5.1}/.github/workflows/ci.yml +0 -0
  10. {pydocket-0.5.0 → pydocket-0.5.1}/.github/workflows/publish.yml +0 -0
  11. {pydocket-0.5.0 → pydocket-0.5.1}/.gitignore +0 -0
  12. {pydocket-0.5.0 → pydocket-0.5.1}/.pre-commit-config.yaml +0 -0
  13. {pydocket-0.5.0 → pydocket-0.5.1}/LICENSE +0 -0
  14. {pydocket-0.5.0 → pydocket-0.5.1}/README.md +0 -0
  15. {pydocket-0.5.0 → pydocket-0.5.1}/chaos/README.md +0 -0
  16. {pydocket-0.5.0 → pydocket-0.5.1}/chaos/__init__.py +0 -0
  17. {pydocket-0.5.0 → pydocket-0.5.1}/chaos/driver.py +0 -0
  18. {pydocket-0.5.0 → pydocket-0.5.1}/chaos/producer.py +0 -0
  19. {pydocket-0.5.0 → pydocket-0.5.1}/chaos/run +0 -0
  20. {pydocket-0.5.0 → pydocket-0.5.1}/chaos/tasks.py +0 -0
  21. {pydocket-0.5.0 → pydocket-0.5.1}/pyproject.toml +0 -0
  22. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/__init__.py +0 -0
  23. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/__main__.py +0 -0
  24. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/annotations.py +0 -0
  25. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/cli.py +0 -0
  26. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/docket.py +0 -0
  27. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/execution.py +0 -0
  28. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/instrumentation.py +0 -0
  29. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/py.typed +0 -0
  30. {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/tasks.py +0 -0
  31. {pydocket-0.5.0 → pydocket-0.5.1}/telemetry/.gitignore +0 -0
  32. {pydocket-0.5.0 → pydocket-0.5.1}/telemetry/start +0 -0
  33. {pydocket-0.5.0 → pydocket-0.5.1}/telemetry/stop +0 -0
  34. {pydocket-0.5.0 → pydocket-0.5.1}/tests/__init__.py +0 -0
  35. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/__init__.py +0 -0
  36. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/conftest.py +0 -0
  37. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_module.py +0 -0
  38. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_parsing.py +0 -0
  39. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_snapshot.py +0 -0
  40. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_striking.py +0 -0
  41. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_tasks.py +0 -0
  42. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_version.py +0 -0
  43. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_worker.py +0 -0
  44. {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_workers.py +0 -0
  45. {pydocket-0.5.0 → pydocket-0.5.1}/tests/conftest.py +0 -0
  46. {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_dependencies.py +0 -0
  47. {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_docket.py +0 -0
  48. {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_instrumentation.py +0 -0
  49. {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_striking.py +0 -0
  50. {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_worker.py +0 -0
  51. {pydocket-0.5.0 → pydocket-0.5.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydocket
3
- Version: 0.5.0
3
+ Version: 0.5.1
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
@@ -2,7 +2,7 @@ import abc
2
2
  import inspect
3
3
  import logging
4
4
  from datetime import timedelta
5
- from typing import Any, Awaitable, Callable, Counter, cast
5
+ from typing import Any, Awaitable, Callable, Counter, TypeVar, cast
6
6
 
7
7
  from .docket import Docket
8
8
  from .execution import Execution
@@ -130,12 +130,29 @@ class Perpetual(Dependency):
130
130
  single = True
131
131
 
132
132
  every: timedelta
133
+ automatic: bool
134
+
133
135
  args: tuple[Any, ...]
134
136
  kwargs: dict[str, Any]
137
+
135
138
  cancelled: bool
136
139
 
137
- def __init__(self, every: timedelta = timedelta(0)) -> None:
140
+ def __init__(
141
+ self,
142
+ every: timedelta = timedelta(0),
143
+ automatic: bool = False,
144
+ ) -> None:
145
+ """Declare a task that should be run perpetually.
146
+
147
+ Args:
148
+ every: The target interval between task executions.
149
+ automatic: If set, this task will be automatically scheduled during worker
150
+ startup and continually through the worker's lifespan. This ensures
151
+ that the task will always be scheduled despite crashes and other
152
+ adverse conditions. Automatic tasks must not require any arguments.
153
+ """
138
154
  self.every = every
155
+ self.automatic = automatic
139
156
  self.cancelled = False
140
157
 
141
158
  def __call__(
@@ -170,6 +187,29 @@ def get_dependency_parameters(
170
187
  return dependencies
171
188
 
172
189
 
190
+ D = TypeVar("D", bound=Dependency)
191
+
192
+
193
+ def get_single_dependency_parameter_of_type(
194
+ function: Callable[..., Awaitable[Any]], dependency_type: type[D]
195
+ ) -> D | None:
196
+ assert dependency_type.single, "Dependency must be single"
197
+ for _, dependency in get_dependency_parameters(function).items():
198
+ if isinstance(dependency, dependency_type):
199
+ return dependency
200
+ return None
201
+
202
+
203
+ def get_single_dependency_of_type(
204
+ dependencies: dict[str, Dependency], dependency_type: type[D]
205
+ ) -> D | None:
206
+ assert dependency_type.single, "Dependency must be single"
207
+ for _, dependency in dependencies.items():
208
+ if isinstance(dependency, dependency_type):
209
+ return dependency
210
+ return None
211
+
212
+
173
213
  def validate_dependencies(function: Callable[..., Awaitable[Any]]) -> None:
174
214
  parameters = get_dependency_parameters(function)
175
215
 
@@ -6,11 +6,9 @@ from datetime import datetime, timedelta, timezone
6
6
  from types import TracebackType
7
7
  from typing import (
8
8
  TYPE_CHECKING,
9
- Any,
10
9
  Mapping,
11
10
  Protocol,
12
11
  Self,
13
- TypeVar,
14
12
  cast,
15
13
  )
16
14
  from uuid import uuid4
@@ -53,8 +51,6 @@ tracer: Tracer = trace.get_tracer(__name__)
53
51
  if TYPE_CHECKING: # pragma: no cover
54
52
  from .dependencies import Dependency
55
53
 
56
- D = TypeVar("D", bound="Dependency")
57
-
58
54
 
59
55
  class _stream_due_tasks(Protocol):
60
56
  async def __call__(
@@ -216,13 +212,17 @@ class Worker:
216
212
  await asyncio.sleep(self.reconnection_delay.total_seconds())
217
213
 
218
214
  async def _worker_loop(self, forever: bool = False):
219
- should_stop = asyncio.Event()
215
+ worker_stopping = asyncio.Event()
216
+
217
+ await self._schedule_all_automatic_perpetual_tasks()
218
+ perpetual_scheduling_task = asyncio.create_task(
219
+ self._perpetual_scheduling_loop(worker_stopping)
220
+ )
220
221
 
221
222
  async with self.docket.redis() as redis:
222
223
  scheduler_task = asyncio.create_task(
223
- self._scheduler_loop(redis, should_stop)
224
+ self._scheduler_loop(redis, worker_stopping)
224
225
  )
225
-
226
226
  active_tasks: dict[asyncio.Task[None], RedisMessageID] = {}
227
227
 
228
228
  async def check_for_work() -> bool:
@@ -329,13 +329,14 @@ class Worker:
329
329
  await asyncio.gather(*active_tasks, return_exceptions=True)
330
330
  await process_completed_tasks()
331
331
 
332
- should_stop.set()
332
+ worker_stopping.set()
333
333
  await scheduler_task
334
+ await perpetual_scheduling_task
334
335
 
335
336
  async def _scheduler_loop(
336
337
  self,
337
338
  redis: Redis,
338
- should_stop: asyncio.Event,
339
+ worker_stopping: asyncio.Event,
339
340
  ) -> None:
340
341
  """Loop that moves due tasks from the queue to the stream."""
341
342
 
@@ -389,7 +390,7 @@ class Worker:
389
390
 
390
391
  total_work: int = sys.maxsize
391
392
 
392
- while not should_stop.is_set() or total_work:
393
+ while not worker_stopping.is_set() or total_work:
393
394
  try:
394
395
  total_work, due_work = await stream_due_tasks(
395
396
  keys=[self.docket.queue_key, self.docket.stream_key],
@@ -416,6 +417,49 @@ class Worker:
416
417
 
417
418
  logger.debug("Scheduler loop finished", extra=self._log_context())
418
419
 
420
+ async def _perpetual_scheduling_loop(self, worker_stopping: asyncio.Event) -> None:
421
+ """Loop that ensures that automatic perpetual tasks are always scheduled."""
422
+
423
+ while not worker_stopping.is_set():
424
+ minimum_interval = self.scheduling_resolution
425
+ try:
426
+ minimum_interval = await self._schedule_all_automatic_perpetual_tasks()
427
+ except Exception: # pragma: no cover
428
+ logger.exception(
429
+ "Error in perpetual scheduling loop",
430
+ exc_info=True,
431
+ extra=self._log_context(),
432
+ )
433
+ finally:
434
+ # Wait until just before the next time any task would need to be
435
+ # scheduled (one scheduling_resolution before the lowest interval)
436
+ interval = max(
437
+ minimum_interval - self.scheduling_resolution,
438
+ self.scheduling_resolution,
439
+ )
440
+ assert interval <= self.scheduling_resolution
441
+ await asyncio.sleep(interval.total_seconds())
442
+
443
+ async def _schedule_all_automatic_perpetual_tasks(self) -> timedelta:
444
+ from .dependencies import Perpetual, get_single_dependency_parameter_of_type
445
+
446
+ minimum_interval = self.scheduling_resolution
447
+ for task_function in self.docket.tasks.values():
448
+ perpetual = get_single_dependency_parameter_of_type(
449
+ task_function, Perpetual
450
+ )
451
+ if perpetual is None:
452
+ continue
453
+
454
+ if not perpetual.automatic:
455
+ continue
456
+
457
+ key = task_function.__name__
458
+ await self.docket.add(task_function, key=key)()
459
+ minimum_interval = min(minimum_interval, perpetual.every)
460
+
461
+ return minimum_interval
462
+
419
463
  async def _execute(self, message: RedisMessage) -> None:
420
464
  key = message[b"key"].decode()
421
465
  async with self.docket.redis() as redis:
@@ -511,12 +555,12 @@ class Worker:
511
555
  def _get_dependencies(
512
556
  self,
513
557
  execution: Execution,
514
- ) -> dict[str, Any]:
558
+ ) -> dict[str, "Dependency"]:
515
559
  from .dependencies import get_dependency_parameters
516
560
 
517
561
  parameters = get_dependency_parameters(execution.function)
518
562
 
519
- dependencies: dict[str, Any] = {}
563
+ dependencies: dict[str, "Dependency"] = {}
520
564
 
521
565
  for parameter_name, dependency in parameters.items():
522
566
  # If the argument is already provided, skip it, which allows users to call
@@ -532,16 +576,14 @@ class Worker:
532
576
  async def _retry_if_requested(
533
577
  self,
534
578
  execution: Execution,
535
- dependencies: dict[str, Any],
579
+ dependencies: dict[str, "Dependency"],
536
580
  ) -> bool:
537
- from .dependencies import Retry
581
+ from .dependencies import Retry, get_single_dependency_of_type
538
582
 
539
- retries = [retry for retry in dependencies.values() if isinstance(retry, Retry)]
540
- if not retries:
583
+ retry = get_single_dependency_of_type(dependencies, Retry)
584
+ if not retry:
541
585
  return False
542
586
 
543
- retry = retries[0]
544
-
545
587
  if retry.attempts is None or execution.attempt < retry.attempts:
546
588
  execution.when = datetime.now(timezone.utc) + retry.delay
547
589
  execution.attempt += 1
@@ -553,19 +595,16 @@ class Worker:
553
595
  return False
554
596
 
555
597
  async def _perpetuate_if_requested(
556
- self, execution: Execution, dependencies: dict[str, Any], duration: timedelta
598
+ self,
599
+ execution: Execution,
600
+ dependencies: dict[str, "Dependency"],
601
+ duration: timedelta,
557
602
  ) -> bool:
558
- from .dependencies import Perpetual
559
-
560
- perpetuals = [
561
- perpetual
562
- for perpetual in dependencies.values()
563
- if isinstance(perpetual, Perpetual)
564
- ]
565
- if not perpetuals:
566
- return False
603
+ from .dependencies import Perpetual, get_single_dependency_of_type
567
604
 
568
- perpetual = perpetuals[0]
605
+ perpetual = get_single_dependency_of_type(dependencies, Perpetual)
606
+ if not perpetual:
607
+ return False
569
608
 
570
609
  if perpetual.cancelled:
571
610
  return False
@@ -1037,3 +1037,31 @@ async def test_perpetual_tasks_perpetuate_even_after_errors(
1037
1037
  await worker.run_at_most({execution.key: 3})
1038
1038
 
1039
1039
  assert calls == 3
1040
+
1041
+
1042
+ async def test_perpetual_tasks_can_be_automatically_scheduled(
1043
+ docket: Docket, worker: Worker
1044
+ ):
1045
+ """Perpetual tasks can be automatically scheduled"""
1046
+
1047
+ calls = 0
1048
+
1049
+ async def my_automatic_task(
1050
+ perpetual: Perpetual = Perpetual(
1051
+ every=timedelta(milliseconds=50), automatic=True
1052
+ ),
1053
+ ):
1054
+ assert isinstance(perpetual, Perpetual)
1055
+
1056
+ assert perpetual.every == timedelta(milliseconds=50)
1057
+
1058
+ nonlocal calls
1059
+ calls += 1
1060
+
1061
+ # Note we never add this task to the docket, we just register it.
1062
+ docket.register(my_automatic_task)
1063
+
1064
+ # The automatic key will be the task function's name
1065
+ await worker.run_at_most({"my_automatic_task": 3})
1066
+
1067
+ assert calls == 3
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