pydocket 0.0.2__py3-none-any.whl → 0.1.1__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,15 +1,21 @@
1
1
  import asyncio
2
2
  import enum
3
+ import importlib
3
4
  import logging
5
+ import os
4
6
  import socket
5
7
  import sys
6
- from datetime import timedelta
7
- from typing import Annotated
8
+ from datetime import datetime, timedelta, timezone
9
+ from functools import partial
10
+ from typing import Annotated, Any, Collection
8
11
 
9
12
  import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
10
15
 
11
16
  from . import __version__, tasks
12
- from .docket import Docket
17
+ from .docket import Docket, DocketSnapshot, WorkerInfo
18
+ from .execution import Operator
13
19
  from .worker import Worker
14
20
 
15
21
  app: typer.Typer = typer.Typer(
@@ -33,6 +39,14 @@ class LogFormat(enum.StrEnum):
33
39
  JSON = "json"
34
40
 
35
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
+
36
50
  def duration(duration_str: str | timedelta) -> timedelta:
37
51
  """
38
52
  Parse a duration string into a timedelta.
@@ -100,6 +114,41 @@ def set_logging_level(level: LogLevel) -> None:
100
114
  logging.getLogger().setLevel(level)
101
115
 
102
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)
143
+
144
+
145
+ @app.command(
146
+ help="Print the version of docket",
147
+ )
148
+ def version() -> None:
149
+ print(__version__)
150
+
151
+
103
152
  @app.command(
104
153
  help="Start a worker to process tasks",
105
154
  )
@@ -136,7 +185,7 @@ def worker(
136
185
  help="The name of the worker",
137
186
  envvar="DOCKET_WORKER_NAME",
138
187
  ),
139
- ] = socket.gethostname(),
188
+ ] = default_worker_name(),
140
189
  logging_level: Annotated[
141
190
  LogLevel,
142
191
  typer.Option(
@@ -153,11 +202,11 @@ def worker(
153
202
  callback=set_logging_format,
154
203
  ),
155
204
  ] = LogFormat.RICH if sys.stdout.isatty() else LogFormat.PLAIN,
156
- prefetch_count: Annotated[
205
+ concurrency: Annotated[
157
206
  int,
158
207
  typer.Option(
159
- help="The number of tasks to request from the docket at a time",
160
- envvar="DOCKET_WORKER_PREFETCH_COUNT",
208
+ help="The maximum number of tasks to process concurrently",
209
+ envvar="DOCKET_WORKER_CONCURRENCY",
161
210
  ),
162
211
  ] = 10,
163
212
  redelivery_timeout: Annotated[
@@ -192,7 +241,7 @@ def worker(
192
241
  docket_name=docket_,
193
242
  url=url,
194
243
  name=name,
195
- prefetch_count=prefetch_count,
244
+ concurrency=concurrency,
196
245
  redelivery_timeout=redelivery_timeout,
197
246
  reconnection_delay=reconnection_delay,
198
247
  until_finished=until_finished,
@@ -201,7 +250,139 @@ def worker(
201
250
  )
202
251
 
203
252
 
204
- @app.command(help="Adds a trace task to the Docket")
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"Restoring {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")
205
386
  def trace(
206
387
  docket_: Annotated[
207
388
  str,
@@ -224,21 +405,104 @@ def trace(
224
405
  help="The message to print",
225
406
  ),
226
407
  ] = "Howdy!",
227
- error: Annotated[
228
- bool,
408
+ delay: Annotated[
409
+ timedelta,
229
410
  typer.Option(
230
- "--error",
231
- help="Intentionally raise an error",
411
+ parser=duration,
412
+ help="The delay before the task is added to the docket",
232
413
  ),
233
- ] = False,
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),
234
458
  ) -> None:
235
459
  async def run() -> None:
236
460
  async with Docket(name=docket_, url=url) as docket:
237
- if error:
238
- execution = await docket.add(tasks.fail)(message)
239
- else:
240
- execution = await docket.add(tasks.trace)(message)
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
+
241
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)
242
506
  print(
243
507
  f"Added {execution.function.__name__} task {execution.key!r} to "
244
508
  f"the docket {docket.name!r}"
@@ -247,8 +511,179 @@ def trace(
247
511
  asyncio.run(run())
248
512
 
249
513
 
250
- @app.command(
251
- help="Print the version of Docket",
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
252
592
  )
253
- def version() -> None:
254
- print(__version__)
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)
docket/dependencies.py CHANGED
@@ -59,7 +59,7 @@ def TaskKey() -> str:
59
59
  class _TaskLogger(Dependency):
60
60
  def __call__(
61
61
  self, docket: Docket, worker: Worker, execution: Execution
62
- ) -> logging.LoggerAdapter:
62
+ ) -> logging.LoggerAdapter[logging.Logger]:
63
63
  logger = logging.getLogger(f"docket.task.{execution.function.__name__}")
64
64
 
65
65
  extra = {