Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-futurist for openSUSE:Factory
checked in at 2026-08-22 21:34:59
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-futurist (Old)
and /work/SRC/openSUSE:Factory/.python-futurist.new.1258 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-futurist"
Sat Aug 22 21:34:59 2026 rev:20 rq:1372930 version:3.5.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-futurist/python-futurist.changes
2026-07-07 21:03:40.819255261 +0200
+++
/work/SRC/openSUSE:Factory/.python-futurist.new.1258/python-futurist.changes
2026-08-22 21:37:04.494217515 +0200
@@ -1,0 +2,11 @@
+Fri Aug 21 20:10:04 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 3.5.0:
+ * typing: Tighten signature for ProcessPoolExecutor.__init__
+ * typing: Expose function signature
+ * Don't alias typing library
+ * Use "futures" consistently in log messages
+ * Add DelayedExecutorMixin variants
+ * ruff: Configure hacking as external linter
+
+-------------------------------------------------------------------
Old:
----
futurist-3.4.0.tar.gz
New:
----
futurist-3.5.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-futurist.spec ++++++
--- /var/tmp/diff_new_pack.In0Et3/_old 2026-08-22 21:37:05.148240893 +0200
+++ /var/tmp/diff_new_pack.In0Et3/_new 2026-08-22 21:37:05.150240965 +0200
@@ -17,7 +17,7 @@
Name: python-futurist
-Version: 3.4.0
+Version: 3.5.0
Release: 0
Summary: Useful additions to futures, from the future.
License: Apache-2.0
++++++ futurist-3.4.0.tar.gz -> futurist-3.5.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/.zuul.yaml
new/futurist-3.5.0/.zuul.yaml
--- old/futurist-3.4.0/.zuul.yaml 2026-06-26 15:32:16.000000000 +0200
+++ new/futurist-3.5.0/.zuul.yaml 2026-08-20 17:19:07.487014000 +0200
@@ -1,8 +1,11 @@
+---
+
- project:
templates:
- check-requirements
- lib-forward-testing-python3
- openstack-python3-jobs
+ - openstack-python3-next-jobs
- periodic-stable-jobs
- publish-openstack-docs-pti
- release-notes-jobs-python3
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/AUTHORS new/futurist-3.5.0/AUTHORS
--- old/futurist-3.4.0/AUTHORS 2026-06-26 15:33:06.000000000 +0200
+++ new/futurist-3.5.0/AUTHORS 2026-08-20 17:19:40.562633500 +0200
@@ -3,6 +3,7 @@
Akihiro Motoki <[email protected]>
Andreas Jaeger <[email protected]>
Anh Tran <[email protected]>
+Balazs Gibizer <[email protected]>
ChangBo Guo(gcb) <[email protected]>
Chenjun Shen <[email protected]>
Corey Bryant <[email protected]>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/ChangeLog new/futurist-3.5.0/ChangeLog
--- old/futurist-3.4.0/ChangeLog 2026-06-26 15:33:06.000000000 +0200
+++ new/futurist-3.5.0/ChangeLog 2026-08-20 17:19:40.548633300 +0200
@@ -1,12 +1,23 @@
CHANGES
=======
+3.5.0
+-----
+
+* typing: Tighten signature for ProcessPoolExecutor.\_\_init\_\_
+* typing: Expose function signature
+* Don't alias typing library
+* Use "futures" consistently in log messages
+* Add DelayedExecutorMixin variants
+* ruff: Configure hacking as external linter
+
3.4.0
-----
* Add release note for mp\_context kwarg
* tests: Fix ProcessPoolExecutor race on Python 3.14+
* Drop Python 3.10 support
+* zuul: Use openstack-python3-next-jobs template
* pre-commit: Bump versions
* typing: Fixes for mypy 2.0.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/PKG-INFO new/futurist-3.5.0/PKG-INFO
--- old/futurist-3.4.0/PKG-INFO 2026-06-26 15:33:06.589815900 +0200
+++ new/futurist-3.5.0/PKG-INFO 2026-08-20 17:19:40.599634400 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: futurist
-Version: 3.4.0
+Version: 3.5.0
Summary: Useful additions to futures, from the future.
Author-email: OpenStack <[email protected]>
License: Apache-2.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/doc/source/reference/index.rst
new/futurist-3.5.0/doc/source/reference/index.rst
--- old/futurist-3.4.0/doc/source/reference/index.rst 2026-06-26
15:32:16.000000000 +0200
+++ new/futurist-3.5.0/doc/source/reference/index.rst 2026-08-20
17:19:07.489014100 +0200
@@ -26,6 +26,19 @@
:members:
:special-members: __init__
+~~~~~~~~~~~~~~~~~
+Delayed Executors
+~~~~~~~~~~~~~~~~~
+
+.. autoclass:: futurist.DelayedExecutorMixinBase
+ :members:
+
+.. autoclass:: futurist.DelayedExecutorMixin
+ :members:
+
+.. autoclass:: futurist.GreenDelayedExecutorMixin
+ :members:
+
-------
Futures
-------
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/doc/source/user/examples.rst
new/futurist-3.5.0/doc/source/user/examples.rst
--- old/futurist-3.4.0/doc/source/user/examples.rst 2026-06-26
15:32:16.000000000 +0200
+++ new/futurist-3.5.0/doc/source/user/examples.rst 2026-08-20
17:19:07.489014100 +0200
@@ -96,6 +96,49 @@
hello
+------------------------------------------------------
+Creating and using a delayed (time-deferred) executor
+------------------------------------------------------
+
+``DelayedExecutorMixin`` adds ``submit_after(delay, fn, …)`` to any futurist
+executor. The scheduler keeps a single background thread and a min-heap of
+deadlines, so arbitrarily many delayed tasks share just one extra thread.
+
+::
+
+ import time
+
+ import futurist
+
+
+ class DelayedThreadExecutor(
+ futurist.DelayedExecutorMixin,
+ futurist.ThreadPoolExecutor,
+ ):
+ pass
+
+
+ def greet(name):
+ return f"Hello, {name}!"
+
+
+ e = DelayedThreadExecutor(max_workers=4)
+
+ # Schedule greet() to run 0.5 seconds from now.
+ fut = e.submit_after(0.5, greet, "world")
+
+ # The future can be cancelled at any point before the deadline.
+ # fut.cancel()
+
+ print(fut.result()) # blocks until the task completes
+ e.shutdown()
+
+**Expected output:**
+
+::
+
+ Hello, world!
+
-------------------------------------------
Creating and using a process-based executor
-------------------------------------------
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/doc/source/user/features.rst
new/futurist-3.5.0/doc/source/user/features.rst
--- old/futurist-3.4.0/doc/source/user/features.rst 2026-06-26
15:32:16.000000000 +0200
+++ new/futurist-3.5.0/doc/source/user/features.rst 2026-08-20
17:19:07.489014100 +0200
@@ -19,6 +19,18 @@
execution statistics. It returns instances
of :py:class:`.futurist.Future` objects.
+Delayed Execution
+-----------------
+
+* A :py:class:`.futurist.DelayedExecutorMixin` that can be combined with any
+ :class:`~concurrent.futures.Executor` subclass to add a
+ ``submit_after(delay, fn, *args, **kwargs)`` method. A single background
+ scheduler thread maintains a min-heap of pending tasks ordered by deadline;
+ tasks can be cancelled while waiting via the returned
+ :class:`~concurrent.futures.Future`. Use
+ :py:class:`.futurist.GreenDelayedExecutorMixin` when working with eventlet-
+ based executors.
+
Periodics
---------
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist/__init__.py
new/futurist-3.5.0/futurist/__init__.py
--- old/futurist-3.4.0/futurist/__init__.py 2026-06-26 15:32:16.000000000
+0200
+++ new/futurist-3.5.0/futurist/__init__.py 2026-08-20 17:19:07.489014100
+0200
@@ -20,7 +20,10 @@
from futurist._futures import CancelledError
from futurist._futures import TimeoutError
+from futurist._futures import DelayedExecutorMixin
+from futurist._futures import DelayedExecutorMixinBase
from futurist._futures import DynamicThreadPoolExecutor
+from futurist._futures import GreenDelayedExecutorMixin
from futurist._futures import GreenThreadPoolExecutor
from futurist._futures import ProcessPoolExecutor
from futurist._futures import SynchronousExecutor
@@ -41,6 +44,9 @@
'SynchronousExecutor',
'ThreadPoolExecutor',
'DynamicThreadPoolExecutor',
+ 'DelayedExecutorMixin',
+ 'GreenDelayedExecutorMixin',
'RejectedSubmission',
'ExecutorStatistics',
+ 'DelayedExecutorMixinBase',
]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist/_futures.py
new/futurist-3.5.0/futurist/_futures.py
--- old/futurist-3.4.0/futurist/_futures.py 2026-06-26 15:32:16.000000000
+0200
+++ new/futurist-3.5.0/futurist/_futures.py 2026-08-20 17:19:07.490014000
+0200
@@ -14,13 +14,16 @@
from __future__ import annotations
+import abc
from collections.abc import Callable
import functools
+import heapq
import logging
+import multiprocessing.context
import queue
import threading
import time
-from typing import Any, ParamSpec, Self, TypeVar
+from typing import Any, Generic, ParamSpec, Self, TypeVar
from concurrent import futures as _futures
from concurrent.futures import process as _process
@@ -33,6 +36,7 @@
_P = ParamSpec('_P')
_R = TypeVar('_R')
+T = TypeVar('T')
LOG = logging.getLogger(__name__)
@@ -463,7 +467,7 @@
def __init__(
self,
max_workers: int | None = None,
- mp_context: Any = None,
+ mp_context: multiprocessing.context.BaseContext | None = None,
) -> None:
if max_workers is None:
max_workers = _utils.get_optimal_process_count()
@@ -813,3 +817,402 @@
'cancelled': self._cancelled,
}
)
+
+
+class DelayedExecutorMixinBase(_futures.Executor, abc.ABC, Generic[T]):
+ """Mixin that adds submit_after(delay, fn, ...) to any Executor subclass.
+
+ Maintains a single background scheduler thread that keeps a min-heap of
+ pending tasks ordered by deadline. When a task's deadline arrives the
+ scheduler submits it to the underlying executor. Only one extra thread is
+ required regardless of how many delayed tasks are pending, making this
+ suitable for use with native threads where thread creation is expensive.
+
+ Concrete subclasses must implement the factory / threading primitives:
+ ``_get_condition_object``, ``_get_future_object``, ``_start_thread``,
+ ``_join_thread``, and ``_is_scheduler_alive``.
+
+ .. note::
+
+ When combining this mixin with
+ :class:`~futurist.ProcessPoolExecutor`, you **must** use the
+ ``spawn`` multiprocessing start method (e.g.
+ ``mp_context=multiprocessing.get_context('spawn')``). The
+ scheduler runs a background thread in the parent process; using
+ the ``fork`` or ``forkserver`` start methods can leave
+ threading primitives (conditions, locks) in a corrupted state in
+ child worker processes, leading to hangs or deadlocks.
+ """
+
+ class Task(Generic[_R]):
+ """A single delayed task entry in the scheduler priority queue.
+
+ Instances are ordered by ``deadline`` so that the min-heap in
+ :class:`DelayedExecutorMixinBase` always pops the task whose
+ deadline is closest.
+ """
+
+ def __init__(
+ self,
+ fn: Callable[..., _R],
+ args: tuple[Any, ...],
+ kwargs: dict[str, Any],
+ future: Future[_R],
+ delay: float,
+ ) -> None:
+ self.fn = fn
+ self.args = args
+ self.kwargs = kwargs
+ self.future = future
+ self.deadline = time.monotonic() + delay
+
+ @property
+ def _remaining_delay(self) -> float:
+ return self.deadline - time.monotonic()
+
+ def __repr__(self) -> str:
+ return (
+ f"Task={self.fn}, "
+ f"remaining_delay={self._remaining_delay}, "
+ f"future={self.future}"
+ )
+
+ def __lt__(self, other: object) -> bool:
+ if not isinstance(other, DelayedExecutorMixinBase.Task):
+ return NotImplemented
+ return self.deadline < other.deadline
+
+ class _SentinelTask(Task[Any]):
+ """Sentinel pushed to the queue to signal the scheduler to exit.
+
+ Inherits :class:`Task` so the scheduler queue can be typed as
+ ``list[Task]`` and ordering falls out naturally from deadline
+ comparison. Setting ``deadline`` to ``float('inf')`` guarantees
+ the sentinel always sorts after every real task, so all pending
+ work is drained before the scheduler thread notices the shutdown
+ signal.
+ """
+
+ def __init__(self) -> None:
+ self.deadline = float("inf")
+
+ def __repr__(self) -> str:
+ return "SentinelTask"
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(*args, **kwargs)
+
+ self._queue_changed = self._get_condition_object()
+ # A min-heap of Task instances (including the _SentinelTask
+ # sentinel, which inherits Task and always sorts last).
+ self._queue: list[DelayedExecutorMixinBase.Task[Any]] = []
+
+ self._sentinel = self._SentinelTask()
+
+ self._shutdown_requested = False
+ self._cancel_futures_on_shutdown = False
+
+ self._scheduler = self._start_thread(self._schedule)
+
+ def _task_wrapper(self, task: Task[Any]) -> None:
+ try:
+ task.future.set_result(task.fn(*task.args, **task.kwargs))
+ except BaseException as e:
+ task.future.set_exception(e)
+
+ def _schedule(self) -> None:
+ """Run the scheduler, submitting delayed tasks as they become due."""
+ while True:
+ task_to_submit: DelayedExecutorMixinBase.Task[Any] | None = None
+ with self._queue_changed:
+ LOG.debug("Waiting for task")
+ self._queue_changed.wait_for(lambda: bool(self._queue))
+ task: DelayedExecutorMixinBase.Task[Any] = heapq.heappop(
+ self._queue
+ )
+ LOG.debug("%s received", task)
+
+ if task is self._sentinel:
+ # We are being shutdown. As the sentinel always sorts
+ # after every real task we know all pending tasks have
+ # been submitted to the inner executor. Shut down the
+ # inner executor now so resources are freed whether or
+ # not the caller passes wait=True.
+ LOG.debug("Sentinel task received, exiting scheduler")
+ super().shutdown(
+ wait=False,
+ cancel_futures=self._cancel_futures_on_shutdown,
+ )
+ break
+
+ # task is now the one with the closest deadline
+ if task.future.cancelled():
+ # task is cancelled, just start over
+ LOG.debug("%s was cancelled while in the queue", task)
+ continue
+
+ # wait for the deadline or an outside trigger
+ LOG.debug("Waiting for the deadline of %s", task)
+ changed = self._queue_changed.wait(task._remaining_delay)
+ LOG.debug(
+ "Awaken while waiting on %s, queue changed=%s",
+ task,
+ changed,
+ )
+
+ if changed:
+ # Something changed before the deadline.
+ if task.future.cancelled():
+ # nothing to do with this task just start over
+ LOG.debug("%s is cancelled, skipping", task)
+ continue
+ else:
+ # Reinsert the task to the queue and start over; this
+ # allows handling a newer task with a closer deadline.
+ heapq.heappush(self._queue, task)
+ LOG.debug("Queue changed, re-scheduling %s", task)
+ continue
+ else:
+ # We hit the deadline of the task so if it is not
+ # cancelled then we can execute it.
+ run_it = task.future.set_running_or_notify_cancel()
+ if not run_it:
+ # Task is cancelled just start over
+ LOG.debug("%s is cancelled, skipping", task)
+ continue
+ # Mark for submission outside the lock to avoid holding
+ # the condition while dispatching to the inner executor.
+ task_to_submit = task
+
+ # Submit outside the lock to avoid holding the condition
+ # longer than needed and to let concurrent submit_after
+ # calls proceed without waiting for this dispatch.
+ if task_to_submit is not None:
+ try:
+ self.submit(self._task_wrapper, task_to_submit)
+ LOG.debug("Task submitted %s", task_to_submit)
+ except BaseException as e:
+ LOG.debug("Failed to submit %s: %s", task_to_submit, e)
+ task_to_submit.future.set_exception(e)
+
+ LOG.debug("Scheduler thread finished")
+
+ def submit_after(
+ self,
+ delay: float,
+ fn: Callable[_P, _R],
+ *args: _P.args,
+ **kwargs: _P.kwargs,
+ ) -> _futures.Future[_R]:
+ """Schedule *fn* to run after *delay* seconds.
+
+ :param delay: Number of seconds to wait before executing *fn*.
+ Must be a non-negative value.
+ :param fn: Callable to execute after the delay.
+ :param args: Positional arguments forwarded to *fn*.
+ :param kwargs: Keyword arguments forwarded to *fn*.
+ :returns: A :class:`~concurrent.futures.Future` that will be
+ resolved with the return value of *fn*, or with an exception
+ if *fn* raises. The future can be cancelled while the task
+ is still waiting for its deadline.
+ :raises RuntimeError: If the executor has already been shut down.
+ :raises ValueError: If *delay* is negative.
+ """
+ if delay < 0:
+ raise ValueError(
+ f"delay must be a non-negative number, got {delay!r}"
+ )
+ with self._queue_changed:
+ if self._shutdown_requested:
+ raise RuntimeError(
+ "Cannot schedule new futures after being shutdown"
+ )
+
+ task = self.Task(
+ fn, args, kwargs, self._get_future_object(), delay
+ )
+ heapq.heappush(self._queue, task)
+ self._queue_changed.notify_all()
+ LOG.debug("%s is queued", task)
+ return task.future
+
+ def shutdown(
+ self, wait: bool = True, *, cancel_futures: bool = False
+ ) -> None:
+ """Shutdown the executor.
+
+ :param wait: If ``True`` (the default) block until the scheduler
+ thread and all submitted work have completed.
+ :param cancel_futures: If ``True``, cancel any delayed tasks that
+ are still waiting for their deadline before shutting down.
+ """
+ LOG.debug("Shutdown requested")
+ with self._queue_changed:
+ self._cancel_futures_on_shutdown = cancel_futures
+ if cancel_futures:
+ for item in self._queue:
+ if item is not self._sentinel:
+ item.future.cancel()
+ if not self._shutdown_requested:
+ # Ensure that our thread wakes at least one more time to allow
+ # it to exit by queuing up a sentinel task after the shutdown
+ # condition is set. This task won't be executed.
+ heapq.heappush(self._queue, self._sentinel)
+ # We only want to queue 1 sentinel even if multiple shutdown
+ # calls happen.
+ self._shutdown_requested = True
+ self._queue_changed.notify_all()
+ LOG.debug("Shutdown signalled to the scheduler thread")
+
+ # If wait is set we need to wait for our sentinel to be processed and
+ # therefore our thread to exit. The scheduler thread shuts down the
+ # inner executor when it processes the sentinel; calling
+ # super().shutdown(wait=True) here then waits for any still-running
+ # inner-executor workers to finish.
+ if wait:
+ LOG.debug("Waiting for the scheduler thread to finish")
+ self._join_thread(self._scheduler)
+ LOG.debug("Waiting for the internal executor to finish")
+ super().shutdown(wait=wait, cancel_futures=cancel_futures)
+ # With wait=False we return immediately. The scheduler thread is
+ # still running, will submit any remaining delayed tasks to the
+ # inner executor, and will then shut down the inner executor itself.
+
+ @property
+ def alive(self) -> bool:
+ """``True`` while the scheduler or inner executor is still active.
+ Returns ``False`` only once both have fully stopped. The
+ ``or super().alive`` term is defensive: in normal operation the
+ scheduler calls ``super().shutdown()`` before exiting so
+ ``super().alive`` is already ``False`` when the scheduler dies, but
+ if the scheduler exits abnormally without that call, the inner
+ executor may still be running and this ensures we report it.
+
+ Note: ``alive`` is not defined on the stdlib
+ :class:`~concurrent.futures.Executor` base class, but every futurist
+ executor exposes it. This mixin is designed to be combined with a
+ futurist executor, so ``super().alive`` will always resolve correctly
+ at runtime even though the static type checker cannot verify it.
+ """
+ return self._is_scheduler_alive() or super().alive # type:
ignore[misc]
+
+ @abc.abstractmethod
+ def _get_condition_object(self) -> threading.Condition:
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def _get_future_object(self) -> Future[Any]:
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def _start_thread(self, fn: Callable[[], None]) -> T:
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def _join_thread(self, t: T) -> None:
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def _is_scheduler_alive(self) -> bool:
+ raise NotImplementedError
+
+
+class GreenDelayedExecutorMixin(DelayedExecutorMixinBase[Any]):
+ """Variant of :class:`DelayedExecutorMixinBase` that uses eventlet
+ primitives.
+
+ This allows the mixin to work correctly in environments where eventlet is
+ present but the standard library has *not* been monkey-patched: all
+ threading primitives (conditions, threads, futures) are substituted with
+ their eventlet equivalents so that the scheduler integrates properly with
+ the eventlet event loop.
+
+ Example usage::
+
+ class DelayedGreenExecutor(
+ GreenDelayedExecutorMixin, GreenThreadPoolExecutor
+ ):
+ pass
+
+
+ executor = DelayedGreenExecutor(max_workers=4)
+ fut = executor.submit_after(0.5, my_func, arg1, kwarg=val)
+ result = fut.result()
+ executor.shutdown()
+ """
+
+ @staticmethod
+ def _get_condition_object() -> threading.Condition:
+ if _green.threading is None:
+ raise RuntimeError(
+ "GreenDelayedExecutorMixin requires eventlet to be installed"
+ )
+ return _green.threading.condition_object()
+
+ @staticmethod
+ def _get_future_object() -> GreenFuture:
+ return GreenFuture()
+
+ @staticmethod
+ def _start_thread(fn: Callable[[], None]) -> Any:
+ if _green.spawn is None:
+ raise RuntimeError(
+ "GreenDelayedExecutorMixin requires eventlet to be installed"
+ )
+ return _green.spawn(fn)
+
+ @staticmethod
+ def _join_thread(t: Any) -> None:
+ t.wait()
+
+ def _is_scheduler_alive(self) -> bool:
+ # GreenThread exposes a ``dead`` attribute rather than ``is_alive()``.
+ return not self._scheduler.dead
+
+
+class DelayedExecutorMixin(DelayedExecutorMixinBase[threading.Thread]):
+ """Variant of :class:`DelayedExecutorMixinBase` that uses native threads.
+
+ Use this class together with any standard-library
+ :class:`~concurrent.futures.Executor` subclass (e.g.
+ :class:`~futurist.ThreadPoolExecutor`) to add
+ :meth:`~DelayedExecutorMixinBase.submit_after` support backed by a single
+ daemon scheduler thread.
+
+ Example usage::
+
+ class DelayedThreadExecutor(DelayedExecutorMixin, ThreadPoolExecutor):
+ pass
+
+
+ executor = DelayedThreadExecutor(max_workers=4)
+ fut = executor.submit_after(0.5, my_func, arg1, kwarg=val)
+ result = fut.result()
+ executor.shutdown()
+ """
+
+ @staticmethod
+ def _get_condition_object() -> threading.Condition:
+ return threading.Condition()
+
+ @staticmethod
+ def _get_future_object() -> Future[Any]:
+ return Future()
+
+ @staticmethod
+ def _start_thread(fn: Callable[[], None]) -> threading.Thread:
+ t = threading.Thread(
+ target=fn,
+ name="futurist-delayed-scheduler",
+ daemon=True,
+ )
+ t.start()
+ return t
+
+ @staticmethod
+ def _join_thread(t: threading.Thread) -> None:
+ t.join()
+
+ def _is_scheduler_alive(self) -> bool:
+ t: threading.Thread = self._scheduler
+ return t.is_alive()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist/_green.py
new/futurist-3.5.0/futurist/_green.py
--- old/futurist-3.4.0/futurist/_green.py 2026-06-26 15:32:16.000000000
+0200
+++ new/futurist-3.5.0/futurist/_green.py 2026-08-20 17:19:07.490014000
+0200
@@ -28,8 +28,10 @@
from eventlet import queue as greenqueue
from eventlet.green import threading as greenthreading
+ from eventlet import greenthread
except ImportError:
- greenpatcher, greenpool, greenqueue, greenthreading = (
+ greenpatcher, greenpool, greenqueue, greenthreading, greenthread = (
+ None,
None,
None,
None,
@@ -42,6 +44,7 @@
Pool = greenpool.GreenPool
Queue = greenqueue.Queue
is_monkey_patched = greenpatcher.is_monkey_patched
+ spawn = greenthread.spawn
class GreenThreading:
@staticmethod
@@ -71,6 +74,8 @@
def is_monkey_patched(mod: str) -> bool:
return False
+ spawn = None
+
class GreenWorker:
def __init__(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist/tests/test_executors.py
new/futurist-3.5.0/futurist/tests/test_executors.py
--- old/futurist-3.4.0/futurist/tests/test_executors.py 2026-06-26
15:32:16.000000000 +0200
+++ new/futurist-3.5.0/futurist/tests/test_executors.py 2026-08-20
17:19:07.491014200 +0200
@@ -34,6 +34,14 @@
return 1
+def returns_args(foo, bar):
+ return foo + bar
+
+
+def returns_clock():
+ return time.monotonic()
+
+
def blows_up():
raise RuntimeError("no worky")
@@ -410,3 +418,365 @@
created_worker = mock_create_thread.return_value
created_worker.start.assert_called_with()
self.assertEqual(13, created_worker.start.call_count)
+
+
+class _GreenDelayedExecutor(
+ futurist.GreenDelayedExecutorMixin, futurist.GreenThreadPoolExecutor
+):
+ pass
+
+
+class _ThreadDelayedExecutor(
+ futurist.DelayedExecutorMixin, futurist.ThreadPoolExecutor
+):
+ pass
+
+
+class TestDelayedExecutorMixin(testscenarios.TestWithScenarios, base.TestCase):
+ executor_cls: type
+ scenarios = [
+ ('green', {'executor_cls': _GreenDelayedExecutor}),
+ ('thread', {'executor_cls': _ThreadDelayedExecutor}),
+ ]
+
+ def setUp(self):
+ super().setUp()
+ self.executor = self.executor_cls(max_workers=1)
+ self.addCleanup(self.executor.shutdown, wait=True)
+
+ def test_tasks_ordered_by_earliest_deadline(self):
+ def task(delay):
+ return self.executor.Task(lambda: None, (), {}, None, delay)
+
+ t10 = task(10)
+ t9 = task(9)
+ t11 = task(11)
+ t8 = task(8)
+ t1 = task(1)
+
+ self.assertEqual(
+ [t1, t8, t9, t10, t11], sorted([t10, t9, t11, t8, t1])
+ )
+
+ def test_execute_one(self):
+ task = self.executor.submit_after(0.1, returns_args, 13, bar=42)
+
+ self.assertEqual(13 + 42, task.result())
+
+ def test_execute_two_sequential(self):
+ task1 = self.executor.submit_after(0.1, returns_clock)
+ task2 = self.executor.submit_after(0.1, returns_clock)
+
+ t1_at = task1.result()
+ t2_at = task2.result()
+ self.assertLess(t1_at, t2_at)
+
+ def test_submit_second_while_waiting_on_first_sequential(self):
+ task1 = self.executor.submit_after(0.5, returns_clock)
+ time.sleep(0.2)
+ task2 = self.executor.submit_after(0.5, returns_clock)
+
+ t1_at = task1.result()
+ t2_at = task2.result()
+ self.assertLess(t1_at, t2_at)
+
+ def test_submit_second_preempts_first(self):
+ task1 = self.executor.submit_after(0.3, returns_clock)
+ task2 = self.executor.submit_after(0.1, returns_clock)
+
+ t1_at = task1.result()
+ t2_at = task2.result()
+ self.assertLess(t2_at, t1_at)
+
+ def test_submit_preempts_first_while_waiting(self):
+ task1 = self.executor.submit_after(0.5, returns_clock)
+ time.sleep(0.2)
+ task2 = self.executor.submit_after(0.1, returns_clock)
+
+ t1_at = task1.result()
+ t2_at = task2.result()
+ self.assertLess(t2_at, t1_at)
+
+ def test_zero_delay_executes(self):
+ """A zero-delay task executes without error."""
+ task = self.executor.submit_after(0, returns_one)
+ self.assertEqual(1, task.result(timeout=5))
+
+ def test_multiple_tasks_execute_in_delay_order(self):
+ """Three tasks with distinct delays finish in deadline order."""
+ # Submit in non-deadline order to prove the scheduler sorts by
+ # deadline, not by submission order: task_long is submitted first
+ # but has the furthest deadline, so task_medium (submitted second)
+ # must execute before it.
+ task_long = self.executor.submit_after(0.3, returns_clock)
+ task_medium = self.executor.submit_after(0.2, returns_clock)
+ task_short = self.executor.submit_after(0.1, returns_clock)
+
+ t_short = task_short.result()
+ t_medium = task_medium.result()
+ t_long = task_long.result()
+
+ self.assertLess(t_short, t_medium)
+ self.assertLess(t_medium, t_long)
+
+ def test_delayed_even_with_idle_workers(self):
+ """Non-zero delay is respected even when the executor has free workers.
+
+ With max_workers=4, all worker slots are available immediately.
+ The task must still wait for its full delay before executing.
+ """
+ executor = self.executor_cls(max_workers=4)
+ self.addCleanup(executor.shutdown, wait=True)
+
+ delay = 0.2
+ start = time.monotonic()
+ task = executor.submit_after(delay, returns_clock)
+ ran_at = task.result(timeout=10)
+ elapsed = ran_at - start
+
+ self.assertGreaterEqual(
+ elapsed,
+ delay,
+ "task ran before its scheduled delay elapsed",
+ )
+
+ def test_submit_after_raises_after_shutdown(self):
+ """submit_after raises RuntimeError once the executor is shut down."""
+ # Shut down the executor created in setUp and verify a subsequent
+ # submit_after call raises RuntimeError. The addCleanup will call
+ # shutdown again, which is a safe no-op.
+ self.executor.shutdown(wait=True)
+ self.assertRaises(
+ RuntimeError, self.executor.submit_after, 0, returns_one
+ )
+
+ def test_cancel_before_deadline_skips_execution(self):
+ """Cancelling a future before its deadline prevents the callable
+ from running.
+ """
+ call_count = []
+
+ def fn():
+ call_count.append(1)
+ return 1
+
+ task = self.executor.submit_after(0.5, fn)
+ cancelled = task.cancel()
+
+ self.assertTrue(cancelled)
+ # Wait well past the deadline so the scheduler processes the
+ # cancellation even on a loaded CI system (0.5s deadline + 0.5s
+ # margin = 1.0s total).
+ time.sleep(1.0)
+ self.assertTrue(task.cancelled())
+ self.assertEqual([], call_count)
+
+ def test_exception_propagation(self):
+ """An exception raised by the callable is reflected in
+ future.exception().
+ """
+ task = self.executor.submit_after(0.1, blows_up)
+ self.assertRaises(RuntimeError, task.result)
+ self.assertIsInstance(task.exception(), RuntimeError)
+
+ def test_concurrent_submissions_all_complete(self):
+ """Many tasks submitted in rapid succession all complete."""
+ num_tasks = 20
+ futures_list = [
+ self.executor.submit_after(0.1, returns_one)
+ for _ in range(num_tasks)
+ ]
+ results = [f.result(timeout=30) for f in futures_list]
+ self.assertEqual([1] * num_tasks, results)
+
+ def test_executor_forwards_args_and_kwargs(self):
+ """The callable is invoked with the exact positional and keyword
+ args given to submit_after.
+ """
+ received = []
+
+ def capture(pos, *, kw):
+ received.append((pos, kw))
+ return pos + kw
+
+ task = self.executor.submit_after(0.1, capture, 7, kw=3)
+ result = task.result()
+
+ self.assertEqual(10, result)
+ self.assertEqual([(7, 3)], received)
+
+ def test_negative_delay_raises_value_error(self):
+ """submit_after rejects negative delay values immediately."""
+ self.assertRaises(
+ ValueError, self.executor.submit_after, -1, returns_one
+ )
+
+ def test_double_shutdown_is_safe(self):
+ """Calling shutdown() twice must not raise or deadlock."""
+ self.executor.shutdown(wait=True)
+ # Second call — must be a safe no-op.
+ self.executor.shutdown(wait=True)
+
+ def test_alive_false_after_no_wait_shutdown(self):
+ self.executor.shutdown(wait=False)
+ self.executor.shutdown(wait=True) # drain
+ self.assertFalse(self.executor.alive)
+
+ def test_scheduler_thread_is_daemon(self):
+ if not isinstance(self.executor._scheduler, threading.Thread):
+ self.skipTest(
+ "daemon attribute only applies to native threading.Thread; "
+ "eventlet GreenThreads exit with the hub unconditionally"
+ )
+ self.assertTrue(
+ self.executor._scheduler.daemon,
+ "scheduler thread must be a daemon so the process can exit "
+ "cleanly if shutdown() is never called",
+ )
+
+ def test_shutdown_cancel_futures_cancels_pending_tasks(self):
+ """shutdown(cancel_futures=True) cancels tasks still in the queue."""
+ # Use a very long delay so both tasks are guaranteed to remain in the
+ # scheduler queue (i.e. well before their deadline) when shutdown is
+ # called.
+ task1 = self.executor.submit_after(100.0, returns_one)
+ task2 = self.executor.submit_after(100.0, returns_one)
+
+ self.executor.shutdown(cancel_futures=True, wait=True)
+
+ self.assertTrue(task1.cancelled(), "task1 should have been cancelled")
+ self.assertTrue(task2.cancelled(), "task2 should have been cancelled")
+
+ def test_alive_true_before_shutdown(self):
+ """The executor is alive as soon as it is created."""
+ self.assertTrue(self.executor.alive)
+
+ def test_alive_false_immediately_after_wait_true_shutdown(self):
+ self.assertTrue(self.executor.alive)
+ self.executor.shutdown(wait=True)
+ self.assertFalse(self.executor.alive)
+
+ def test_submit_after_raises_after_wait_false_shutdown(self):
+ self.executor.shutdown(wait=False)
+ self.assertRaises(
+ RuntimeError, self.executor.submit_after, 0, returns_one
+ )
+
+ def test_task_cancelled_while_in_queue_is_skipped(self):
+ call_count = []
+
+ def fn():
+ call_count.append(1)
+
+ # quick (0.1 s) has a shorter deadline than slow (100 s).
+ # The scheduler pops quick first, leaving slow in the queue.
+ quick = self.executor.submit_after(0.1, returns_one)
+ slow = self.executor.submit_after(100.0, fn)
+
+ # Cancel slow while the scheduler is busy waiting on quick's deadline.
+ # future.cancel() does NOT notify _queue_changed, so the scheduler
+ # is not woken; slow simply sits in the queue as CANCELLED.
+ self.assertTrue(slow.cancel())
+
+ # Wait for quick to complete; the scheduler then loops and pops slow.
+ self.assertEqual(1, quick.result(timeout=5))
+
+ # Shut down and wait for the scheduler to drain fully rather than
+ # sleeping for a fixed time, which would be racy on a loaded system.
+ self.executor.shutdown(wait=True)
+
+ self.assertTrue(slow.cancelled())
+ self.assertEqual([], call_count, "slow callable must not have run")
+
+ def test_task_cancelled_during_scheduler_wakeup_is_skipped(self):
+ call_count = []
+
+ def fn():
+ call_count.append(1)
+ return 1
+
+ # task_a has a long deadline — the scheduler pops it and waits.
+ task_a = self.executor.submit_after(5.0, fn)
+ # Ensure the scheduler has had time to pop task_a and begin waiting.
+ time.sleep(0.05)
+
+ # Cancel task_a (does NOT wake the scheduler on its own).
+ self.assertTrue(task_a.cancel())
+
+ # Submitting task_b calls notify_all, waking the scheduler.
+ # It finds changed=True and task_a.future.cancelled() → True,
+ # so it takes the L963-966 branch: continue without executing task_a.
+ task_b = self.executor.submit_after(0.1, returns_one)
+
+ self.assertEqual(1, task_b.result(timeout=5))
+ self.assertTrue(task_a.cancelled())
+ self.assertEqual([], call_count, "task_a callable must not have run")
+
+ def test_running_task_not_cancelled_by_cancel_futures(self):
+ executor = self.executor_cls(max_workers=2)
+ self.addCleanup(executor.shutdown, wait=True)
+
+ # Use futures compatible with the executor's threading model as
+ # synchronisation points. In the green variant these are
+ # GreenFuture objects whose .result() yields to the eventlet hub,
+ # so the test and the worker interleave correctly without
+ # time.sleep() blocking the entire event loop.
+ running_signal = executor._get_future_object()
+ hold_signal = executor._get_future_object()
+
+ # Release hold_signal in cleanup so the worker is never left dangling
+ # if the test fails before the explicit set_result() call below.
+ def _release_hold():
+ try:
+ hold_signal.set_result(None)
+ except Exception:
+ pass # already set by the test body
+
+ self.addCleanup(_release_hold)
+
+ def controlled_task():
+ running_signal.set_result(True) # signal: task is now running
+ hold_signal.result(timeout=10) # park until the test proceeds
+
+ slow_future = executor.submit_after(0.0, controlled_task)
+ # Block until controlled_task has actually started executing. In the
+ # green variant GreenFuture.result() yields to the hub so the green
+ # worker can run and signal without blocking the event loop.
+ running_signal.result(timeout=10)
+
+ # At this point slow_future is in RUNNING state: the scheduler called
+ # set_running_or_notify_cancel() on it before dispatching to the inner
+ # executor, and controlled_task() is still parked at hold_signal.
+ # It is no longer in the delay queue, so cancel_futures=True cannot
+ # affect it.
+ self.assertTrue(
+ slow_future.running(),
+ "slow_future must be in RUNNING state before shutdown is called",
+ )
+
+ # Release the parked task so that shutdown(wait=True) can complete.
+ hold_signal.set_result(None)
+ executor.shutdown(cancel_futures=True, wait=True)
+
+ self.assertFalse(slow_future.cancelled())
+ self.assertTrue(slow_future.done())
+
+ def test_inner_submit_failure_propagated_to_future(self):
+ with mock.patch.object(
+ self.executor,
+ 'submit',
+ side_effect=RuntimeError("injected dispatch failure"),
+ ):
+ future = self.executor.submit_after(0.0, returns_one)
+ exc = future.exception(timeout=5)
+
+ self.assertIsInstance(exc, RuntimeError)
+ self.assertIn("injected dispatch failure", str(exc))
+
+ def test_sentinel_task_has_no_future_attribute(self):
+ self.assertFalse(
+ hasattr(self.executor._sentinel, 'future'),
+ "_SentinelTask must not define a .future attribute — "
+ "the cancel-loop guard `if item is not self._sentinel` "
+ "exists precisely because it is absent.",
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist/tests/test_waiters.py
new/futurist-3.5.0/futurist/tests/test_waiters.py
--- old/futurist-3.4.0/futurist/tests/test_waiters.py 2026-06-26
15:32:16.000000000 +0200
+++ new/futurist-3.5.0/futurist/tests/test_waiters.py 2026-08-20
17:19:07.492014200 +0200
@@ -10,6 +10,7 @@
# License for the specific language governing permissions and limitations
# under the License.
+import multiprocessing
import time
import eventlet
@@ -71,7 +72,14 @@
'process',
{
'executor_cls': futurist.ProcessPoolExecutor,
- 'executor_kwargs': {},
+ 'executor_kwargs': {
+ # Use 'spawn' rather than the platform default 'fork'.
+ # This test module imports eventlet at module level, which
+ # creates background threads; forking a multithreaded
+ # process can deadlock the child due to inherited locks.
+ # 'spawn' starts a clean interpreter and avoids this.
+ 'mp_context': multiprocessing.get_context('spawn'),
+ },
'use_eventlet_sleep': False,
},
),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist.egg-info/PKG-INFO
new/futurist-3.5.0/futurist.egg-info/PKG-INFO
--- old/futurist-3.4.0/futurist.egg-info/PKG-INFO 2026-06-26
15:33:06.000000000 +0200
+++ new/futurist-3.5.0/futurist.egg-info/PKG-INFO 2026-08-20
17:19:40.564633600 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: futurist
-Version: 3.4.0
+Version: 3.5.0
Summary: Useful additions to futures, from the future.
Author-email: OpenStack <[email protected]>
License: Apache-2.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist.egg-info/SOURCES.txt
new/futurist-3.5.0/futurist.egg-info/SOURCES.txt
--- old/futurist-3.4.0/futurist.egg-info/SOURCES.txt 2026-06-26
15:33:06.000000000 +0200
+++ new/futurist-3.5.0/futurist.egg-info/SOURCES.txt 2026-08-20
17:19:40.586634200 +0200
@@ -48,6 +48,7 @@
futurist/tests/test_waiters.py
releasenotes/notes/add-mp_context-arg-db2ca2d0b3f24b93.yaml
releasenotes/notes/add-reno-996dd44974d53238.yaml
+releasenotes/notes/delayed-executor-mixin-a4f3c8d1b9e20571.yaml
releasenotes/notes/deprecate-eventlet-d96deb8b97930fef.yaml
releasenotes/notes/drop-python-3.10-1ffc51bb720262e3.yaml
releasenotes/notes/drop-python27-support-5757997ea990b7ca.yaml
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/futurist.egg-info/pbr.json
new/futurist-3.5.0/futurist.egg-info/pbr.json
--- old/futurist-3.4.0/futurist.egg-info/pbr.json 2026-06-26
15:33:06.000000000 +0200
+++ new/futurist-3.5.0/futurist.egg-info/pbr.json 2026-08-20
17:19:40.570633600 +0200
@@ -1 +1 @@
-{"git_version": "85f1968", "is_release": true}
\ No newline at end of file
+{"git_version": "b429036", "is_release": true}
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/futurist-3.4.0/pyproject.toml
new/futurist-3.5.0/pyproject.toml
--- old/futurist-3.4.0/pyproject.toml 2026-06-26 15:32:16.000000000 +0200
+++ new/futurist-3.5.0/pyproject.toml 2026-08-20 17:19:07.492014200 +0200
@@ -45,6 +45,7 @@
[tool.ruff.lint]
select = ["E4", "E5", "E7", "E9", "F", "G", "LOG", "S", "UP"]
+external = ["H"]
ignore = [
"S101", # asserts are only used for type narrowing
]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/futurist-3.4.0/releasenotes/notes/delayed-executor-mixin-a4f3c8d1b9e20571.yaml
new/futurist-3.5.0/releasenotes/notes/delayed-executor-mixin-a4f3c8d1b9e20571.yaml
---
old/futurist-3.4.0/releasenotes/notes/delayed-executor-mixin-a4f3c8d1b9e20571.yaml
1970-01-01 01:00:00.000000000 +0100
+++
new/futurist-3.5.0/releasenotes/notes/delayed-executor-mixin-a4f3c8d1b9e20571.yaml
2026-08-20 17:19:07.493014000 +0200
@@ -0,0 +1,32 @@
+---
+features:
+ - |
+ Adds ``DelayedExecutorMixin`` and ``GreenDelayedExecutorMixin`` mixin
+ classes that extend any :class:`~concurrent.futures.Executor` subclass
+ with a ``submit_after(delay, fn, *args, **kwargs)`` method.
+
+ A single background scheduler thread maintains a min-heap of pending
+ tasks ordered by their deadline. When a task's deadline arrives the
+ scheduler submits it to the underlying executor. Tasks can be
+ cancelled while waiting for their deadline via the returned
+ :class:`~concurrent.futures.Future`.
+
+ Use ``DelayedExecutorMixin`` with native-thread executors (e.g.
+ :class:`~futurist.ThreadPoolExecutor`) and ``GreenDelayedExecutorMixin``
+ with eventlet-based executors (e.g.
+ :class:`~futurist.GreenThreadPoolExecutor`). Each ``submit_after``
+ call accepts its own *delay* value, so different tasks submitted to the
+ same executor can have different delays.
+
+ Example::
+
+ class DelayedThreadExecutor(
+ futurist.DelayedExecutorMixin,
+ futurist.ThreadPoolExecutor,
+ ):
+ pass
+
+ executor = DelayedThreadExecutor(max_workers=4)
+ fut = executor.submit_after(0.5, my_func, arg1, kwarg=val)
+ result = fut.result()
+ executor.shutdown()