diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..80cb39f --- /dev/null +++ b/.github/workflows/docs.yml @@ -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 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..d8c8b5b --- /dev/null +++ b/docs/index.md @@ -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. 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/extracontext/executor.py b/extracontext/executor.py index 3620e3a..bbea397 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" 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() 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..6f13183 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,13 +42,20 @@ dev = [ "black", "pyflakes", "pytest-coverage", + "pytest-timeout>=2.4.0", +] +docs = [ + "mkdocs", + "mkdocs-material", + "mkdocstrings[python]", ] [tool.pytest.ini_options] +timeout = 2 testpaths = "tests" python_files = "test_*.py" python_functions = "test_*" -addopts = "-v --doctest-modules" +addopts = "-v --doctest-modules" # --timeout=2" # FIXME: timeout won't work like this in python 3.12 [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)