flexexecutor 0.0.11__tar.gz → 0.0.12__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.
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: flexexecutor
3
- Version: 0.0.11
3
+ Version: 0.0.12
4
4
  Summary: Executors for both async and sync functions and automatically scales the number of workers
5
- Home-page: https://github.com/leavers/flexexecutor
6
5
  Author: leavers
7
6
  Author-Email: Leavers <leavers930@gmail.com>
8
7
  License: MIT
@@ -11,17 +10,16 @@ Classifier: License :: OSI Approved :: MIT License
11
10
  Classifier: Operating System :: OS Independent
12
11
  Classifier: Programming Language :: Python
13
12
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.8
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Programming Language :: Python :: 3.10
17
13
  Classifier: Programming Language :: Python :: 3.11
18
14
  Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
19
17
  Classifier: Topic :: Software Development
20
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
19
  Classifier: Topic :: Utilities
22
- Project-URL: Homepage, https://github.com/leavers/flexexecutor
23
- Project-URL: Documentation, https://github.com/leavers/flexexecutor/blob/main/README.md
24
- Requires-Python: >=3.8
20
+ Project-URL: homepage, https://github.com/leavers/flexexecutor
21
+ Project-URL: documentation, https://github.com/leavers/flexexecutor/blob/main/README.md
22
+ Requires-Python: >=3.11
25
23
  Description-Content-Type: text/markdown
26
24
 
27
25
  # Flexexecutor
@@ -0,0 +1,811 @@
1
+ """
2
+ Flexexecutor provides executors that can automatically scale the number of
3
+ workers up and down.
4
+
5
+ Copyright (c) 2020-2026, Leavers.
6
+ License: MIT
7
+ """
8
+
9
+ import asyncio
10
+ import atexit
11
+ import itertools
12
+ import queue
13
+ import types
14
+ from asyncio import AbstractEventLoop
15
+ from asyncio import Queue as AsyncQueue
16
+ from asyncio.timeouts import timeout
17
+ from concurrent.futures import Future, ProcessPoolExecutor, _base
18
+ from concurrent.futures.thread import BrokenThreadPool
19
+ from inspect import iscoroutinefunction
20
+ from queue import Empty
21
+ from threading import Event, Lock, Semaphore, Thread
22
+ from time import monotonic
23
+ from typing import TYPE_CHECKING
24
+ from weakref import WeakKeyDictionary, ref, WeakSet
25
+
26
+ if TYPE_CHECKING:
27
+ from typing_extensions import Callable, ParamSpec, TypeVar
28
+
29
+ P = ParamSpec("P")
30
+ T = TypeVar("T")
31
+
32
+ __all__ = (
33
+ "__version__",
34
+ "AsyncPoolExecutor",
35
+ "AsyncWorkerContext",
36
+ "BrokenThreadPool",
37
+ "Future",
38
+ "ProcessPoolExecutor",
39
+ "ThreadPoolExecutor",
40
+ "WorkerContext",
41
+ )
42
+
43
+ __version__ = "0.0.12"
44
+
45
+ # Type alias for async component (loop, queue) tuple
46
+ _AsyncComponent = tuple[AbstractEventLoop, "AsyncQueue[_AsyncWorkItem | None]"]
47
+
48
+ _threads_queues: WeakKeyDictionary[Thread, queue.SimpleQueue] = WeakKeyDictionary()
49
+ _async_executors: WeakSet["AsyncPoolExecutor"] = WeakSet()
50
+ _shutdown = False
51
+ _global_shutdown_lock = Lock()
52
+
53
+
54
+ def _python_exit():
55
+ global _shutdown
56
+ with _global_shutdown_lock:
57
+ _shutdown = True
58
+
59
+ threads_items = list(_threads_queues.items())
60
+ for t, q in threads_items:
61
+ q.put(None)
62
+ for t, q in threads_items:
63
+ t.join()
64
+
65
+ for e in _async_executors:
66
+ e.shutdown()
67
+
68
+
69
+ atexit.register(_python_exit)
70
+
71
+
72
+ class WorkerContext:
73
+ """Worker context for thread pool executor.
74
+
75
+ This class provides a way to customize worker thread initialization,
76
+ task execution, and cleanup. It is designed to be compatible with
77
+ Python 3.10+ and follows the design of Python 3.14's ThreadPoolExecutor.
78
+
79
+ Example:
80
+ class DatabaseContext(WorkerContext):
81
+ def initialize(self):
82
+ self.db = create_db_connection()
83
+
84
+ def finalize(self):
85
+ self.db.close()
86
+
87
+ def run(self, task):
88
+ fn, args, kwargs = task
89
+ return fn(self.db, *args, **kwargs)
90
+ """
91
+
92
+ @classmethod
93
+ def prepare(
94
+ cls, initializer: "Callable | None" = None, initargs: tuple = ()
95
+ ) -> tuple["Callable[[], WorkerContext]", "Callable[..., tuple]"]:
96
+ """Prepare context factory and task resolver.
97
+
98
+ Args:
99
+ initializer: A callable used to initialize worker threads.
100
+ initargs: A tuple of arguments to pass to the initializer.
101
+
102
+ Returns:
103
+ A tuple of (create_context, resolve_task) functions.
104
+ """
105
+ if initializer is not None:
106
+ if not callable(initializer):
107
+ raise TypeError("initializer must be a callable")
108
+
109
+ def create_context() -> "WorkerContext":
110
+ return cls(initializer, initargs)
111
+
112
+ def resolve_task(fn, args, kwargs) -> tuple:
113
+ return (fn, args, kwargs)
114
+
115
+ return create_context, resolve_task
116
+
117
+ def __init__(self, initializer: "Callable | None" = None, initargs: tuple = ()):
118
+ self.initializer = initializer
119
+ self.initargs = initargs
120
+
121
+ def initialize(self) -> None:
122
+ """Initialize the worker context.
123
+
124
+ Called once when the worker thread starts.
125
+ """
126
+ if self.initializer is not None:
127
+ self.initializer(*self.initargs)
128
+
129
+ def finalize(self) -> None:
130
+ """Finalize the worker context.
131
+
132
+ Called once when the worker thread exits.
133
+ """
134
+
135
+ def run(self, task: tuple) -> "T":
136
+ """Execute a task.
137
+
138
+ Args:
139
+ task: A tuple of (fn, args, kwargs).
140
+
141
+ Returns:
142
+ The result of the task execution.
143
+ """
144
+ fn, args, kwargs = task
145
+ return fn(*args, **kwargs)
146
+
147
+
148
+ class _WorkItem:
149
+ """A work item for the thread pool executor."""
150
+
151
+ def __init__(self, future: Future, task: tuple):
152
+ self.future = future
153
+ self.task = task
154
+
155
+ def run(self, ctx: WorkerContext) -> None:
156
+ if not self.future.set_running_or_notify_cancel():
157
+ return
158
+
159
+ try:
160
+ result = ctx.run(self.task)
161
+ except BaseException as exc:
162
+ self.future.set_exception(exc)
163
+ # Break a reference cycle with the exception 'exc'
164
+ self = None # noqa: F841
165
+ else:
166
+ self.future.set_result(result)
167
+
168
+ __class_getitem__ = classmethod(types.GenericAlias)
169
+
170
+
171
+ def _worker(
172
+ executor_ref,
173
+ work_queue: queue.SimpleQueue,
174
+ create_context: "Callable[[], WorkerContext]",
175
+ idle_timeout: float,
176
+ ):
177
+ """Worker function for the thread pool executor with idle timeout support."""
178
+ ctx = create_context()
179
+ try:
180
+ ctx.initialize()
181
+ except BaseException:
182
+ _base.LOGGER.critical("Exception in initializer:", exc_info=True)
183
+ executor = executor_ref()
184
+ if executor is not None: # pragma: no cover
185
+ executor._initializer_failed()
186
+ return
187
+
188
+ idle_tick = monotonic()
189
+ try:
190
+ while True:
191
+ try:
192
+ work_item = work_queue.get(block=True, timeout=0.1)
193
+ except Empty:
194
+ # Check idle timeout
195
+ if idle_timeout >= 0 and monotonic() - idle_tick > idle_timeout:
196
+ break
197
+ continue
198
+
199
+ if work_item is None:
200
+ break
201
+
202
+ work_item.run(ctx)
203
+ del work_item
204
+
205
+ executor = executor_ref()
206
+ if executor is not None:
207
+ executor._idle_semaphore.release()
208
+ del executor
209
+ idle_tick = monotonic()
210
+ finally:
211
+ ctx.finalize()
212
+ executor = executor_ref()
213
+ if executor is None:
214
+ work_queue.put(None)
215
+ else:
216
+ executor._idle_semaphore.acquire(timeout=0)
217
+ if _shutdown or executor._shutdown:
218
+ executor._shutdown = True
219
+ work_queue.put(None)
220
+ del executor
221
+
222
+
223
+ class ThreadPoolExecutor(_base.Executor):
224
+ """A thread pool executor with automatic worker scaling and idle timeout.
225
+
226
+ This implementation is compatible with Python 3.11-3.14 and does not depend
227
+ on internal CPython APIs.
228
+ """
229
+
230
+ _counter = itertools.count().__next__
231
+
232
+ @classmethod
233
+ def prepare_context(
234
+ cls, initializer: "Callable | None" = None, initargs: tuple = ()
235
+ ) -> tuple["Callable[[], WorkerContext]", "Callable[..., tuple]"]:
236
+ """Prepare context factory and task resolver.
237
+
238
+ This class method can be overridden to provide a custom WorkerContext.
239
+
240
+ Args:
241
+ initializer: A callable used to initialize worker threads.
242
+ initargs: A tuple of arguments to pass to the initializer.
243
+
244
+ Returns:
245
+ A tuple of (create_context, resolve_task) functions.
246
+ """
247
+ return WorkerContext.prepare(initializer, initargs)
248
+
249
+ def __init__(
250
+ self,
251
+ max_workers: int | None = 1024,
252
+ thread_name_prefix: str = "",
253
+ initializer=None,
254
+ initargs=(),
255
+ idle_timeout: float | None = 60.0,
256
+ ):
257
+ """Initializes a new ThreadPoolExecutor instance.
258
+
259
+ :type max_workers: int, optional
260
+ :param max_workers: The maximum number of workers to create. Defaults to 1024.
261
+
262
+ :type thread_name_prefix: str, optional
263
+ :param thread_name_prefix: An optional name prefix to give our threads.
264
+
265
+ :type initializer: callable, optional
266
+ :param initializer: A callable used to initialize worker threads.
267
+
268
+ :type initargs: tuple, optional
269
+ :param initargs: A tuple of arguments to pass to the initializer.
270
+
271
+ :type idle_timeout: float, optional
272
+ :param idle_timeout: The maximum amount of time (in seconds) that a worker
273
+ thread can remain idle before it is terminated. If set to None or negative
274
+ value, workers will never be terminated. Defaults to 60 seconds.
275
+ """
276
+ if max_workers is None:
277
+ max_workers = 1024
278
+ if max_workers <= 0:
279
+ raise ValueError("max_workers must be greater than 0")
280
+
281
+ self._max_workers = max_workers
282
+ self._work_queue: queue.SimpleQueue[_WorkItem | None] = queue.SimpleQueue()
283
+ self._idle_semaphore = Semaphore(0)
284
+ self._threads: set[Thread] = set()
285
+ self._broken = False
286
+ self._shutdown = False
287
+ self._shutdown_lock = Lock()
288
+ self._thread_name_prefix = (
289
+ thread_name_prefix or f"ThreadPoolExecutor-{self._counter()}"
290
+ )
291
+
292
+ # Prepare context factory and task resolver
293
+ (
294
+ self._create_worker_context,
295
+ self._resolve_work_item_task,
296
+ ) = type(self).prepare_context(initializer, initargs)
297
+
298
+ if idle_timeout is None or idle_timeout < 0:
299
+ self._idle_timeout = -1.0
300
+ else:
301
+ self._idle_timeout = max(0.1, idle_timeout)
302
+
303
+ def submit(
304
+ self,
305
+ fn: "Callable[P, T]",
306
+ /,
307
+ *args: "P.args",
308
+ **kwargs: "P.kwargs",
309
+ ) -> "Future[T]":
310
+ if iscoroutinefunction(fn):
311
+ raise TypeError("fn must not be a coroutine function")
312
+
313
+ with self._shutdown_lock, _global_shutdown_lock:
314
+ if self._broken:
315
+ raise BrokenThreadPool(self._broken)
316
+
317
+ if self._shutdown:
318
+ raise RuntimeError("cannot schedule new futures after shutdown")
319
+ if _shutdown:
320
+ # coverage didn't realize that _shutdown is set, add no cover here
321
+ raise RuntimeError( # pragma: no cover
322
+ "cannot schedule new futures after interpreter shutdown"
323
+ )
324
+
325
+ f: Future = Future()
326
+ task = self._resolve_work_item_task(fn, args, kwargs)
327
+ w = _WorkItem(f, task)
328
+
329
+ self._work_queue.put(w)
330
+ self._adjust_thread_count()
331
+ return f
332
+
333
+ submit.__doc__ = _base.Executor.submit.__doc__
334
+
335
+ def _adjust_thread_count(self):
336
+ # If idle threads are available, don't spin new threads
337
+ if self._idle_semaphore.acquire(timeout=0):
338
+ return
339
+
340
+ # Remove dead threads
341
+ threads = self._threads
342
+ dead_threads = [t for t in threads if not t.is_alive()]
343
+ for t in dead_threads:
344
+ threads.discard(t)
345
+
346
+ # When the executor gets lost, the weakref callback will wake up
347
+ # the worker threads.
348
+ def weakref_cb(_, q=self._work_queue):
349
+ q.put(None)
350
+
351
+ num_threads = len(threads)
352
+ if num_threads < self._max_workers:
353
+ t = Thread(
354
+ name=f"{self._thread_name_prefix}_{num_threads}",
355
+ target=_worker,
356
+ args=(
357
+ ref(self, weakref_cb),
358
+ self._work_queue,
359
+ self._create_worker_context,
360
+ self._idle_timeout,
361
+ ),
362
+ )
363
+ t.start()
364
+ threads.add(t)
365
+ _threads_queues[t] = self._work_queue
366
+
367
+ def _initializer_failed(self):
368
+ with self._shutdown_lock:
369
+ self._broken = (
370
+ "A thread initializer failed, the thread pool is not usable anymore"
371
+ )
372
+ # Drain work queue and mark pending futures failed
373
+ while True:
374
+ try:
375
+ work_item = self._work_queue.get_nowait()
376
+ except Empty:
377
+ break
378
+ if work_item is not None:
379
+ work_item.future.set_exception(BrokenThreadPool(self._broken))
380
+
381
+ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False):
382
+ with self._shutdown_lock:
383
+ self._shutdown = True
384
+ if cancel_futures:
385
+ # Drain all work items from the queue, and then cancel their
386
+ # associated futures.
387
+ while True:
388
+ try:
389
+ work_item = self._work_queue.get_nowait()
390
+ except Empty:
391
+ break
392
+ if work_item is not None:
393
+ work_item.future.cancel()
394
+
395
+ # Send a wake-up to prevent threads calling
396
+ # _work_queue.get(block=True) from permanently blocking.
397
+ self._work_queue.put(None)
398
+
399
+ if wait:
400
+ for t in self._threads:
401
+ t.join()
402
+
403
+ shutdown.__doc__ = _base.Executor.shutdown.__doc__
404
+
405
+
406
+ class AsyncWorkerContext:
407
+ """Worker context for async pool executor.
408
+
409
+ This class provides a way to customize async worker initialization,
410
+ task execution, and cleanup. It supports both sync and async initializers.
411
+
412
+ Example:
413
+ class DatabaseContext(AsyncWorkerContext):
414
+ async def initialize(self):
415
+ self.db = await create_async_db_connection()
416
+
417
+ async def finalize(self):
418
+ await self.db.close()
419
+
420
+ async def run(self, task):
421
+ fn, args, kwargs = task
422
+ return await fn(self.db, *args, **kwargs)
423
+ """
424
+
425
+ @classmethod
426
+ def prepare(
427
+ cls, initializer: "Callable | None" = None, initargs: tuple = ()
428
+ ) -> tuple["Callable[[], AsyncWorkerContext]", "Callable[..., tuple]"]:
429
+ """Prepare context factory and task resolver.
430
+
431
+ Args:
432
+ initializer: A callable used to initialize worker threads.
433
+ Can be sync or async.
434
+ initargs: A tuple of arguments to pass to the initializer.
435
+
436
+ Returns:
437
+ A tuple of (create_context, resolve_task) functions.
438
+ """
439
+ if initializer is not None:
440
+ if not callable(initializer):
441
+ raise TypeError("initializer must be a callable")
442
+
443
+ def create_context() -> "AsyncWorkerContext":
444
+ return cls(initializer, initargs)
445
+
446
+ def resolve_task(fn, args, kwargs) -> tuple:
447
+ return (fn, args, kwargs)
448
+
449
+ return create_context, resolve_task
450
+
451
+ def __init__(self, initializer: "Callable | None" = None, initargs: tuple = ()):
452
+ self.initializer = initializer
453
+ self.initargs = initargs
454
+
455
+ async def initialize(self) -> None:
456
+ """Initialize the worker context.
457
+
458
+ Called once when the worker thread starts and event loop is ready.
459
+ """
460
+ if self.initializer is not None:
461
+ if iscoroutinefunction(self.initializer):
462
+ await self.initializer(*self.initargs)
463
+ else:
464
+ self.initializer(*self.initargs)
465
+
466
+ async def finalize(self) -> None:
467
+ """Finalize the worker context.
468
+
469
+ Called once when the worker thread exits.
470
+ """
471
+
472
+ async def run(self, task: tuple) -> "T":
473
+ """Execute a task.
474
+
475
+ Args:
476
+ task: A tuple of (fn, args, kwargs).
477
+
478
+ Returns:
479
+ The result of the task execution.
480
+ """
481
+ fn, args, kwargs = task
482
+ if iscoroutinefunction(fn):
483
+ return await fn(*args, **kwargs)
484
+ return fn(*args, **kwargs)
485
+
486
+
487
+ class _AsyncWorkItem:
488
+ """A work item for the async pool executor."""
489
+
490
+ def __init__(self, future: Future, task: tuple):
491
+ self.future = future
492
+ self.task = task
493
+
494
+ async def run(self, ctx: AsyncWorkerContext) -> None:
495
+ if not self.future.set_running_or_notify_cancel():
496
+ return
497
+
498
+ try:
499
+ result = await ctx.run(self.task)
500
+ except BaseException as exc:
501
+ self.future.set_exception(exc)
502
+ # Break a reference cycle with the exception 'exc'
503
+ self = None # noqa: F841
504
+ else:
505
+ self.future.set_result(result)
506
+
507
+ __class_getitem__ = classmethod(types.GenericAlias)
508
+
509
+
510
+ async def _async_worker(
511
+ executor_ref,
512
+ ready: Event,
513
+ max_workers: int,
514
+ idle_timeout: float,
515
+ create_context: "Callable[[], AsyncWorkerContext]",
516
+ ):
517
+ """Worker function for the async pool executor with idle timeout support."""
518
+ if max_workers <= 0:
519
+ raise ValueError(f"max_workers must be greater than 0, got {max_workers}")
520
+
521
+ ctx = create_context()
522
+ try:
523
+ await ctx.initialize()
524
+ except BaseException:
525
+ _base.LOGGER.critical("Exception in initializer:", exc_info=True)
526
+ executor = executor_ref()
527
+ if executor is not None:
528
+ executor._initializer_failed()
529
+ return
530
+
531
+ semaphore = asyncio.Semaphore(max_workers)
532
+ concurrency = 0
533
+
534
+ def on_task_done(_):
535
+ nonlocal concurrency
536
+ concurrency -= 1
537
+ semaphore.release()
538
+
539
+ loop = asyncio.get_running_loop()
540
+ work_queue = AsyncQueue()
541
+ if (executor := executor_ref()) is not None:
542
+ executor._mark_thread_active(loop, work_queue)
543
+ del executor
544
+
545
+ async def run(tg: asyncio.TaskGroup):
546
+ nonlocal concurrency
547
+
548
+ ready.set()
549
+ idle_tick = monotonic()
550
+
551
+ try:
552
+ while True:
553
+ if await semaphore.acquire():
554
+ semaphore.release()
555
+ try:
556
+ async with timeout(0.1):
557
+ work_item = await work_queue.get()
558
+ if work_item is None:
559
+ break
560
+
561
+ # Create task that runs the work item with context
562
+ async def run_with_context(item):
563
+ await item.run(ctx)
564
+
565
+ task = tg.create_task(run_with_context(work_item))
566
+ task.add_done_callback(on_task_done)
567
+ concurrency += 1
568
+ del work_item
569
+ idle_tick = monotonic()
570
+ except (Empty, TimeoutError):
571
+ pass
572
+ if concurrency > 0:
573
+ idle_tick = monotonic()
574
+ elif idle_timeout >= 0 and monotonic() - idle_tick > idle_timeout:
575
+ break
576
+ finally:
577
+ if (executor := executor_ref()) is not None:
578
+ executor._mark_thread_inactive()
579
+ if _shutdown or executor._shutdown:
580
+ executor._shutdown = True
581
+ work_queue.put_nowait(None)
582
+ del executor
583
+
584
+ try:
585
+ async with asyncio.TaskGroup() as tg:
586
+ await run(tg)
587
+ finally:
588
+ await ctx.finalize()
589
+
590
+
591
+ class AsyncPoolExecutor(_base.Executor):
592
+ """An async pool executor with automatic worker scaling and idle timeout.
593
+
594
+ This executor runs coroutines in a dedicated worker thread with its own
595
+ event loop. It supports concurrent execution of multiple coroutines with
596
+ a semaphore-based limit.
597
+ """
598
+
599
+ _counter = itertools.count().__next__
600
+
601
+ @classmethod
602
+ def prepare_context(
603
+ cls, initializer: "Callable | None" = None, initargs: tuple = ()
604
+ ) -> tuple["Callable[[], AsyncWorkerContext]", "Callable[..., tuple]"]:
605
+ """Prepare context factory and task resolver.
606
+
607
+ This class method can be overridden to provide a custom AsyncWorkerContext.
608
+
609
+ Args:
610
+ initializer: A callable used to initialize worker threads.
611
+ Can be sync or async.
612
+ initargs: A tuple of arguments to pass to the initializer.
613
+
614
+ Returns:
615
+ A tuple of (create_context, resolve_task) functions.
616
+ """
617
+ return AsyncWorkerContext.prepare(initializer, initargs)
618
+
619
+ def __init__(
620
+ self,
621
+ max_workers: int | None = None,
622
+ thread_name_prefix: str = "",
623
+ initializer=None,
624
+ initargs=(),
625
+ idle_timeout: float | None = 60.0,
626
+ ):
627
+ """Initializes a new AsyncPoolExecutor instance.
628
+
629
+ :type max_workers: int, optional
630
+ :param max_workers: The maximum number of concurrent coroutines to run.
631
+ Defaults to 262144.
632
+
633
+ :type thread_name_prefix: str, optional
634
+ :param thread_name_prefix: An optional name prefix to give our threads.
635
+
636
+ :type initializer: callable, optional
637
+ :param initializer: A callable used to initialize worker threads.
638
+ Can be a sync function or an async coroutine function.
639
+
640
+ :type initargs: tuple, optional
641
+ :param initargs: A tuple of arguments to pass to the initializer.
642
+
643
+ :type idle_timeout: float, optional
644
+ :param idle_timeout: The maximum amount of time (in seconds) that a worker
645
+ thread can remain idle before it is terminated. If set to None or negative
646
+ value, workers will never be terminated. Defaults to 60 seconds.
647
+ """
648
+ super().__init__()
649
+ if max_workers is None:
650
+ max_workers = 262144
651
+ if max_workers <= 0:
652
+ raise ValueError("max_workers must be greater than 0")
653
+ if not thread_name_prefix:
654
+ thread_name_prefix = f"AsyncPoolExecutor-{self._counter()}"
655
+
656
+ self._broken = False
657
+ self._shutdown = False
658
+ self._shutdown_lock = Lock()
659
+ self._max_workers = max_workers
660
+ self._thread_name_prefix = thread_name_prefix
661
+ self._thread: Thread | None = None
662
+ self._thread_active = Event()
663
+
664
+ # Prepare context factory and task resolver
665
+ (
666
+ self._create_worker_context,
667
+ self._resolve_work_item_task,
668
+ ) = type(self).prepare_context(initializer, initargs)
669
+
670
+ # Wrap (loop, queue) into a list to make they can be accessed in weakref
671
+ # callback even if worker has been changed.
672
+ self._comp: list[_AsyncComponent] = []
673
+
674
+ if idle_timeout is None or idle_timeout < 0:
675
+ self._idle_timeout = -1
676
+ else:
677
+ self._idle_timeout = max(0.1, idle_timeout)
678
+ _async_executors.add(self)
679
+
680
+ def submit(
681
+ self,
682
+ fn: "Callable[P, T]",
683
+ /,
684
+ *args: "P.args",
685
+ **kwargs: "P.kwargs",
686
+ ) -> "Future[T]":
687
+ if not iscoroutinefunction(fn):
688
+ raise TypeError("fn must be a coroutine function")
689
+
690
+ with self._shutdown_lock, _global_shutdown_lock:
691
+ if self._broken:
692
+ raise BrokenThreadPool(self._broken)
693
+
694
+ if self._shutdown:
695
+ raise RuntimeError("cannot schedule new futures after shutdown")
696
+ if _shutdown:
697
+ raise RuntimeError( # pragma: no cover
698
+ "cannot schedule new futures after interpreter shutdown"
699
+ )
700
+
701
+ self._start_worker_thread()
702
+ if self._broken:
703
+ raise BrokenThreadPool(self._broken)
704
+ assert self._comp
705
+
706
+ f = Future() # type: ignore
707
+ task = self._resolve_work_item_task(fn, args, kwargs)
708
+ w = _AsyncWorkItem(f, task)
709
+ loop, work_queue = self._comp[0]
710
+ loop.call_soon_threadsafe(work_queue.put_nowait, w)
711
+
712
+ return f
713
+
714
+ submit.__doc__ = _base.Executor.submit.__doc__
715
+
716
+ def _start_worker_thread(self):
717
+ if self._thread_active.is_set():
718
+ return
719
+
720
+ def weakref_cb(_, c=self._comp):
721
+ if not c:
722
+ return
723
+ loop, work_queue = c[0]
724
+ loop.call_soon_threadsafe(work_queue.put_nowait, None)
725
+
726
+ ready = Event()
727
+
728
+ def run_worker():
729
+ loop = asyncio.new_event_loop()
730
+ asyncio.set_event_loop(loop)
731
+ try:
732
+ loop.run_until_complete(
733
+ _async_worker(
734
+ ref(self, weakref_cb),
735
+ ready,
736
+ self._max_workers,
737
+ self._idle_timeout,
738
+ self._create_worker_context,
739
+ )
740
+ )
741
+ finally:
742
+ loop.close()
743
+
744
+ t = Thread(
745
+ name=f"{self._thread_name_prefix or self}_0",
746
+ target=run_worker,
747
+ )
748
+ t.start()
749
+ self._thread = t
750
+
751
+ # Check if initializer has finished or failed
752
+ while not self._broken:
753
+ if ready.is_set():
754
+ break
755
+ ready.wait(0.1)
756
+
757
+ def _mark_thread_active(self, loop: AbstractEventLoop, work_queue: AsyncQueue):
758
+ self._comp.append((loop, work_queue))
759
+ self._thread_active.set()
760
+
761
+ def _mark_thread_inactive(self):
762
+ self._comp.clear()
763
+ self._thread_active.clear()
764
+
765
+ def _cancel_pending_futures(self, loop: AbstractEventLoop, work_queue: AsyncQueue):
766
+ async def drain_and_cancel():
767
+ cancelled = []
768
+ while True:
769
+ try:
770
+ work_item = work_queue.get_nowait()
771
+ if work_item is not None:
772
+ cancelled.append(work_item)
773
+ except asyncio.QueueEmpty:
774
+ break
775
+ return cancelled
776
+
777
+ future = asyncio.run_coroutine_threadsafe(drain_and_cancel(), loop)
778
+ try:
779
+ work_items = future.result(timeout=5.0)
780
+ for work_item in work_items:
781
+ work_item.future.set_exception(BrokenThreadPool(self._broken))
782
+ except Exception:
783
+ pass
784
+
785
+ def _initializer_failed(self):
786
+ self._broken = True
787
+ self._mark_thread_inactive()
788
+ if c := self._comp:
789
+ loop, work_queue = c[0]
790
+ c.clear()
791
+ self._cancel_pending_futures(loop, work_queue)
792
+
793
+ def shutdown(self, wait=True, *, cancel_futures=False):
794
+ with self._shutdown_lock:
795
+ self._shutdown = True
796
+
797
+ if c := self._comp:
798
+ loop, work_queue = c[0]
799
+ c.clear()
800
+
801
+ if cancel_futures:
802
+ self._cancel_pending_futures(loop, work_queue)
803
+
804
+ loop.call_soon_threadsafe(work_queue.put_nowait, None)
805
+
806
+ if wait and (t := self._thread) is not None:
807
+ t.join()
808
+ self._thread = None
809
+ self._mark_thread_inactive()
810
+
811
+ shutdown.__doc__ = _base.Executor.shutdown.__doc__
@@ -11,20 +11,19 @@ classifiers = [
11
11
  "Operating System :: OS Independent",
12
12
  "Programming Language :: Python",
13
13
  "Programming Language :: Python :: 3",
14
- "Programming Language :: Python :: 3.8",
15
- "Programming Language :: Python :: 3.9",
16
- "Programming Language :: Python :: 3.10",
17
14
  "Programming Language :: Python :: 3.11",
18
15
  "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
19
18
  "Topic :: Software Development",
20
19
  "Topic :: Software Development :: Libraries :: Python Modules",
21
20
  "Topic :: Utilities",
22
21
  ]
23
- requires-python = ">=3.8"
22
+ requires-python = ">=3.11"
24
23
  readme = "README.md"
25
24
  dependencies = []
26
25
  dynamic = []
27
- version = "0.0.11"
26
+ version = "0.0.12"
28
27
 
29
28
  [project.license]
30
29
  text = "MIT"
@@ -54,21 +53,6 @@ excludes = [
54
53
  source = "file"
55
54
  path = "flexexecutor.py"
56
55
 
57
- [tool.pdm.dev-dependencies]
58
- dev = [
59
- "autoflake>=2.3.1",
60
- "coverage[toml]>=7.6.1",
61
- "mypy>=1.11.1",
62
- "nox>=2024.4.15",
63
- "pytest>=8.3.2",
64
- "pytest-asyncio>=0.23.8",
65
- "pytest-cov>=5.0.0",
66
- "pytest-mock>=3.14.0",
67
- "pytest-timeout>=2.3.1",
68
- "ruoml>=0.11.0",
69
- "ruff>=0.6.1",
70
- ]
71
-
72
56
  [tool.autoflake]
73
57
  in-place = true
74
58
  recursive = true
@@ -105,3 +89,19 @@ warn_unused_configs = true
105
89
 
106
90
  [tool.pytest.ini_options]
107
91
  asyncio_mode = "strict"
92
+
93
+ [dependency-groups]
94
+ dev = [
95
+ "autoflake>=2.3.1",
96
+ "coverage[toml]>=7.6.1",
97
+ "nox>=2024.4.15",
98
+ "pytest>=8.3.2",
99
+ "pytest-asyncio>=0.23.8",
100
+ "pytest-cov>=5.0.0",
101
+ "pytest-mock>=3.14.0",
102
+ "pytest-timeout>=2.3.1",
103
+ "ruff>=0.9.10",
104
+ "typing-extensions>=4.12.2",
105
+ "ty>=0.0.1a34",
106
+ "python-lsp-server>=1.14.0",
107
+ ]
@@ -1,386 +0,0 @@
1
- """
2
- Flexexecutor provides executors that can automatically scale the number of
3
- workers up and down.
4
-
5
- Copyright (c) 2020-2024, Leavers.
6
- License: MIT
7
- """
8
-
9
- import asyncio
10
- import atexit
11
- import itertools
12
- from concurrent.futures import Future, ProcessPoolExecutor, _base
13
- from concurrent.futures.thread import BrokenThreadPool, _WorkItem
14
- from concurrent.futures.thread import ThreadPoolExecutor as _ThreadPoolExecutor
15
- from inspect import iscoroutinefunction
16
- from queue import Empty
17
- from threading import Event, Lock, Thread
18
- from time import monotonic
19
- from weakref import WeakKeyDictionary, ref
20
-
21
- __all__ = (
22
- "__version__",
23
- "AsyncPoolExecutor",
24
- "BrokenThreadPool",
25
- "Future",
26
- "ProcessPoolExecutor",
27
- "ThreadPoolExecutor",
28
- )
29
-
30
- __version__ = "0.0.11"
31
-
32
- _threads_queues = WeakKeyDictionary() # type: ignore
33
- _shutdown = False
34
- _global_shutdown_lock = Lock()
35
-
36
-
37
- def _python_exit():
38
- global _shutdown
39
- with _global_shutdown_lock:
40
- _shutdown = True
41
- items = list(_threads_queues.items())
42
- for t, q in items:
43
- q.put(None)
44
- for t, q in items:
45
- t.join()
46
-
47
-
48
- atexit.register(_python_exit)
49
-
50
-
51
- def _worker(executor_ref, work_queue, initializer, initargs, idle_timeout):
52
- if initializer is not None:
53
- try:
54
- initializer(*initargs)
55
- except BaseException:
56
- _base.LOGGER.critical("Exception in initializer:", exc_info=True)
57
- executor = executor_ref()
58
- if executor is not None: # pragma: no cover
59
- executor._initializer_failed()
60
- return
61
-
62
- idle_tick = -1.0
63
- try:
64
- while True:
65
- if idle_tick == -1.0:
66
- idle_tick = monotonic()
67
- elif idle_timeout >= 0 and monotonic() - idle_tick > idle_timeout:
68
- break
69
- try:
70
- work_item = work_queue.get(block=True, timeout=0.1)
71
- except Empty:
72
- continue
73
- if work_item is not None:
74
- work_item.run()
75
- del work_item
76
-
77
- executor = executor_ref()
78
- if executor is not None:
79
- executor._idle_semaphore.release()
80
- del executor
81
- idle_tick = monotonic()
82
- continue
83
- break # pragma: no cover
84
- finally:
85
- executor = executor_ref()
86
- if executor is None:
87
- work_queue.put(None)
88
- else:
89
- executor._idle_semaphore.acquire(timeout=0)
90
- if _shutdown or executor._shutdown:
91
- executor._shutdown = True
92
- work_queue.put(None)
93
- del executor
94
-
95
-
96
- class ThreadPoolExecutor(_ThreadPoolExecutor):
97
- def __init__(
98
- self,
99
- max_workers=1024,
100
- thread_name_prefix="",
101
- initializer=None,
102
- initargs=(),
103
- idle_timeout=60.0,
104
- ):
105
- """Initializes a new ThreadPoolExecutor instance.
106
-
107
- :type max_workers: int, optional
108
- :param max_workers: The maximum number of workers to create. Defaults to 1024.
109
-
110
- :type thread_name_prefix: str, optional
111
- :param thread_name_prefix: An optional name prefix to give our threads.
112
-
113
- :type initializer: callable, optional
114
- :param initializer: A callable used to initialize worker threads.
115
-
116
- :type initargs: tuple, optional
117
- :parm initargs: A tuple of arguments to pass to the initializer.
118
-
119
- :type idle_timeout: float, optional
120
- :param idle_timeout: The maximum amount of time (in seconds) that a worker
121
- thread can remain idle before it is terminated. If set to None or negative
122
- value, workers will never be terminated. Defaults to 60 seconds.
123
- """
124
- if max_workers is None:
125
- max_workers = 1024
126
- super().__init__(max_workers, thread_name_prefix, initializer, initargs)
127
- if idle_timeout is None or idle_timeout < 0:
128
- self._idle_timeout = -1
129
- else:
130
- self._idle_timeout = max(0.1, idle_timeout)
131
-
132
- def submit(self, fn, /, *args, **kwargs):
133
- if iscoroutinefunction(fn):
134
- raise TypeError("fn must not be a coroutine function")
135
-
136
- with self._shutdown_lock, _global_shutdown_lock:
137
- if self._broken:
138
- raise BrokenThreadPool(self._broken)
139
-
140
- if self._shutdown:
141
- raise RuntimeError("cannot schedule new futures after shutdown")
142
- if _shutdown:
143
- # coverage didn't realize that _shutdown is set, add no cover here
144
- raise RuntimeError( # pragma: no cover
145
- "cannot schedule new futures after interpreter shutdown"
146
- )
147
-
148
- f = Future() # type: ignore
149
- w = _WorkItem(f, fn, args, kwargs)
150
-
151
- self._work_queue.put(w)
152
- self._adjust_thread_count()
153
- return f
154
-
155
- submit.__doc__ = _base.Executor.submit.__doc__
156
-
157
- def _adjust_thread_count(self):
158
- if self._idle_semaphore.acquire(timeout=0):
159
- return
160
- threads = self._threads
161
- dead_threads = [t for t in threads if not t.is_alive()]
162
- for t in dead_threads:
163
- threads.remove(t) # type: ignore
164
-
165
- def weakref_cb(_, q=self._work_queue):
166
- q.put(None)
167
-
168
- num_threads = len(threads)
169
- if num_threads < self._max_workers:
170
- t = Thread(
171
- name=f"{self._thread_name_prefix or self}_{num_threads}",
172
- target=_worker,
173
- args=(
174
- ref(self, weakref_cb),
175
- self._work_queue,
176
- self._initializer,
177
- self._initargs,
178
- self._idle_timeout,
179
- ),
180
- )
181
- t.start()
182
- threads.add(t) # type: ignore
183
- _threads_queues[t] = self._work_queue
184
-
185
-
186
- class _AsyncWorkItem(_WorkItem):
187
- async def run(self):
188
- if not self.future.set_running_or_notify_cancel():
189
- print("cancelled")
190
- return
191
-
192
- try:
193
- result = await self.fn(*self.args, **self.kwargs)
194
- self.future.set_result(result)
195
- except BaseException as exc:
196
- self.future.set_exception(exc)
197
- finally:
198
- del self
199
-
200
-
201
- async def _async_worker(
202
- executor_ref,
203
- work_queue,
204
- initializer,
205
- initargs,
206
- max_workers,
207
- idle_timeout,
208
- ):
209
- executor = executor_ref()
210
- if executor is not None:
211
- executor._running.set()
212
-
213
- if initializer is not None:
214
- try:
215
- initializer(*initargs)
216
- except BaseException:
217
- _base.LOGGER.critical("Exception in initializer:", exc_info=True)
218
- if executor is not None: # pragma: no cover
219
- executor._running.set()
220
- executor._initializer_failed()
221
- return
222
-
223
- idle_tick = monotonic()
224
- curr_tasks = set()
225
- loop = asyncio.get_running_loop()
226
- asleep = asyncio.sleep
227
-
228
- try:
229
- while True:
230
- if idle_timeout >= 0 and monotonic() - idle_tick > idle_timeout:
231
- break
232
- if len(curr_tasks) < max_workers:
233
- try:
234
- work_item = work_queue.get(block=True, timeout=0.1)
235
- if work_item is not None:
236
- task = loop.create_task(work_item.run())
237
- curr_tasks.add(task)
238
- await asleep(0)
239
- del work_item
240
- else:
241
- break
242
- except Empty:
243
- pass
244
- await asleep(0)
245
- finished_tasks = [t for t in curr_tasks if t.done()]
246
- for t in finished_tasks:
247
- curr_tasks.remove(t)
248
- if curr_tasks:
249
- idle_tick = monotonic()
250
- finally:
251
- while curr_tasks:
252
- await asleep(0)
253
- finished_tasks = [t for t in curr_tasks if t.done()]
254
- for t in finished_tasks:
255
- curr_tasks.remove(t)
256
- executor = executor_ref()
257
- if executor is None:
258
- work_queue.put(None)
259
- else:
260
- executor._running.clear()
261
- if _shutdown or executor._shutdown:
262
- executor._shutdown = True
263
- work_queue.put(None)
264
- del executor
265
-
266
-
267
- class AsyncWorker(Thread):
268
- def __init__(
269
- self,
270
- name,
271
- executor_ref,
272
- work_queue,
273
- initializer,
274
- initargs,
275
- max_workers,
276
- idle_timeout,
277
- ):
278
- super().__init__(name=name)
279
- self._executor_ref = executor_ref
280
- self._work_queue = work_queue
281
- self._initializer = initializer
282
- self._initargs = initargs
283
- self._max_workers = max_workers
284
- self._idle_timeout = idle_timeout
285
-
286
- def run(self):
287
- loop = asyncio.new_event_loop()
288
- loop.run_until_complete(
289
- _async_worker(
290
- self._executor_ref,
291
- self._work_queue,
292
- self._initializer,
293
- self._initargs,
294
- self._max_workers,
295
- self._idle_timeout,
296
- )
297
- )
298
-
299
-
300
- class AsyncPoolExecutor(ThreadPoolExecutor):
301
- _counter = itertools.count().__next__
302
-
303
- def __init__(
304
- self,
305
- max_workers=None,
306
- thread_name_prefix="",
307
- initializer=None,
308
- initargs=(),
309
- idle_timeout=60.0,
310
- ):
311
- """Initializes a new AsyncPoolExecutor instance.
312
-
313
- :type max_workers: int, optional
314
- :param max_workers: The maximum number of workers to create. Defaults to 261244.
315
-
316
- :type thread_name_prefix: str, optional
317
- :param thread_name_prefix: An optional name prefix to give our threads.
318
-
319
- :type initializer: callable, optional
320
- :param initializer: A callable used to initialize worker threads.
321
-
322
- :type initargs: tuple, optional
323
- :parm initargs: A tuple of arguments to pass to the initializer.
324
-
325
- :type idle_timeout: float, optional
326
- :param idle_timeout: The maximum amount of time (in seconds) that a worker
327
- thread can remain idle before it is terminated. If set to None or negative
328
- value, workers will never be terminated. Defaults to 60 seconds.
329
- """
330
- if max_workers is None:
331
- max_workers = 262144
332
- if not thread_name_prefix:
333
- thread_name_prefix = f"AsyncPoolExecutor-{self._counter()}" # type: ignore
334
- super().__init__(max_workers, thread_name_prefix, initializer, initargs)
335
- del self._idle_semaphore
336
- self._running = Event()
337
- if idle_timeout is None or idle_timeout < 0:
338
- self._idle_timeout = -1
339
- else:
340
- self._idle_timeout = max(0.1, idle_timeout)
341
-
342
- def submit(self, fn, /, *args, **kwargs):
343
- if not iscoroutinefunction(fn):
344
- raise TypeError("fn must be a coroutine function")
345
- with self._shutdown_lock, _global_shutdown_lock:
346
- if self._broken:
347
- raise BrokenThreadPool(self._broken)
348
-
349
- if self._shutdown:
350
- raise RuntimeError("cannot schedule new futures after shutdown")
351
- if _shutdown:
352
- # coverage didn't realize that _shutdown is set, add no cover here
353
- raise RuntimeError( # pragma: no cover
354
- "cannot schedule new futures after interpreter shutdown"
355
- )
356
-
357
- f = Future() # type: ignore
358
- w = _AsyncWorkItem(f, fn, args, kwargs)
359
-
360
- self._work_queue.put(w)
361
- self._adjust_thread_count()
362
- return f
363
-
364
- submit.__doc__ = _base.Executor.submit.__doc__
365
-
366
- def _adjust_thread_count(self):
367
- if self._running.is_set():
368
- return
369
- threads = self._threads
370
- threads.clear() # type: ignore
371
-
372
- def weakref_cb(_, q=self._work_queue):
373
- q.put(None)
374
-
375
- w = AsyncWorker(
376
- f"{self._thread_name_prefix or self}_0",
377
- ref(self, weakref_cb),
378
- self._work_queue,
379
- self._initializer,
380
- self._initargs,
381
- self._max_workers,
382
- self._idle_timeout,
383
- )
384
- w.start()
385
- threads.add(w) # type: ignore
386
- _threads_queues[w] = self._work_queue
File without changes
File without changes