pydocket 0.2.1__tar.gz → 0.3.0__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.2.1 → pydocket-0.3.0}/.pre-commit-config.yaml +2 -2
  2. {pydocket-0.2.1 → pydocket-0.3.0}/PKG-INFO +1 -1
  3. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/__init__.py +2 -0
  4. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/dependencies.py +28 -0
  5. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/instrumentation.py +6 -0
  6. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/worker.py +38 -1
  7. {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_fundamentals.py +102 -0
  8. {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_worker.py +56 -0
  9. {pydocket-0.2.1 → pydocket-0.3.0}/.cursor/rules/general.mdc +0 -0
  10. {pydocket-0.2.1 → pydocket-0.3.0}/.cursor/rules/python-style.mdc +0 -0
  11. {pydocket-0.2.1 → pydocket-0.3.0}/.github/codecov.yml +0 -0
  12. {pydocket-0.2.1 → pydocket-0.3.0}/.github/workflows/chaos.yml +0 -0
  13. {pydocket-0.2.1 → pydocket-0.3.0}/.github/workflows/ci.yml +0 -0
  14. {pydocket-0.2.1 → pydocket-0.3.0}/.github/workflows/publish.yml +0 -0
  15. {pydocket-0.2.1 → pydocket-0.3.0}/.gitignore +0 -0
  16. {pydocket-0.2.1 → pydocket-0.3.0}/LICENSE +0 -0
  17. {pydocket-0.2.1 → pydocket-0.3.0}/README.md +0 -0
  18. {pydocket-0.2.1 → pydocket-0.3.0}/chaos/README.md +0 -0
  19. {pydocket-0.2.1 → pydocket-0.3.0}/chaos/__init__.py +0 -0
  20. {pydocket-0.2.1 → pydocket-0.3.0}/chaos/driver.py +0 -0
  21. {pydocket-0.2.1 → pydocket-0.3.0}/chaos/producer.py +0 -0
  22. {pydocket-0.2.1 → pydocket-0.3.0}/chaos/run +0 -0
  23. {pydocket-0.2.1 → pydocket-0.3.0}/chaos/tasks.py +0 -0
  24. {pydocket-0.2.1 → pydocket-0.3.0}/pyproject.toml +0 -0
  25. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/__main__.py +0 -0
  26. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/annotations.py +0 -0
  27. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/cli.py +0 -0
  28. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/docket.py +0 -0
  29. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/execution.py +0 -0
  30. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/py.typed +0 -0
  31. {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/tasks.py +0 -0
  32. {pydocket-0.2.1 → pydocket-0.3.0}/telemetry/.gitignore +0 -0
  33. {pydocket-0.2.1 → pydocket-0.3.0}/telemetry/start +0 -0
  34. {pydocket-0.2.1 → pydocket-0.3.0}/telemetry/stop +0 -0
  35. {pydocket-0.2.1 → pydocket-0.3.0}/tests/__init__.py +0 -0
  36. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/__init__.py +0 -0
  37. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/conftest.py +0 -0
  38. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_module.py +0 -0
  39. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_parsing.py +0 -0
  40. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_snapshot.py +0 -0
  41. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_striking.py +0 -0
  42. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_tasks.py +0 -0
  43. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_version.py +0 -0
  44. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_worker.py +0 -0
  45. {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_workers.py +0 -0
  46. {pydocket-0.2.1 → pydocket-0.3.0}/tests/conftest.py +0 -0
  47. {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_dependencies.py +0 -0
  48. {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_docket.py +0 -0
  49. {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_instrumentation.py +0 -0
  50. {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_striking.py +0 -0
  51. {pydocket-0.2.1 → pydocket-0.3.0}/uv.lock +0 -0
@@ -24,13 +24,13 @@ repos:
24
24
  hooks:
25
25
  - id: pyright
26
26
  name: pyright (docket package)
27
- entry: uv run pyright --verifytypes docket --ignoreexternal
27
+ entry: pyright --verifytypes docket --ignoreexternal
28
28
  language: system
29
29
  types: [python]
30
30
  pass_filenames: false
31
31
  - id: pyright
32
32
  name: pyright (source and tests)
33
- entry: uv run pyright tests
33
+ entry: pyright tests
34
34
  language: system
35
35
  types: [python]
36
36
  pass_filenames: false
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydocket
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -14,6 +14,7 @@ from .dependencies import (
14
14
  CurrentExecution,
15
15
  CurrentWorker,
16
16
  ExponentialRetry,
17
+ Perpetual,
17
18
  Retry,
18
19
  TaskKey,
19
20
  TaskLogger,
@@ -34,5 +35,6 @@ __all__ = [
34
35
  "Retry",
35
36
  "ExponentialRetry",
36
37
  "Logged",
38
+ "Perpetual",
37
39
  "__version__",
38
40
  ]
@@ -126,6 +126,34 @@ class ExponentialRetry(Retry):
126
126
  return retry
127
127
 
128
128
 
129
+ class Perpetual(Dependency):
130
+ single = True
131
+
132
+ every: timedelta
133
+ args: tuple[Any, ...]
134
+ kwargs: dict[str, Any]
135
+ cancelled: bool
136
+
137
+ def __init__(self, every: timedelta = timedelta(0)) -> None:
138
+ self.every = every
139
+ self.cancelled = False
140
+
141
+ def __call__(
142
+ self, docket: Docket, worker: Worker, execution: Execution
143
+ ) -> "Perpetual":
144
+ perpetual = Perpetual(every=self.every)
145
+ perpetual.args = execution.args
146
+ perpetual.kwargs = execution.kwargs
147
+ return perpetual
148
+
149
+ def cancel(self) -> None:
150
+ self.cancelled = True
151
+
152
+ def perpetuate(self, *args: Any, **kwargs: Any) -> None:
153
+ self.args = args
154
+ self.kwargs = kwargs
155
+
156
+
129
157
  def get_dependency_parameters(
130
158
  function: Callable[..., Awaitable[Any]],
131
159
  ) -> dict[str, Dependency]:
@@ -70,6 +70,12 @@ TASKS_RETRIED = meter.create_counter(
70
70
  unit="1",
71
71
  )
72
72
 
73
+ TASKS_PERPETUATED = meter.create_counter(
74
+ "docket_tasks_perpetuated",
75
+ description="How many tasks that have been self-perpetuated",
76
+ unit="1",
77
+ )
78
+
73
79
  TASK_DURATION = meter.create_histogram(
74
80
  "docket_task_duration",
75
81
  description="How long tasks take to complete",
@@ -36,6 +36,7 @@ from .instrumentation import (
36
36
  TASK_PUNCTUALITY,
37
37
  TASKS_COMPLETED,
38
38
  TASKS_FAILED,
39
+ TASKS_PERPETUATED,
39
40
  TASKS_RETRIED,
40
41
  TASKS_RUNNING,
41
42
  TASKS_STARTED,
@@ -424,12 +425,20 @@ class Worker:
424
425
  TASKS_SUCCEEDED.add(1, counter_labels)
425
426
  duration = datetime.now(timezone.utc) - start
426
427
  log_context["duration"] = duration.total_seconds()
427
- logger.info("%s [%s] %s", "↩", duration, call, extra=log_context)
428
+ rescheduled = await self._perpetuate_if_requested(
429
+ execution, dependencies, duration
430
+ )
431
+ arrow = "↫" if rescheduled else "↩"
432
+ logger.info("%s [%s] %s", arrow, duration, call, extra=log_context)
428
433
  except Exception:
429
434
  TASKS_FAILED.add(1, counter_labels)
430
435
  duration = datetime.now(timezone.utc) - start
431
436
  log_context["duration"] = duration.total_seconds()
432
437
  retried = await self._retry_if_requested(execution, dependencies)
438
+ if not retried:
439
+ retried = await self._perpetuate_if_requested(
440
+ execution, dependencies, duration
441
+ )
433
442
  arrow = "↫" if retried else "↩"
434
443
  logger.exception("%s [%s] %s", arrow, duration, call, extra=log_context)
435
444
  finally:
@@ -481,6 +490,34 @@ class Worker:
481
490
 
482
491
  return False
483
492
 
493
+ async def _perpetuate_if_requested(
494
+ self, execution: Execution, dependencies: dict[str, Any], duration: timedelta
495
+ ) -> bool:
496
+ from .dependencies import Perpetual
497
+
498
+ perpetuals = [
499
+ perpetual
500
+ for perpetual in dependencies.values()
501
+ if isinstance(perpetual, Perpetual)
502
+ ]
503
+ if not perpetuals:
504
+ return False
505
+
506
+ perpetual = perpetuals[0]
507
+
508
+ if perpetual.cancelled:
509
+ return False
510
+
511
+ now = datetime.now(timezone.utc)
512
+ execution.when = max(now, now + perpetual.every - duration)
513
+ execution.args = perpetual.args
514
+ execution.kwargs = perpetual.kwargs
515
+
516
+ await self.docket.schedule(execution)
517
+
518
+ TASKS_PERPETUATED.add(1, {**self.labels(), **execution.specific_labels()})
519
+ return True
520
+
484
521
  @property
485
522
  def workers_set(self) -> str:
486
523
  return self.docket.workers_set
@@ -22,6 +22,7 @@ from docket import (
22
22
  Execution,
23
23
  ExponentialRetry,
24
24
  Logged,
25
+ Perpetual,
25
26
  Retry,
26
27
  TaskKey,
27
28
  TaskLogger,
@@ -854,3 +855,104 @@ async def test_adding_task_with_unbindable_arguments(
854
855
  await worker.run_until_finished()
855
856
 
856
857
  assert "got an unexpected keyword argument 'd'" in caplog.text
858
+
859
+
860
+ async def test_perpetual_tasks(docket: Docket, worker: Worker):
861
+ """Perpetual tasks should reschedule themselves forever"""
862
+
863
+ calls = 0
864
+
865
+ async def perpetual_task(
866
+ a: str,
867
+ b: int,
868
+ perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
869
+ ):
870
+ assert a == "a"
871
+ assert b == 2
872
+
873
+ assert isinstance(perpetual, Perpetual)
874
+
875
+ assert perpetual.every == timedelta(milliseconds=50)
876
+
877
+ nonlocal calls
878
+ calls += 1
879
+
880
+ execution = await docket.add(perpetual_task)(a="a", b=2)
881
+
882
+ await worker.run_at_most({execution.key: 3})
883
+
884
+ assert calls == 3
885
+
886
+
887
+ async def test_perpetual_tasks_can_cancel_themselves(docket: Docket, worker: Worker):
888
+ """A perpetual task can request its own cancellation"""
889
+ calls = 0
890
+
891
+ async def perpetual_task(
892
+ a: str,
893
+ b: int,
894
+ perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
895
+ ):
896
+ assert a == "a"
897
+ assert b == 2
898
+
899
+ assert isinstance(perpetual, Perpetual)
900
+
901
+ assert perpetual.every == timedelta(milliseconds=50)
902
+
903
+ nonlocal calls
904
+ calls += 1
905
+
906
+ if calls == 3:
907
+ perpetual.cancel()
908
+
909
+ await docket.add(perpetual_task)(a="a", b=2)
910
+
911
+ await worker.run_until_finished()
912
+
913
+ assert calls == 3
914
+
915
+
916
+ async def test_perpetual_tasks_can_change_their_parameters(
917
+ docket: Docket, worker: Worker
918
+ ):
919
+ """Perpetual tasks may change their parameters each time"""
920
+ arguments: list[tuple[str, int]] = []
921
+
922
+ async def perpetual_task(
923
+ a: str,
924
+ b: int,
925
+ perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
926
+ ):
927
+ arguments.append((a, b))
928
+ perpetual.perpetuate(a + "a", b=b + 1)
929
+
930
+ execution = await docket.add(perpetual_task)(a="a", b=1)
931
+
932
+ await worker.run_at_most({execution.key: 3})
933
+
934
+ assert len(arguments) == 3
935
+ assert arguments == [("a", 1), ("aa", 2), ("aaa", 3)]
936
+
937
+
938
+ async def test_perpetual_tasks_perpetuate_even_after_errors(
939
+ docket: Docket, worker: Worker
940
+ ):
941
+ """Perpetual tasks may change their parameters each time"""
942
+ calls = 0
943
+
944
+ async def perpetual_task(
945
+ a: str,
946
+ b: int,
947
+ perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
948
+ ):
949
+ nonlocal calls
950
+ calls += 1
951
+
952
+ raise ValueError("woops!")
953
+
954
+ execution = await docket.add(perpetual_task)(a="a", b=1)
955
+
956
+ await worker.run_at_most({execution.key: 3})
957
+
958
+ assert calls == 3
@@ -11,6 +11,7 @@ import redis.exceptions
11
11
  from redis.asyncio import Redis
12
12
 
13
13
  from docket import CurrentWorker, Docket, Worker
14
+ from docket.dependencies import CurrentDocket, Perpetual
14
15
  from docket.docket import RedisMessage
15
16
  from docket.tasks import standard_tasks
16
17
 
@@ -358,3 +359,58 @@ async def test_worker_recovers_from_redis_errors(
358
359
  assert worker_info.last_seen > error_time, (
359
360
  "Worker should have sent heartbeats after the Redis error"
360
361
  )
362
+
363
+
364
+ async def test_perpetual_tasks_are_scheduled_close_to_target_time(
365
+ docket: Docket, worker: Worker
366
+ ):
367
+ """A perpetual task is scheduled as close to the target period as possible"""
368
+ timestamps: list[datetime] = []
369
+
370
+ async def perpetual_task(
371
+ a: str,
372
+ b: int,
373
+ perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
374
+ ):
375
+ timestamps.append(datetime.now(timezone.utc))
376
+
377
+ if len(timestamps) % 2 == 0:
378
+ await asyncio.sleep(0.05)
379
+
380
+ await docket.add(perpetual_task, key="my-key")(a="a", b=2)
381
+
382
+ await worker.run_at_most({"my-key": 8})
383
+
384
+ assert len(timestamps) == 8
385
+
386
+ intervals = [next - previous for previous, next in zip(timestamps, timestamps[1:])]
387
+ total = timedelta(seconds=sum(i.total_seconds() for i in intervals))
388
+ average = total / len(intervals)
389
+
390
+ # even with a variable duration, Docket attempts to schedule them equally
391
+ assert timedelta(milliseconds=45) <= average <= timedelta(milliseconds=70)
392
+
393
+
394
+ async def test_worker_can_exit_from_perpetual_tasks_that_queue_further_tasks(
395
+ docket: Docket, worker: Worker
396
+ ):
397
+ """A worker can exit if it's processing a perpetual task that queues more tasks"""
398
+
399
+ inner_calls = 0
400
+
401
+ async def inner_task():
402
+ nonlocal inner_calls
403
+ inner_calls += 1
404
+
405
+ async def perpetual_task(
406
+ docket: Docket = CurrentDocket(),
407
+ perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
408
+ ):
409
+ await docket.add(inner_task)()
410
+ await docket.add(inner_task)()
411
+
412
+ execution = await docket.add(perpetual_task)()
413
+
414
+ await worker.run_at_most({execution.key: 3})
415
+
416
+ assert inner_calls == 6
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