pydocket 0.0.1__py3-none-any.whl → 0.1.0__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/cli.py CHANGED
@@ -1,6 +1,22 @@
1
+ import asyncio
2
+ import enum
3
+ import importlib
4
+ import logging
5
+ import os
6
+ import socket
7
+ import sys
8
+ from datetime import datetime, timedelta, timezone
9
+ from functools import partial
10
+ from typing import Annotated, Any, Collection
11
+
1
12
  import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
2
15
 
3
- from docket import __version__
16
+ from . import __version__, tasks
17
+ from .docket import Docket, DocketSnapshot, WorkerInfo
18
+ from .execution import Operator
19
+ from .worker import Worker
4
20
 
5
21
  app: typer.Typer = typer.Typer(
6
22
  help="Docket - A distributed background task system for Python functions",
@@ -9,15 +25,665 @@ app: typer.Typer = typer.Typer(
9
25
  )
10
26
 
11
27
 
12
- @app.command(
13
- help="Start a worker to process tasks",
14
- )
15
- def worker() -> None:
16
- print("TODO: start the worker")
28
+ class LogLevel(enum.StrEnum):
29
+ DEBUG = "DEBUG"
30
+ INFO = "INFO"
31
+ WARNING = "WARNING"
32
+ ERROR = "ERROR"
33
+ CRITICAL = "CRITICAL"
34
+
35
+
36
+ class LogFormat(enum.StrEnum):
37
+ RICH = "rich"
38
+ PLAIN = "plain"
39
+ JSON = "json"
40
+
41
+
42
+ def local_time(when: datetime) -> str:
43
+ return when.astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
44
+
45
+
46
+ def default_worker_name() -> str:
47
+ return f"{socket.gethostname()}#{os.getpid()}"
48
+
49
+
50
+ def duration(duration_str: str | timedelta) -> timedelta:
51
+ """
52
+ Parse a duration string into a timedelta.
53
+
54
+ Supported formats:
55
+ - 123 = 123 seconds
56
+ - 123s = 123 seconds
57
+ - 123m = 123 minutes
58
+ - 123h = 123 hours
59
+ - 00:00 = mm:ss
60
+ - 00:00:00 = hh:mm:ss
61
+ """
62
+ if isinstance(duration_str, timedelta):
63
+ return duration_str
64
+
65
+ if ":" in duration_str:
66
+ parts = duration_str.split(":")
67
+ if len(parts) == 2: # mm:ss
68
+ minutes, seconds = map(int, parts)
69
+ return timedelta(minutes=minutes, seconds=seconds)
70
+ elif len(parts) == 3: # hh:mm:ss
71
+ hours, minutes, seconds = map(int, parts)
72
+ return timedelta(hours=hours, minutes=minutes, seconds=seconds)
73
+ else:
74
+ raise ValueError(f"Invalid duration string: {duration_str}")
75
+ elif duration_str.endswith("s"):
76
+ return timedelta(seconds=int(duration_str[:-1]))
77
+ elif duration_str.endswith("m"):
78
+ return timedelta(minutes=int(duration_str[:-1]))
79
+ elif duration_str.endswith("h"):
80
+ return timedelta(hours=int(duration_str[:-1]))
81
+ else:
82
+ return timedelta(seconds=int(duration_str))
83
+
84
+
85
+ def set_logging_format(format: LogFormat) -> None:
86
+ root_logger = logging.getLogger()
87
+ if format == LogFormat.JSON:
88
+ from pythonjsonlogger.json import JsonFormatter
89
+
90
+ formatter = JsonFormatter(
91
+ "{name}{asctime}{levelname}{message}{exc_info}", style="{"
92
+ )
93
+ handler = logging.StreamHandler(stream=sys.stdout)
94
+ handler.setFormatter(formatter)
95
+ root_logger.addHandler(handler)
96
+ elif format == LogFormat.PLAIN:
97
+ handler = logging.StreamHandler(stream=sys.stdout)
98
+ formatter = logging.Formatter(
99
+ "[%(asctime)s] %(levelname)s - %(name)s - %(message)s",
100
+ datefmt="%Y-%m-%d %H:%M:%S",
101
+ )
102
+ handler.setFormatter(formatter)
103
+ root_logger.addHandler(handler)
104
+ else:
105
+ from rich.logging import RichHandler
106
+
107
+ handler = RichHandler()
108
+ formatter = logging.Formatter("%(message)s", datefmt="[%X]")
109
+ handler.setFormatter(formatter)
110
+ root_logger.addHandler(handler)
111
+
112
+
113
+ def set_logging_level(level: LogLevel) -> None:
114
+ logging.getLogger().setLevel(level)
115
+
116
+
117
+ def handle_strike_wildcard(value: str) -> str | None:
118
+ if value in ("", "*"):
119
+ return None
120
+ return value
121
+
122
+
123
+ def interpret_python_value(value: str | None) -> Any:
124
+ if value is None:
125
+ return None
126
+
127
+ type, _, value = value.rpartition(":")
128
+ if not type:
129
+ # without a type hint, we assume the value is a string
130
+ return value
131
+
132
+ module_name, _, member_name = type.rpartition(".")
133
+ module = importlib.import_module(module_name or "builtins")
134
+ member = getattr(module, member_name)
135
+
136
+ # special cases for common useful types
137
+ if member is timedelta:
138
+ return timedelta(seconds=int(value))
139
+ elif member is bool:
140
+ return value.lower() == "true"
141
+ else:
142
+ return member(value)
17
143
 
18
144
 
19
145
  @app.command(
20
- help="Print the version of Docket",
146
+ help="Print the version of docket",
21
147
  )
22
148
  def version() -> None:
23
149
  print(__version__)
150
+
151
+
152
+ @app.command(
153
+ help="Start a worker to process tasks",
154
+ )
155
+ def worker(
156
+ tasks: Annotated[
157
+ list[str],
158
+ typer.Option(
159
+ "--tasks",
160
+ help=(
161
+ "The dotted path of a task collection to register with the docket. "
162
+ "This can be specified multiple times. A task collection is any "
163
+ "iterable of async functions."
164
+ ),
165
+ ),
166
+ ] = ["docket.tasks:standard_tasks"],
167
+ docket_: Annotated[
168
+ str,
169
+ typer.Option(
170
+ "--docket",
171
+ help="The name of the docket",
172
+ envvar="DOCKET_NAME",
173
+ ),
174
+ ] = "docket",
175
+ url: Annotated[
176
+ str,
177
+ typer.Option(
178
+ help="The URL of the Redis server",
179
+ envvar="DOCKET_URL",
180
+ ),
181
+ ] = "redis://localhost:6379/0",
182
+ name: Annotated[
183
+ str | None,
184
+ typer.Option(
185
+ help="The name of the worker",
186
+ envvar="DOCKET_WORKER_NAME",
187
+ ),
188
+ ] = default_worker_name(),
189
+ logging_level: Annotated[
190
+ LogLevel,
191
+ typer.Option(
192
+ help="The logging level",
193
+ envvar="DOCKET_LOGGING_LEVEL",
194
+ callback=set_logging_level,
195
+ ),
196
+ ] = LogLevel.INFO,
197
+ logging_format: Annotated[
198
+ LogFormat,
199
+ typer.Option(
200
+ help="The logging format",
201
+ envvar="DOCKET_LOGGING_FORMAT",
202
+ callback=set_logging_format,
203
+ ),
204
+ ] = LogFormat.RICH if sys.stdout.isatty() else LogFormat.PLAIN,
205
+ concurrency: Annotated[
206
+ int,
207
+ typer.Option(
208
+ help="The maximum number of tasks to process concurrently",
209
+ envvar="DOCKET_WORKER_CONCURRENCY",
210
+ ),
211
+ ] = 10,
212
+ redelivery_timeout: Annotated[
213
+ timedelta,
214
+ typer.Option(
215
+ parser=duration,
216
+ help="How long to wait before redelivering a task to another worker",
217
+ envvar="DOCKET_WORKER_REDELIVERY_TIMEOUT",
218
+ ),
219
+ ] = timedelta(minutes=5),
220
+ reconnection_delay: Annotated[
221
+ timedelta,
222
+ typer.Option(
223
+ parser=duration,
224
+ help=(
225
+ "How long to wait before reconnecting to the Redis server after "
226
+ "a connection error"
227
+ ),
228
+ envvar="DOCKET_WORKER_RECONNECTION_DELAY",
229
+ ),
230
+ ] = timedelta(seconds=5),
231
+ until_finished: Annotated[
232
+ bool,
233
+ typer.Option(
234
+ "--until-finished",
235
+ help="Exit after the current docket is finished",
236
+ ),
237
+ ] = False,
238
+ ) -> None:
239
+ asyncio.run(
240
+ Worker.run(
241
+ docket_name=docket_,
242
+ url=url,
243
+ name=name,
244
+ concurrency=concurrency,
245
+ redelivery_timeout=redelivery_timeout,
246
+ reconnection_delay=reconnection_delay,
247
+ until_finished=until_finished,
248
+ tasks=tasks,
249
+ )
250
+ )
251
+
252
+
253
+ @app.command(help="Strikes a task or parameters from the docket")
254
+ def strike(
255
+ function: Annotated[
256
+ str,
257
+ typer.Argument(
258
+ help="The function to strike",
259
+ callback=handle_strike_wildcard,
260
+ ),
261
+ ] = "*",
262
+ parameter: Annotated[
263
+ str,
264
+ typer.Argument(
265
+ help="The parameter to strike",
266
+ callback=handle_strike_wildcard,
267
+ ),
268
+ ] = "*",
269
+ operator: Annotated[
270
+ Operator,
271
+ typer.Argument(
272
+ help="The operator to compare the value against",
273
+ ),
274
+ ] = Operator.EQUAL,
275
+ value: Annotated[
276
+ str | None,
277
+ typer.Argument(
278
+ help="The value to strike from the docket",
279
+ ),
280
+ ] = None,
281
+ docket_: Annotated[
282
+ str,
283
+ typer.Option(
284
+ "--docket",
285
+ help="The name of the docket",
286
+ envvar="DOCKET_NAME",
287
+ ),
288
+ ] = "docket",
289
+ url: Annotated[
290
+ str,
291
+ typer.Option(
292
+ help="The URL of the Redis server",
293
+ envvar="DOCKET_URL",
294
+ ),
295
+ ] = "redis://localhost:6379/0",
296
+ ) -> None:
297
+ if not function and not parameter:
298
+ raise typer.BadParameter(
299
+ message="Must provide either a function and/or a parameter",
300
+ )
301
+
302
+ value_ = interpret_python_value(value)
303
+ if parameter:
304
+ function_name = f"{function or '(all tasks)'}"
305
+ print(f"Striking {function_name} {parameter} {operator} {value_!r}")
306
+ else:
307
+ print(f"Striking {function}")
308
+
309
+ async def run() -> None:
310
+ async with Docket(name=docket_, url=url) as docket:
311
+ await docket.strike(function, parameter, operator, value_)
312
+
313
+ asyncio.run(run())
314
+
315
+
316
+ @app.command(help="Restores a task or parameters to the Docket")
317
+ def restore(
318
+ function: Annotated[
319
+ str,
320
+ typer.Argument(
321
+ help="The function to restore",
322
+ callback=handle_strike_wildcard,
323
+ ),
324
+ ] = "*",
325
+ parameter: Annotated[
326
+ str,
327
+ typer.Argument(
328
+ help="The parameter to restore",
329
+ callback=handle_strike_wildcard,
330
+ ),
331
+ ] = "*",
332
+ operator: Annotated[
333
+ Operator,
334
+ typer.Argument(
335
+ help="The operator to compare the value against",
336
+ ),
337
+ ] = Operator.EQUAL,
338
+ value: Annotated[
339
+ str | None,
340
+ typer.Argument(
341
+ help="The value to restore to the docket",
342
+ ),
343
+ ] = None,
344
+ docket_: Annotated[
345
+ str,
346
+ typer.Option(
347
+ "--docket",
348
+ help="The name of the docket",
349
+ envvar="DOCKET_NAME",
350
+ ),
351
+ ] = "docket",
352
+ url: Annotated[
353
+ str,
354
+ typer.Option(
355
+ help="The URL of the Redis server",
356
+ envvar="DOCKET_URL",
357
+ ),
358
+ ] = "redis://localhost:6379/0",
359
+ ) -> None:
360
+ if not function and not parameter:
361
+ raise typer.BadParameter(
362
+ message="Must provide either a function and/or a parameter",
363
+ )
364
+
365
+ value_ = interpret_python_value(value)
366
+ if parameter:
367
+ function_name = f"{function or '(all tasks)'}"
368
+ print(f"Striking {function_name} {parameter} {operator} {value_!r}")
369
+ else:
370
+ print(f"Restoring {function}")
371
+
372
+ async def run() -> None:
373
+ async with Docket(name=docket_, url=url) as docket:
374
+ await docket.restore(function, parameter, operator, value_)
375
+
376
+ asyncio.run(run())
377
+
378
+
379
+ tasks_app: typer.Typer = typer.Typer(
380
+ help="Run docket's built-in tasks", no_args_is_help=True
381
+ )
382
+ app.add_typer(tasks_app, name="tasks")
383
+
384
+
385
+ @tasks_app.command(help="Adds a trace task to the Docket")
386
+ def trace(
387
+ docket_: Annotated[
388
+ str,
389
+ typer.Option(
390
+ "--docket",
391
+ help="The name of the docket",
392
+ envvar="DOCKET_NAME",
393
+ ),
394
+ ] = "docket",
395
+ url: Annotated[
396
+ str,
397
+ typer.Option(
398
+ help="The URL of the Redis server",
399
+ envvar="DOCKET_URL",
400
+ ),
401
+ ] = "redis://localhost:6379/0",
402
+ message: Annotated[
403
+ str,
404
+ typer.Argument(
405
+ help="The message to print",
406
+ ),
407
+ ] = "Howdy!",
408
+ delay: Annotated[
409
+ timedelta,
410
+ typer.Option(
411
+ parser=duration,
412
+ help="The delay before the task is added to the docket",
413
+ ),
414
+ ] = timedelta(seconds=0),
415
+ ) -> None:
416
+ async def run() -> None:
417
+ async with Docket(name=docket_, url=url) as docket:
418
+ when = datetime.now(timezone.utc) + delay
419
+ execution = await docket.add(tasks.trace, when)(message)
420
+ print(
421
+ f"Added {execution.function.__name__} task {execution.key!r} to "
422
+ f"the docket {docket.name!r}"
423
+ )
424
+
425
+ asyncio.run(run())
426
+
427
+
428
+ @tasks_app.command(help="Adds a fail task to the Docket")
429
+ def fail(
430
+ docket_: Annotated[
431
+ str,
432
+ typer.Option(
433
+ "--docket",
434
+ help="The name of the docket",
435
+ envvar="DOCKET_NAME",
436
+ ),
437
+ ] = "docket",
438
+ url: Annotated[
439
+ str,
440
+ typer.Option(
441
+ help="The URL of the Redis server",
442
+ envvar="DOCKET_URL",
443
+ ),
444
+ ] = "redis://localhost:6379/0",
445
+ message: Annotated[
446
+ str,
447
+ typer.Argument(
448
+ help="The message to print",
449
+ ),
450
+ ] = "Howdy!",
451
+ delay: Annotated[
452
+ timedelta,
453
+ typer.Option(
454
+ parser=duration,
455
+ help="The delay before the task is added to the docket",
456
+ ),
457
+ ] = timedelta(seconds=0),
458
+ ) -> None:
459
+ async def run() -> None:
460
+ async with Docket(name=docket_, url=url) as docket:
461
+ when = datetime.now(timezone.utc) + delay
462
+ execution = await docket.add(tasks.fail, when)(message)
463
+ print(
464
+ f"Added {execution.function.__name__} task {execution.key!r} to "
465
+ f"the docket {docket.name!r}"
466
+ )
467
+
468
+ asyncio.run(run())
469
+
470
+
471
+ @tasks_app.command(help="Adds a sleep task to the Docket")
472
+ def sleep(
473
+ docket_: Annotated[
474
+ str,
475
+ typer.Option(
476
+ "--docket",
477
+ help="The name of the docket",
478
+ envvar="DOCKET_NAME",
479
+ ),
480
+ ] = "docket",
481
+ url: Annotated[
482
+ str,
483
+ typer.Option(
484
+ help="The URL of the Redis server",
485
+ envvar="DOCKET_URL",
486
+ ),
487
+ ] = "redis://localhost:6379/0",
488
+ seconds: Annotated[
489
+ float,
490
+ typer.Argument(
491
+ help="The number of seconds to sleep",
492
+ ),
493
+ ] = 1,
494
+ delay: Annotated[
495
+ timedelta,
496
+ typer.Option(
497
+ parser=duration,
498
+ help="The delay before the task is added to the docket",
499
+ ),
500
+ ] = timedelta(seconds=0),
501
+ ) -> None:
502
+ async def run() -> None:
503
+ async with Docket(name=docket_, url=url) as docket:
504
+ when = datetime.now(timezone.utc) + delay
505
+ execution = await docket.add(tasks.sleep, when)(seconds)
506
+ print(
507
+ f"Added {execution.function.__name__} task {execution.key!r} to "
508
+ f"the docket {docket.name!r}"
509
+ )
510
+
511
+ asyncio.run(run())
512
+
513
+
514
+ def relative_time(now: datetime, when: datetime) -> str:
515
+ delta = now - when
516
+ if delta < -timedelta(minutes=30):
517
+ return f"at {local_time(when)}"
518
+ elif delta < timedelta(0):
519
+ return f"in {-delta}"
520
+ elif delta < timedelta(minutes=30):
521
+ return f"{delta} ago"
522
+ else:
523
+ return f"at {local_time(when)}"
524
+
525
+
526
+ @app.command(help="Shows a snapshot of what's on the docket right now")
527
+ def snapshot(
528
+ docket_: Annotated[
529
+ str,
530
+ typer.Option(
531
+ "--docket",
532
+ help="The name of the docket",
533
+ envvar="DOCKET_NAME",
534
+ ),
535
+ ] = "docket",
536
+ url: Annotated[
537
+ str,
538
+ typer.Option(
539
+ help="The URL of the Redis server",
540
+ envvar="DOCKET_URL",
541
+ ),
542
+ ] = "redis://localhost:6379/0",
543
+ ) -> None:
544
+ async def run() -> DocketSnapshot:
545
+ async with Docket(name=docket_, url=url) as docket:
546
+ return await docket.snapshot()
547
+
548
+ snapshot = asyncio.run(run())
549
+
550
+ relative = partial(relative_time, snapshot.taken)
551
+
552
+ console = Console()
553
+
554
+ summary_lines = [
555
+ f"Docket: {docket_!r}",
556
+ f"as of {local_time(snapshot.taken)}",
557
+ (
558
+ f"{len(snapshot.workers)} workers, "
559
+ f"{len(snapshot.running)}/{snapshot.total_tasks} running"
560
+ ),
561
+ ]
562
+ table = Table(title="\n".join(summary_lines))
563
+ table.add_column("When", style="green")
564
+ table.add_column("Function", style="cyan")
565
+ table.add_column("Key", style="cyan")
566
+ table.add_column("Worker", style="yellow")
567
+ table.add_column("Started", style="green")
568
+
569
+ for execution in snapshot.running:
570
+ table.add_row(
571
+ relative(execution.when),
572
+ execution.function.__name__,
573
+ execution.key,
574
+ execution.worker,
575
+ relative(execution.started),
576
+ )
577
+
578
+ for execution in snapshot.future:
579
+ table.add_row(
580
+ relative(execution.when),
581
+ execution.function.__name__,
582
+ execution.key,
583
+ "",
584
+ "",
585
+ )
586
+
587
+ console.print(table)
588
+
589
+
590
+ workers_app: typer.Typer = typer.Typer(
591
+ help="Look at the workers on a docket", no_args_is_help=True
592
+ )
593
+ app.add_typer(workers_app, name="workers")
594
+
595
+
596
+ def print_workers(
597
+ docket_name: str,
598
+ workers: Collection[WorkerInfo],
599
+ highlight_task: str | None = None,
600
+ ) -> None:
601
+ sorted_workers = sorted(workers, key=lambda w: w.last_seen, reverse=True)
602
+
603
+ table = Table(title=f"Workers in Docket: {docket_name}")
604
+
605
+ table.add_column("Name", style="cyan")
606
+ table.add_column("Last Seen", style="green")
607
+ table.add_column("Tasks", style="yellow")
608
+
609
+ now = datetime.now(timezone.utc)
610
+
611
+ for worker in sorted_workers:
612
+ time_ago = now - worker.last_seen
613
+
614
+ tasks = [
615
+ f"[bold]{task}[/bold]" if task == highlight_task else task
616
+ for task in sorted(worker.tasks)
617
+ ]
618
+
619
+ table.add_row(
620
+ worker.name,
621
+ f"{time_ago} ago",
622
+ "\n".join(tasks) if tasks else "(none)",
623
+ )
624
+
625
+ console = Console()
626
+ console.print(table)
627
+
628
+
629
+ @workers_app.command(name="ls", help="List all workers on the docket")
630
+ def list_workers(
631
+ docket_: Annotated[
632
+ str,
633
+ typer.Option(
634
+ "--docket",
635
+ help="The name of the docket",
636
+ envvar="DOCKET_NAME",
637
+ ),
638
+ ] = "docket",
639
+ url: Annotated[
640
+ str,
641
+ typer.Option(
642
+ help="The URL of the Redis server",
643
+ envvar="DOCKET_URL",
644
+ ),
645
+ ] = "redis://localhost:6379/0",
646
+ ) -> None:
647
+ async def run() -> Collection[WorkerInfo]:
648
+ async with Docket(name=docket_, url=url) as docket:
649
+ return await docket.workers()
650
+
651
+ workers = asyncio.run(run())
652
+
653
+ print_workers(docket_, workers)
654
+
655
+
656
+ @workers_app.command(
657
+ name="for-task",
658
+ help="List the workers on the docket that can process a certain task",
659
+ )
660
+ def workers_for_task(
661
+ task: Annotated[
662
+ str,
663
+ typer.Argument(
664
+ help="The name of the task",
665
+ ),
666
+ ],
667
+ docket_: Annotated[
668
+ str,
669
+ typer.Option(
670
+ "--docket",
671
+ help="The name of the docket",
672
+ envvar="DOCKET_NAME",
673
+ ),
674
+ ] = "docket",
675
+ url: Annotated[
676
+ str,
677
+ typer.Option(
678
+ help="The URL of the Redis server",
679
+ envvar="DOCKET_URL",
680
+ ),
681
+ ] = "redis://localhost:6379/0",
682
+ ) -> None:
683
+ async def run() -> Collection[WorkerInfo]:
684
+ async with Docket(name=docket_, url=url) as docket:
685
+ return await docket.task_workers(task)
686
+
687
+ workers = asyncio.run(run())
688
+
689
+ print_workers(docket_, workers, highlight_task=task)