Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Deploy docs to GitHub Pages

on:
push:
branches: [main, docs]

permissions:
contents: read
pages: write
id-token: write

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.12"
- run: pip install ".[docs]"

- run: mkdocs build

- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: ./site
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

# - run: mkdocs gh-deploy --force
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ build/
*egg-info/*
.coverage
.mypy_cache/*
site/

49 changes: 49 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# API Reference

All public objects are importable from the top-level `extracontext` package.

## ContextLocal

The main entry point. A factory that returns either a `NativeContextLocal` or `PyContextLocal` instance depending on the `backend` argument.

::: extracontext.ContextLocal

---

## ContextMap

A `ContextLocal` subclass implementing `collections.abc.MutableMapping`, providing dictionary-style access alongside attribute access.

::: extracontext.ContextMap

---

## ContextPreservingExecutor

A `concurrent.futures.ThreadPoolExecutor` subclass that propagates the current context into worker threads.

::: extracontext.ContextPreservingExecutor

---

## ContextError

Exception raised when a context variable is accessed outside a valid scope.

::: extracontext.ContextError

---

## NativeContextLocal

The default backend implementation, built on stdlib `contextvars.ContextVar`.

::: extracontext.NativeContextLocal

---

## PyContextLocal

The pure-Python backend implementation, using frame introspection.

::: extracontext.PyContextLocal
72 changes: 72 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# extracontext

**Context Variable namespaces supporting generators, asyncio and multi-threading.**

`extracontext` provides utilities and facilities to isolated context variables added to Python 3.7, including an isolated namespace, no need to manually calling getters and setters, straightforward function calling and context managers.

In other words, it provides a [PEP 567](https://peps.python.org/pep-0567/)-compliant, drop-in replacement for `threading.local` namespaces that also works seamlessly with asyncio tasks and generators.

## Why extracontext?

Python's built-in `contextvars` module requires verbose boilerplate: each variable must be declared at the top level, and `.get()`/`.set()` methods must be used explicitly. Isolating a function from leaking context changes requires the caller to use `Context.run()`.

`extracontext` restores the simplicity of attribute access and `=` assignment, while providing the same concurrency safety:

```python
# stdlib contextvars — verbose
import contextvars
ctx_color = contextvars.ContextVar("ctx_color")
ctx_color.set("red")
print(ctx_color.get())
```

```python
# extracontext — simple
from extracontext import ContextLocal
ctx = ContextLocal()
ctx.color = "red"
print(ctx.color)
```

Context isolation is declared on the callee, not the caller:

```python
from extracontext import ContextLocal

ctx = ContextLocal()

@ctx
def render_markup(text):
# Changes here never leak back to the caller
ctx.color = "blue"
...

ctx.color = "red"
render_markup(my_text)
assert ctx.color == "red" # unchanged
```

## Installation

```
pip install python-extracontext
```

## Quick Start

```python
from extracontext import ContextLocal

ctx = ContextLocal()

@ctx
def worker(value):
ctx.result = value * 2
return ctx.result

ctx.result = 0
worker(21)
assert ctx.result == 0 # not affected by the isolated call
```

See the [Usage Guide](usage.md) for more examples, or the [API Reference](api.md) for the full public API.
167 changes: 167 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Usage Guide

## Basic Usage

Instantiate a `ContextLocal` namespace once (typically at module level). Any attribute set on it is visible only within the current thread or asyncio task:

```python
from extracontext import ContextLocal

ctx = ContextLocal()

def myworker():
ctx.value = "test" # only visible in this thread/task
```

## Decorator: Isolated Function Scope

Decorate a function with the `ContextLocal` instance to execute it in an isolated copy of the context. Changes made inside the function are never visible to the caller:

```python
from extracontext import ContextLocal

ctx = ContextLocal()

@ctx
def isolated_example():
ctx.value = 2
assert ctx.value == 2

ctx.value = 1
isolated_example()
assert ctx.value == 1 # unchanged
```

The decorator works for plain functions, generator functions, coroutine functions, and async generator functions.

## Context Manager: Isolated `with` Block

Use the `ContextLocal` instance as a context manager to isolate changes within a `with` block:

```python
from extracontext import ContextLocal

ctx = ContextLocal()
ctx.value = 1

with ctx:
ctx.value = 2
assert ctx.value == 2

assert ctx.value == 1 # restored
```

Context managers are re-entrant, so nested `with ctx:` blocks work correctly.

## Generator Isolation

Unlike stdlib `contextvars`, `extracontext` properly isolates context for each generator instance across `yield` points:

```python
from extracontext import ContextLocal

ctx = ContextLocal()
results = []

@ctx
def contexted_generator(value):
ctx.value = value
yield
results.append(ctx.value)

generators = [contexted_generator(i) for i in range(10)]
any(next(gen) for gen in generators)
any(next(gen, None) for gen in generators)

assert results == list(range(10)) # each generator kept its own value
```

This also works with async generators.

## Asyncio Support

`ContextLocal` instances work transparently with asyncio tasks — each task sees its own isolated copy of the context:

```python
import asyncio
from extracontext import ContextLocal

ctx = ContextLocal()

async def task(value):
ctx.value = value
await asyncio.sleep(0)
assert ctx.value == value # other tasks don't interfere

async def main():
await asyncio.gather(*[task(i) for i in range(10)])

asyncio.run(main())
```

## ContextMap: Dictionary-Style Access

`ContextMap` is a `ContextLocal` subclass implementing `collections.abc.MutableMapping`. It supports both `ctx["key"]` and `ctx.key` access:

```python
from extracontext import ContextMap

ctx = ContextMap()

def myworker():
ctx["value"] = "test"
assert ctx.value == "test" # attribute access also works
```

Optionally initialize with an existing mapping:

```python
ctx = ContextMap({"color": "red", "font": "arial"})
```

All standard mapping methods are available: `keys()`, `values()`, `items()`, `get()`, `pop()`, `clear()`, `update()`, `setdefault()`.

## Cross-Thread Context Preservation

When using `asyncio` with a thread pool executor, context normally doesn't propagate to the worker thread. `ContextPreservingExecutor` fixes this:

```python
import asyncio
import random
import time
from extracontext import ContextLocal, ContextPreservingExecutor

ctx = ContextLocal()

def sync_part():
time.sleep(random.random())
print(ctx.value) # sees the calling task's context

async def async_task(executor, value):
ctx.value = value
loop = asyncio.get_running_loop()
await loop.run_in_executor(executor, sync_part)

async def main():
with ContextPreservingExecutor() as executor:
async with asyncio.TaskGroup() as tg:
for value in range(10):
tg.create_task(async_task(executor, value))

asyncio.run(main())
```

Each thread call sees the context of its originating asyncio task.

> **Note:** `ContextPreservingExecutor` requires the default `"native"` backend. The pure Python backend does not support shared values across threads.

## Choosing a Backend

By default, `ContextLocal` uses the `"native"` backend (based on stdlib `contextvars`). You can explicitly select the pure-Python backend:

```python
ctx = ContextLocal(backend="python") # pure Python reimplementation
ctx = ContextLocal(backend="native") # stdlib contextvars (default)
```

The native backend is recommended for all production use. The Python backend is useful for debugging or environments where native contextvars have issues.
17 changes: 14 additions & 3 deletions extracontext/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,22 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._context = contextvars.copy_context()

def run(self):
ctx = self._context.copy()
result = ctx.run(super().run)
def run(self, ctx=None):
context_vars_ctx = self._context.copy()
# Python 3.14: the signature for _WorkItem.run changed, and
# it now needs an intemediate object that is passed in the "ctx" parameter.
result = context_vars_ctx.run(super().run, *((ctx,) if ctx else () ))
return result

import sys
"""
There are some changes in Python 3.14 to copy the context of the original thread,
but it still won't work with a task-specific context

"""
if sys.version_info >= (3,14):
pass


original_submit = ThreadPoolExecutor.submit
new_globals = concurrent.futures.thread.__dict__.copy()
Expand Down
35 changes: 35 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
site_name: extracontext
site_description: Context Variable namespaces supporting generators, asyncio and multi-threading
site_url: https://jsbueno.github.io/extracontext/
repo_url: https://github.com/jsbueno/extracontext
repo_name: jsbueno/extracontext

theme:
name: material
palette:
- scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
- scheme: slate
toggle:
icon: material/brightness-4
name: Switch to light mode
features:
- navigation.instant
- navigation.top
- content.code.copy

plugins:
- search
- mkdocstrings:
handlers:
python:
options:
show_source: true
show_root_heading: true

nav:
- Home: index.md
- Usage Guide: usage.md
- API Reference: api.md
Loading
Loading