From 646efad5cb402c9b9783b9bdf8b5c046d81d2f89 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Sat, 28 Feb 2026 12:48:20 -0300 Subject: [PATCH 1/7] Auto generated docs --- .github/workflows/docs.yml | 19 +++++ .gitignore | 1 + docs/api.md | 49 +++++++++++ docs/index.md | 70 ++++++++++++++++ docs/usage.md | 167 +++++++++++++++++++++++++++++++++++++ mkdocs.yml | 35 ++++++++ pyproject.toml | 5 ++ 7 files changed, 346 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/api.md create mode 100644 docs/index.md create mode 100644 docs/usage.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..0dd3c0d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,19 @@ +name: Deploy docs to GitHub Pages + +on: + push: + branches: [main] + +permissions: + contents: 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 gh-deploy --force diff --git a/.gitignore b/.gitignore index b4d62d5..0298c93 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ build/ *egg-info/* .coverage .mypy_cache/* +site/ diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..2974880 --- /dev/null +++ b/docs/api.md @@ -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 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e01a13e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,70 @@ +# extracontext + +**Context Variable namespaces supporting generators, asyncio and multi-threading.** + +`extracontext` 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. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..7da01f7 --- /dev/null +++ b/docs/usage.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..944de09 --- /dev/null +++ b/mkdocs.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6a64ed8..2443f42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,11 @@ dev = [ "pyflakes", "pytest-coverage", ] +docs = [ + "mkdocs", + "mkdocs-material", + "mkdocstrings[python]", +] [tool.pytest.ini_options] testpaths = "tests" From 4fccc1042648402c4c22ee7c0bb9fee519d6658a Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Sat, 28 Feb 2026 12:52:48 -0300 Subject: [PATCH 2/7] Human written introduction --- docs/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index e01a13e..d8c8b5b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,9 @@ **Context Variable namespaces supporting generators, asyncio and multi-threading.** -`extracontext` 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. +`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? From 2930c179235551a78cacccc939a37499abf05e17 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Sat, 28 Feb 2026 13:07:09 -0300 Subject: [PATCH 3/7] testing gh actions --- .github/workflows/docs.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0dd3c0d..a30779e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,10 +2,12 @@ name: Deploy docs to GitHub Pages on: push: - branches: [main] + branches: [main, docs] permissions: - contents: write + contents: read + pages: write + id-token: write jobs: deploy: From 6a61bad306bc6a14e589e1d9098b5c4480092cbc Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Sat, 28 Feb 2026 13:22:14 -0300 Subject: [PATCH 4/7] let's take some other actions --- .github/workflows/docs.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a30779e..41ecbff 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,4 +18,15 @@ jobs: with: python-version: "3.12" - run: pip install ".[docs]" - - run: mkdocs gh-deploy --force + + - 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 From 0aaf7192ef0931f68a04399004930eb960c0a5ee Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Sat, 28 Feb 2026 13:26:56 -0300 Subject: [PATCH 5/7] who knows now? --- .github/workflows/docs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 41ecbff..80cb39f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,6 +19,8 @@ jobs: python-version: "3.12" - run: pip install ".[docs]" + - run: mkdocs build + - name: Setup Pages uses: actions/configure-pages@v4 - name: Upload artifact From 9fee5dfca4f4ae6a2ecc0f67ee63f57dc71bd809 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Sat, 28 Feb 2026 14:53:31 -0300 Subject: [PATCH 6/7] Adjusts for Python 3.14, fixes #7 --- extracontext/executor.py | 17 ++++++++++++++--- pyproject.toml | 6 +++++- tests/test_executor.py | 2 -- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/extracontext/executor.py b/extracontext/executor.py index 3620e3a..7028e8f 100644 --- a/extracontext/executor.py +++ b/extracontext/executor.py @@ -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" parmeter. + 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() diff --git a/pyproject.toml b/pyproject.toml index 2443f42..665d949 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ classifiers = [ "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", "Operating System :: OS Independent", ] +dependencies = [ +] [project.urls] repository = "https://github.com/jsbueno/extracontext" @@ -40,6 +42,7 @@ dev = [ "black", "pyflakes", "pytest-coverage", + "pytest-timeout>=2.4.0", ] docs = [ "mkdocs", @@ -47,11 +50,12 @@ docs = [ "mkdocstrings[python]", ] + [tool.pytest.ini_options] testpaths = "tests" python_files = "test_*.py" python_functions = "test_*" -addopts = "-v --doctest-modules" +addopts = "-v --doctest-modules --timeout=2" [tool.mypy] mypy_path = "typecheck" diff --git a/tests/test_executor.py b/tests/test_executor.py index 2566b6d..237278f 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -9,7 +9,6 @@ def test_executor_preserves_context(): # test executed using ordinary contexts - executor = ContextPreservingExecutor(1) myvar = None @@ -24,7 +23,6 @@ def stage_1(): async def stage_2(): nonlocal all_ok, message # myvar - myvar.set(23) loop = asyncio.get_running_loop() task1 = loop.run_in_executor(executor, stage_3) From a195237264ff8f435f4b5fdb108c35a6f3aa1883 Mon Sep 17 00:00:00 2001 From: Joao Bueno Date: Sat, 28 Feb 2026 15:08:06 -0300 Subject: [PATCH 7/7] pytest-timeout configuration fixed --- extracontext/executor.py | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extracontext/executor.py b/extracontext/executor.py index 7028e8f..bbea397 100644 --- a/extracontext/executor.py +++ b/extracontext/executor.py @@ -34,7 +34,7 @@ def __init__(self, *args, **kwargs): 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" parmeter. + # 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 diff --git a/pyproject.toml b/pyproject.toml index 665d949..6f13183 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,12 +50,12 @@ docs = [ "mkdocstrings[python]", ] - [tool.pytest.ini_options] +timeout = 2 testpaths = "tests" python_files = "test_*.py" python_functions = "test_*" -addopts = "-v --doctest-modules --timeout=2" +addopts = "-v --doctest-modules" # --timeout=2" # FIXME: timeout won't work like this in python 3.12 [tool.mypy] mypy_path = "typecheck"