Skip to content

teremterem/Promising

 
 

Repository files navigation

Promising

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.

Installation

pip install promising

Requires Python 3.11+.

Quick Start

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 result2

Parent-Child Hierarchy

The 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 automatically

Waiting for Children

By 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() and await_children_sync() are purely for timing/synchronization — they do not propagate child exceptions. Internally they use return_exceptions=True so that all children are awaited even when some fail, but the exceptions are discarded. To handle errors from children, capture their Promise references 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"

Sync Function Support

@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 == 7

Inside 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 resolved

The 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.

Thread Pool Configuration

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 to GLOBAL_DEFAULT at the root.
  • GLOBAL_DEFAULT — use Defaults.SYNC_THREAD_POOL.
  • ASYNCIO_DEFAULT — pass None to run_in_executor, letting the event loop use its own default executor.
  • A concrete ThreadPoolExecutor instance.

Method Decorators

@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 * 2

Lightweight Contexts: promising.context

promising.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.

As a Context Manager

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 b

Nesting 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

As a Decorator

@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 properly

Like @promising.function, it works with @classmethod, @staticmethod, and instance methods in either decorator order.

promising.context vs promising.function

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.

Execution Timing: start_soon

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 starts

Configuration Inheritance

Promises inherit configuration from their parents through three parameters:

  • start_soon — whether the Promise starts executing immediately upon creation. When left as NOT_SET (the default), it defers to its parent's children_start_soon, or falls back to start_soon_default. INHERIT copies the parent's start_soon directly.
  • children_start_soon — enforces a start_soon default for child Promises that left their start_soon as NOT_SET. NOT_SET means no enforcement. INHERIT copies the parent's children_start_soon setting. Note: Promise defaults to NOT_SET (no enforcement unless explicitly chosen), while PromisingContext / promising.context defaults to INHERIT (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_DEFAULT reads 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 = False

Thread-Safe Access

Every 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).

Working with Promise Directly

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)

Examples

The examples/ directory contains runnable examples. To install example dependencies:

uv sync --extra examples
  • examples/keyword_agent.py — an LLM-powered keyword extraction agent using @promising.function with litellm and pydantic.
  • examples/htmx_ui/ — a web UI example using python-fasthtml and HTMX.

Design Note: Settings Are Frozen at Creation Time

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.

API Reference

Decorator

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

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

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).

Top-Level Convenience Functions

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.

Sentinels

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.

Errors

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.

License

MIT

About

A new, general-purpose hierarchical async framework for Python, adapted to the emerging reality of building highly parallel LLM-based multi-agent systems.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Contributors

Languages