TODO This document was generated by AI - REVIEW and "HUMANIZE"
Hierarchical async Promise management for Python.
Promising extends asyncio.Future with automatic parent-child relationships between asynchronous operations. When a Promise creates other Promises during its execution, those become its children — tracked via context variables, forming a tree you can await, inspect, or configure as a unit.
pip install promisingRequires Python 3.11+.
Decorate an async function with @promising.function to make it return a Promise instead of a coroutine:
import asyncio
import promising
@promising.function
async def fetch_data(url: str) -> dict:
# ... fetch something ...
return {"url": url, "status": "ok"}
async def main():
promise = fetch_data("https://example.com") # Returns a Promise, not a coroutine
result = await promise # Await it to get the result
print(result)
asyncio.run(main())A Promise can be awaited multiple times without re-executing the function:
promise = fetch_data("https://example.com")
result1 = await promise # Executes the function
result2 = await promise # Returns the cached result
assert result1 is result2The core feature. When a Promise creates other Promises during its execution, those are automatically registered as children:
@promising.function
async def child_task(name: str) -> str:
return f"done: {name}"
@promising.function
async def parent_task() -> str:
# These Promises become children of parent_task's Promise
a = child_task("a")
b = child_task("b")
return "parent done"
promise = parent_task()
await promise
# The parent-child relationship is tracked automaticallyBy default, a parent resolves as soon as its own coroutine finishes — children may still be running. Use await_children() to wait for all children before the parent resolves:
@promising.function
async def parent_task() -> str:
child_task("a")
child_task("b")
# Wait for all children to complete before returning
await promising.await_children()
return "all done"Use recursively=True to wait for the entire subtree (children, grandchildren, etc.):
await promising.await_children(recursively=True)Note:
await_children()andawait_children_sync()are purely for timing/synchronization — they do not propagate child exceptions. Internally they usereturn_exceptions=Trueso that all children are awaited even when some fail, but the exceptions are discarded. To handle errors from children, capture theirPromisereferences and await them directly:@promising.function async def parent_task() -> str: a = child_task("a") b = child_task("b") # Await each child to propagate its exceptions await a await b return "all done"
@promising.function works on regular (non-async) functions too. They run in a thread pool while still participating in the Promise hierarchy:
@promising.function
def compute(x: int, y: int) -> int:
# Runs in a ThreadPoolExecutor
return x + y
async def main():
result = await compute(3, 4)
assert result == 7Inside a sync promising function, use .sync() instead of await to get a child Promise's result:
@promising.function
async def async_greet(name: str) -> str:
return f"hello, {name}"
@promising.function
def sync_caller() -> str:
greeting = async_greet("world", start_soon=False)
return greeting.sync() # Blocks until resolvedThe sync counterpart of await_children() is also available:
@promising.function
def sync_parent() -> str:
child_task("a")
child_task("b")
promising.await_children_sync()
return "done"sync(), await_children_sync(), concurrent_future.result(), and concurrent_future.exception() all guard against being called from the event loop thread (which would deadlock) by raising SyncUsageError.
By default, sync promising functions run in a global ThreadPoolExecutor (Defaults.SYNC_THREAD_POOL). You can control which thread pool is used via the thread_pool parameter on @promising.function, promising.context, or Promise:
from concurrent.futures import ThreadPoolExecutor
import promising
from promising import ASYNCIO_DEFAULT, GLOBAL_DEFAULT
# Use a custom thread pool for a specific function
my_pool = ThreadPoolExecutor(max_workers=4)
@promising.function(thread_pool=my_pool)
def cpu_bound_work(data: list) -> list:
return sorted(data)
# Let the event loop use its own default executor
@promising.function(thread_pool=ASYNCIO_DEFAULT)
def io_work() -> str:
...
# Override at call time
result = await cpu_bound_work(data, thread_pool=ASYNCIO_DEFAULT)Thread pool settings inherit through the context hierarchy. A promising.context can set the thread pool for all sync functions within its scope:
custom_pool = ThreadPoolExecutor(max_workers=8)
with promising.context(thread_pool=custom_pool):
# All sync promising functions here use custom_pool
await compute(3, 4)The thread_pool parameter accepts:
INHERIT(default) — inherit from the parent context; falls back toGLOBAL_DEFAULTat the root.GLOBAL_DEFAULT— useDefaults.SYNC_THREAD_POOL.ASYNCIO_DEFAULT— passNonetorun_in_executor, letting the event loop use its own default executor.- A concrete
ThreadPoolExecutorinstance.
@promising.function works with instance methods, @classmethod, and @staticmethod. Decorator order doesn't matter:
class MyService:
@promising.function
async def instance_method(self) -> str:
return "instance"
# Either order works for classmethod and staticmethod
@classmethod
@promising.function
async def from_config(cls, path: str) -> "MyService":
return cls()
@promising.function
@staticmethod
async def utility(x: int) -> int:
return x * 2promising.context creates a PromisingContext — a lightweight node in the hierarchy that is not an asyncio.Future. Its main use is scoping start_soon-related configuration locally, so that Promises created within it inherit specific defaults. It also lets you group child Promises under a shared context for awaiting or inspection.
import promising
@promising.function
async def child_task(name: str) -> str:
return f"done: {name}"
async def main():
# All children created inside default to start_soon=False
with promising.context(children_start_soon=False) as ctx:
a = child_task("a") # deferred — won't start until awaited
b = child_task("b") # same
result_a = await a
result_b = await bNesting works as expected — inner contexts become children of outer ones:
with promising.context() as outer:
with promising.context() as inner:
assert promising.get_active_context() is inner
assert inner.get_parent_context() is outer
assert promising.get_active_context() is outer@promising.context wraps a function so that each call runs inside a fresh PromisingContext. This works on both async and sync functions:
@promising.context(children_start_soon=False)
async def do_work() -> str:
# Children created here inherit start_soon=False
a = child_task("x")
b = child_task("y")
return "done"
await do_work()
# Await all children in all contexts before finishing
await promising.await_children(recursively=True)
# TODO Awaiting children will fail here because there is no active context -
# fix it by describing how to set up the application properlyLike @promising.function, it works with @classmethod, @staticmethod, and instance methods in either decorator order.
The key difference: @promising.function creates a Promise (an asyncio.Future that can be awaited and appears in the parent promise chain), while @promising.context creates a bare PromisingContext that only participates in the context hierarchy. Calling a @promising.context-decorated function executes the function as is and returns its result directly (not wrapped in a Promise). If the decorated function is async, the result will still need to be awaited, though. Use @promising.context when you want to scope configuration or group children without the function itself becoming a Promise.
By default, Promises start executing immediately upon creation (at the nearest event loop opportunity). This is controlled by the start_soon parameter:
# Starts immediately (default behavior)
promise = fetch_data("https://example.com")
# Defers execution until awaited
@promising.function(start_soon=False)
async def lazy_fetch(url: str) -> dict:
...
promise = lazy_fetch("https://example.com") # Not running yet
result = await promise # Now it startsPromises inherit configuration from their parents through three parameters:
start_soon— whether the Promise starts executing immediately upon creation. When left asNOT_SET(the default), it defers to its parent'schildren_start_soon, or falls back tostart_soon_default.INHERITcopies the parent'sstart_soondirectly.children_start_soon— enforces astart_soondefault for child Promises that left theirstart_soonasNOT_SET.NOT_SETmeans no enforcement.INHERITcopies the parent'schildren_start_soonsetting. Note:Promisedefaults toNOT_SET(no enforcement unless explicitly chosen), whilePromisingContext/promising.contextdefaults toINHERIT(transparent pass-through of the parent's policy).start_soon_default— a per-Promise local override for the global default.INHERIT(default) propagates from the parent.GLOBAL_DEFAULTreads the current global setting directly, ignoring the parent chain.
These can be set on the decorator or overridden at call time by passing them as keyword arguments. Call-time values always take precedence over decorator-level defaults — even passing NOT_SET explicitly at call time overrides the decorator value:
@promising.function(children_start_soon=False)
async def parent() -> str:
# All children created here will default to start_soon=False
a = child_task("a") # Deferred (inherits from parent)
b = child_task("b", start_soon=True) # Overridden: starts immediately
...The global default can be changed:
import promising
# All Promises start immediately by default (this is the initial value)
promising.Defaults.START_SOON = True
# Change to lazy execution globally
promising.Defaults.START_SOON = FalseEvery Promise has a PromiseBackedConcurrentFuture (a concurrent.futures.Future subclass) for use from non-async threads:
import threading
async def main():
promise = fetch_data("https://example.com")
concurrent_future = promise.as_concurrent_future()
def worker():
# Blocks until the Promise resolves (thread-safe)
result = concurrent_future.result(timeout=5.0)
print(result)
thread = threading.Thread(target=worker)
thread.start()
await promise
thread.join()promise.sync(), concurrent_future.result(), and concurrent_future.exception() all raise SyncUsageError if called from the event loop thread (which would deadlock).
You can create Promises without @promising.function by passing a coroutine or a prefilled value:
from promising import Promise
# From a coroutine
async def my_coro() -> str:
return "hello"
promise = Promise(my_coro(), start_soon=True)
result = await promise
# Prefilled with a result (immediately resolved)
promise = Promise(prefill_result=42)
assert promise.done()
assert promise.result() == 42
# Prefilled with an exception
promise = Promise(prefill_exception=ValueError("oops"))
# Explicit parent (overrides automatic context-based detection)
parent = Promise(prefill_result="parent")
child = Promise(my_coro(), parent=parent)
# No parent (opt out of automatic parent detection)
orphan = Promise(my_coro(), parent=None)The examples/ directory contains runnable examples. To install example dependencies:
uv sync --extra examplesexamples/keyword_agent.py— an LLM-powered keyword extraction agent using@promising.functionwithlitellmandpydantic.examples/htmx_ui/— a web UI example usingpython-fasthtmland HTMX.
All configuration — start_soon, children_start_soon, start_soon_default, thread_pool, etc. — is resolved and frozen the moment a Promise or PromisingContext is created. Sentinels like INHERIT and GLOBAL_DEFAULT are replaced with concrete values immediately, so later changes to Defaults or parent contexts have no effect on already-created promises.
This is intentional: because a Promise may execute eagerly (the default) or be deferred, the user cannot predict when the underlying coroutine will run. Freezing settings at creation time guarantees that the behavior a promise was created with is the behavior it runs with, regardless of scheduling.
| Symbol | Description |
|---|---|
promising.function |
Decorator that wraps async or sync functions to return Promise objects. Usable as @promising.function or @promising.function(start_soon=...). |
promising.PromisingFunction |
The wrapper class created by the decorator. Implements the descriptor protocol for method support. |
promising.context |
Context manager and decorator that creates a PromisingContext without producing a Promise. Usable as with promising.context(): or @promising.context(). Accepts namespace, loop, parent, thread_pool, children_start_soon, and start_soon_default. |
Promise extends both PromisingContext and asyncio.Future. It inherits all hierarchy and configuration methods from PromisingContext (see below) and adds coroutine execution and thread-safe access.
| Method / Property | Description |
|---|---|
await promise |
Wait for and return the result. Can be awaited multiple times. |
promise.sync(timeout=None) |
Synchronous counterpart of await — blocks the calling thread. Must not be called from the event loop thread. |
promise.done() |
Whether the Promise has resolved (inherited from asyncio.Future). |
promise.result() |
The resolved value (inherited from asyncio.Future). |
promise.as_concurrent_future() |
Get a thread-safe PromiseBackedConcurrentFuture view. |
PromisingContext is the base class that manages the parent-child hierarchy, configuration inheritance, and context variable tracking. Promise inherits from it. It can also be used standalone as a lightweight context node that participates in the hierarchy without being an asyncio.Future.
| Method / Property | Description |
|---|---|
ctx.namespace |
Optional human-readable namespace string. Used in __repr__ output. Set via the namespace constructor parameter. |
ctx.get_parent_context(raise_if_none=True) |
Get the immediate parent context (may be a PromisingContext or a Promise). |
ctx.get_parent_promise(raise_if_none=True) |
Get the nearest ancestor that is a Promise (walks up past non-Promise contexts). |
ctx.await_children(recursively=False) |
Async — wait for child contexts to finish. |
ctx.await_children_sync(recursively=False, timeout=None) |
Sync — block until child contexts finish. |
ctx.collect_remaining_children(recursively=False, exclude_non_awaitable=True, exclude_done=True) |
Get the set of child contexts that are still reachable and (optionally) still running. |
ctx.get_thread_pool_executor() |
Return the resolved thread pool executor for this context (ThreadPoolExecutor, or None if ASYNCIO_DEFAULT). |
| Function | Description |
|---|---|
promising.get_active_context(raise_if_none=True) |
Get the currently active PromisingContext (may be a PromisingContext or a Promise). |
promising.get_active_promise(raise_if_none=True) |
Get the currently active Promise (walks up the parent chain past non-Promise contexts). |
promising.await_children(recursively=False) |
Wait for all children of the current context. |
promising.await_children_sync(recursively=False, timeout=None) |
Sync counterpart — block until children finish. |
promising.Defaults.START_SOON |
Class attribute holding the global default for eager execution (True by default). Set it to False to switch to lazy execution globally. |
| Sentinel | Meaning |
|---|---|
promising.NOT_SET |
No value provided / no enforcement. |
promising.INHERIT |
Copy from the parent context; fall back to the global default when there is no parent. |
promising.GLOBAL_DEFAULT |
Read the current global setting directly, ignoring the parent chain. |
promising.ASYNCIO_DEFAULT |
Let the event loop use its own default executor (passes None to run_in_executor). Used with the thread_pool parameter. |
promising.Sentinel |
The sentinel class. All sentinels above are instances of it. |
All sentinels raise RuntimeError on boolean coercion to prevent misuse.
| Error | Raised When |
|---|---|
promising.ContextNotFoundError |
No active PromisingContext is found (e.g. calling get_active_context() or await_children() outside a promising function). |
promising.ContextAlreadyActiveError |
Attempting to enter a PromisingContext that is already active (e.g. nested with ctx: on the same instance). |
promising.ContextNotActiveError |
Attempting to exit a PromisingContext that is not active. |
promising.ContextUsageError |
Misuse of promising.context (e.g. using the same instance as both context manager and decorator, or incorrect argument usage). |
promising.DecorationError |
Invalid decorator usage (e.g. passing a non-callable to @promising.function or @promising.context). |
promising.PromiseNotFoundError |
No active Promise is found (e.g. calling get_active_promise() when the active context is not a Promise). |
promising.SyncUsageError |
sync() or await_children_sync() is called from the event loop thread, which would deadlock. |
All inherit from promising.BasePromisingError.
MIT