python-extracontext 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- extracontext/__init__.py +14 -0
- extracontext/_future_task.py +59 -0
- extracontext/base.py +19 -0
- extracontext/contextlocal.py +264 -0
- extracontext/contextlocal_native.py +262 -0
- extracontext/mapping.py +71 -0
- python_extracontext-1.0.0.dist-info/METADATA +562 -0
- python_extracontext-1.0.0.dist-info/RECORD +10 -0
- python_extracontext-1.0.0.dist-info/WHEEL +5 -0
- python_extracontext-1.0.0.dist-info/top_level.txt +1 -0
extracontext/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .base import ContextLocal
|
|
2
|
+
from .contextlocal import PyContextLocal, ContextError
|
|
3
|
+
from .mapping import ContextMap
|
|
4
|
+
from .contextlocal_native import NativeContextLocal
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ContextLocal",
|
|
10
|
+
"ContextMap",
|
|
11
|
+
"PyContextLocal",
|
|
12
|
+
"ContextError",
|
|
13
|
+
"NativeContextLocal",
|
|
14
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code backported from Python 3.12:
|
|
3
|
+
Task.__init__ will accept a "contxt" parameter that is needed
|
|
4
|
+
in order to re-use contexts for async generator iterations.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
The subclass is created in order for the minimal of "copy-pasting" around
|
|
8
|
+
to be needed.
|
|
9
|
+
|
|
10
|
+
# **** License for this file: PSF License - ****
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import contextvars
|
|
14
|
+
import itertools
|
|
15
|
+
from asyncio.tasks import _PyTask, _register_task
|
|
16
|
+
|
|
17
|
+
# from asyncio import futures
|
|
18
|
+
|
|
19
|
+
from asyncio import coroutines
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_task_name_counter = itertools.count(1).__next__
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FutureTask(_PyTask):
|
|
26
|
+
# Just overrides __init__ with Python 3.12 _PyTask.__init__,
|
|
27
|
+
# which accepts the context as argument
|
|
28
|
+
|
|
29
|
+
def __init__(self, coro, *, loop=None, name=None, context=None, eager_start=False):
|
|
30
|
+
# skip Python < 3.10 Task.__init__ :
|
|
31
|
+
super(_PyTask, self).__init__(loop=loop)
|
|
32
|
+
if self._source_traceback:
|
|
33
|
+
del self._source_traceback[-1]
|
|
34
|
+
if not coroutines.iscoroutine(coro):
|
|
35
|
+
# raise after Future.__init__(), attrs are required for __del__
|
|
36
|
+
# prevent logging for pending task in __del__
|
|
37
|
+
self._log_destroy_pending = False
|
|
38
|
+
raise TypeError(f"a coroutine was expected, got {coro!r}")
|
|
39
|
+
|
|
40
|
+
if name is None:
|
|
41
|
+
self._name = f"FutureTask-{_task_name_counter()}"
|
|
42
|
+
else:
|
|
43
|
+
self._name = str(name)
|
|
44
|
+
|
|
45
|
+
self._num_cancels_requested = 0
|
|
46
|
+
self._must_cancel = False
|
|
47
|
+
self._fut_waiter = None
|
|
48
|
+
self._coro = coro
|
|
49
|
+
if context is None:
|
|
50
|
+
# this is the only codepath in Python < 3.10, and the reason for this hack:
|
|
51
|
+
self._context = contextvars.copy_context()
|
|
52
|
+
else:
|
|
53
|
+
self._context = context
|
|
54
|
+
|
|
55
|
+
if eager_start and self._loop.is_running():
|
|
56
|
+
self.__eager_start()
|
|
57
|
+
else:
|
|
58
|
+
self._loop.call_soon(self._Task__step, context=self._context)
|
|
59
|
+
_register_task(self)
|
extracontext/base.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class ContextLocal:
|
|
2
|
+
_backend_registry = {}
|
|
3
|
+
|
|
4
|
+
def __new__(cls, *args, backend=None, **kwargs):
|
|
5
|
+
if backend is None:
|
|
6
|
+
backend = getattr(cls, "_backend_key", "native")
|
|
7
|
+
cls = cls._backend_registry[backend]
|
|
8
|
+
## Do not forward arguments to object.__new__
|
|
9
|
+
if len(__class__.__mro__) == 2:
|
|
10
|
+
args, kwargs = (), {}
|
|
11
|
+
return super().__new__(cls, *args, **kwargs)
|
|
12
|
+
|
|
13
|
+
def __init__(self, *, backend=None):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
def __init_subclass__(cls, *args, **kw):
|
|
17
|
+
if hasattr(cls, "_backend_key"):
|
|
18
|
+
cls._backend_registry[cls._backend_key] = cls
|
|
19
|
+
super().__init_subclass__(*args, **kw)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Super context wrapper -
|
|
3
|
+
|
|
4
|
+
meant to be simpler to use and work in more scenarios than
|
|
5
|
+
Python's contextvars.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
Create one or more project-wide instances of "ContextLocal"
|
|
9
|
+
Decorate your functions, co-routines, worker-methods and generators
|
|
10
|
+
that should hold their own states with that instance's `context` method -
|
|
11
|
+
|
|
12
|
+
and use the instance as namespace for private variables that will be local
|
|
13
|
+
and non-local until entering another callable decorated
|
|
14
|
+
with that instance - that will create a new, separated scope
|
|
15
|
+
visible inside the decorated callable.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import uuid
|
|
21
|
+
import sys
|
|
22
|
+
import typing as T
|
|
23
|
+
|
|
24
|
+
from functools import wraps
|
|
25
|
+
from types import FrameType
|
|
26
|
+
from weakref import WeakKeyDictionary
|
|
27
|
+
|
|
28
|
+
from .base import ContextLocal
|
|
29
|
+
|
|
30
|
+
__author__ = "João S. O. Bueno"
|
|
31
|
+
__license__ = "LGPL v. 3.0+"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContextError(AttributeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_sentinel = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _WeakableId:
|
|
42
|
+
"""Used internally to identify Frames with context data attached using weakrefs"""
|
|
43
|
+
|
|
44
|
+
__slots__ = ["__weakref__", "value"]
|
|
45
|
+
|
|
46
|
+
def __init__(self, v=0):
|
|
47
|
+
if not v:
|
|
48
|
+
v = int(uuid.uuid4())
|
|
49
|
+
self.value = v
|
|
50
|
+
|
|
51
|
+
def __eq__(self, other):
|
|
52
|
+
return self.value == other.value
|
|
53
|
+
|
|
54
|
+
def __hash__(self):
|
|
55
|
+
return hash(self.value)
|
|
56
|
+
|
|
57
|
+
def __repr__(self):
|
|
58
|
+
return f"ID({uuid.UUID(int=self.value)})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PyContextLocal(ContextLocal):
|
|
62
|
+
"""Creates a namespace object whose attributes can keep individual and distinct values for
|
|
63
|
+
the same key for code running in parallel - either in asyncio tasks, or threads.
|
|
64
|
+
|
|
65
|
+
The bennefits are the same one gets by using contextvars.ContextVar from the stdlib as
|
|
66
|
+
specified on PEP 567. However extracontext.ContextLocal is designed to be easier
|
|
67
|
+
and more convenient to use - as a single instance can hold values for several
|
|
68
|
+
keys, just as happens with threading.local objects. And no special getter and
|
|
69
|
+
setter methods are needed to retrieve the unique value stored in the current
|
|
70
|
+
context: normal attribute access and assignment works transparently.
|
|
71
|
+
|
|
72
|
+
Internally, the current implementation uses a completly different way to
|
|
73
|
+
keep distinct states where needed: the "locals" mapping for each execution
|
|
74
|
+
frame is used as storage for the unique values in an async task context, or in
|
|
75
|
+
a thread. Although not recomended up to now, read/write access to non-local-variables
|
|
76
|
+
in the "locals" mapping is specified on PEP 558. While that PEP is not
|
|
77
|
+
final, it is clear in its texts that the capability of using "locals" as
|
|
78
|
+
a mapping to convey data will be kept and made official.
|
|
79
|
+
|
|
80
|
+
References to the frames containing context data is kept using
|
|
81
|
+
weakreferences, so when a Frame ends up execution, its contents
|
|
82
|
+
are deleted normally, with no risks of frame data
|
|
83
|
+
hanging around due to PyContextLocal data.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
# TODO: change _BASEDIST to a property counting the intermediate
|
|
89
|
+
# methods between subclasses and the methods here.
|
|
90
|
+
_BASEDIST = 0
|
|
91
|
+
|
|
92
|
+
_backend_key = "python"
|
|
93
|
+
|
|
94
|
+
def __init__(self, **kwargs):
|
|
95
|
+
print(f"\n***************init {type(self)}")
|
|
96
|
+
super().__init__(**kwargs)
|
|
97
|
+
super().__setattr__("_et_registry", WeakKeyDictionary())
|
|
98
|
+
|
|
99
|
+
def _introspect_registry(
|
|
100
|
+
self, name: T.Optional[str] = None, starting_frame: int = 2
|
|
101
|
+
) -> T.Tuple[dict, T.Tuple[int, int]]:
|
|
102
|
+
"""
|
|
103
|
+
returns the first namespace found for this context, if name is None
|
|
104
|
+
else, the first namespace where the name exists. The second return
|
|
105
|
+
value is a tuple inticatind the frame distance to the topmost namespace
|
|
106
|
+
and the frame distance to the returned namespace.
|
|
107
|
+
This way callers can tell if the searched name is on the topmost
|
|
108
|
+
namespace and act accordingly. ("del" needs this information,
|
|
109
|
+
as it can't remove information on an outter namespace)
|
|
110
|
+
"""
|
|
111
|
+
starting_frame += self._BASEDIST
|
|
112
|
+
f: T.Optional[FrameType] = sys._getframe(starting_frame)
|
|
113
|
+
count = 0
|
|
114
|
+
first_ns = None
|
|
115
|
+
while f:
|
|
116
|
+
hf = self._frameid(f)
|
|
117
|
+
if hf in self._et_registry:
|
|
118
|
+
if first_ns is None:
|
|
119
|
+
first_ns = count
|
|
120
|
+
registered_namespaces = f.f_locals["$contexts"]
|
|
121
|
+
for namespace_index in reversed(self._et_registry[hf]):
|
|
122
|
+
namespace = registered_namespaces[namespace_index]
|
|
123
|
+
if name is None or name in namespace:
|
|
124
|
+
return namespace, (first_ns, count)
|
|
125
|
+
count += 1
|
|
126
|
+
f = f.f_back
|
|
127
|
+
|
|
128
|
+
if name:
|
|
129
|
+
raise ContextError(f"{name !r} not defined in any previous context")
|
|
130
|
+
raise ContextError("No previous context set")
|
|
131
|
+
|
|
132
|
+
def _frameid(self, frame: FrameType) -> _WeakableId:
|
|
133
|
+
if not "$contexts_salt" in frame.f_locals:
|
|
134
|
+
frame.f_locals["$contexts_salt"] = _WeakableId()
|
|
135
|
+
return frame.f_locals["$contexts_salt"]
|
|
136
|
+
|
|
137
|
+
def _register_context(self, f: FrameType) -> None:
|
|
138
|
+
hf = self._frameid(f)
|
|
139
|
+
contexts_list = f.f_locals.setdefault("$contexts", [])
|
|
140
|
+
contexts_list.append({})
|
|
141
|
+
self._et_registry.setdefault(hf, []).append(len(contexts_list) - 1)
|
|
142
|
+
|
|
143
|
+
def _pop_context(self, f: FrameType) -> None:
|
|
144
|
+
hf = self._frameid(f)
|
|
145
|
+
context_being_popped = self._et_registry[hf].pop()
|
|
146
|
+
contexts_list = f.f_locals["$contexts"]
|
|
147
|
+
contexts_list[context_being_popped] = None
|
|
148
|
+
|
|
149
|
+
def __getattr__(self, name: str) -> T.Any:
|
|
150
|
+
try:
|
|
151
|
+
namespace, _ = self._introspect_registry(name)
|
|
152
|
+
result = namespace[name]
|
|
153
|
+
if result is _sentinel:
|
|
154
|
+
raise KeyError(name)
|
|
155
|
+
return result
|
|
156
|
+
except (ContextError, KeyError):
|
|
157
|
+
raise AttributeError(f"Attribute not set: {name}")
|
|
158
|
+
|
|
159
|
+
def __setattr__(self, name: str, value: T.Any) -> None:
|
|
160
|
+
try:
|
|
161
|
+
namespace, _ = self._introspect_registry()
|
|
162
|
+
except ContextError:
|
|
163
|
+
# Automatically creates a new namespace if not inside
|
|
164
|
+
# any explicit denominated context:
|
|
165
|
+
self._register_context(sys._getframe(1 + self._BASEDIST))
|
|
166
|
+
namespace, _ = self._introspect_registry()
|
|
167
|
+
|
|
168
|
+
namespace[name] = value
|
|
169
|
+
|
|
170
|
+
def __delattr__(self, name: str) -> None:
|
|
171
|
+
try:
|
|
172
|
+
namespace, (topmost_ns, found_ns) = self._introspect_registry(name)
|
|
173
|
+
except ContextError:
|
|
174
|
+
raise AttributeError(name)
|
|
175
|
+
if topmost_ns == found_ns:
|
|
176
|
+
result = namespace[name]
|
|
177
|
+
if result is not _sentinel:
|
|
178
|
+
if "$deleted" in namespace and name in namespace["$deleted"]:
|
|
179
|
+
# attribute exists in target namespace, but the outter
|
|
180
|
+
# attribute had previously been shadowed by a delete -
|
|
181
|
+
# restore the shadowing:
|
|
182
|
+
setattr(self, name, _sentinel)
|
|
183
|
+
|
|
184
|
+
else:
|
|
185
|
+
# Remove topmost name assignemnt, and outer value is exposed
|
|
186
|
+
# ("one_level" attribute stacking behavior as described in 'features.py'
|
|
187
|
+
# disbled as unecessaryly complex):
|
|
188
|
+
# del namespace[name]
|
|
189
|
+
|
|
190
|
+
# To preserve "entry_only" behavior:
|
|
191
|
+
namespace.setdefault("$deleted", set()).add(name)
|
|
192
|
+
setattr(self, name, _sentinel)
|
|
193
|
+
return
|
|
194
|
+
# value is already shadowed:
|
|
195
|
+
raise AttributeError(name)
|
|
196
|
+
|
|
197
|
+
# Name is found, but it is not on the top-most level, so attribute is shadowed:
|
|
198
|
+
setattr(self, name, _sentinel)
|
|
199
|
+
# fossil: namespace, _ = self._introspect_registry(name)
|
|
200
|
+
namespace.setdefault("$deleted", set()).add(name)
|
|
201
|
+
|
|
202
|
+
def __call__(self, callable_: T.Callable) -> T.Callable:
|
|
203
|
+
@wraps(callable_)
|
|
204
|
+
def wrapper(*args, **kw):
|
|
205
|
+
f = sys._getframe()
|
|
206
|
+
self._register_context(f)
|
|
207
|
+
f_id = self._frameid(f)
|
|
208
|
+
result = _sentinel
|
|
209
|
+
try:
|
|
210
|
+
result = callable_(*args, **kw)
|
|
211
|
+
finally:
|
|
212
|
+
if f_id in self._et_registry:
|
|
213
|
+
del self._et_registry[f_id]
|
|
214
|
+
# Setup context for generator, async generator or coroutine if one was returned:
|
|
215
|
+
if result is not _sentinel:
|
|
216
|
+
frame = None
|
|
217
|
+
for frame_attr in ("gi_frame", "ag_frame", "cr_frame"):
|
|
218
|
+
frame = getattr(result, frame_attr, None)
|
|
219
|
+
if frame:
|
|
220
|
+
self._register_context(frame)
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
return wrapper
|
|
224
|
+
|
|
225
|
+
def __enter__(self):
|
|
226
|
+
self._register_context(sys._getframe(1))
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
230
|
+
self._pop_context(sys._getframe(1))
|
|
231
|
+
|
|
232
|
+
def _run(self, callable_, *args, **kw):
|
|
233
|
+
"""Runs callable with an isolated context
|
|
234
|
+
no need to decorate the target callable
|
|
235
|
+
"""
|
|
236
|
+
with self:
|
|
237
|
+
return callable_(*args, **kw)
|
|
238
|
+
|
|
239
|
+
def __dir__(self) -> T.List[str]:
|
|
240
|
+
frame_count = 2
|
|
241
|
+
all_attrs = set()
|
|
242
|
+
seen_namespaces = set()
|
|
243
|
+
while True:
|
|
244
|
+
try:
|
|
245
|
+
namespace, _ = self._introspect_registry(starting_frame=frame_count)
|
|
246
|
+
except (
|
|
247
|
+
ValueError,
|
|
248
|
+
ContextError,
|
|
249
|
+
): # ValueError can be raised sys._getframe inside _introspect_registry
|
|
250
|
+
break
|
|
251
|
+
frame_count += 1
|
|
252
|
+
if id(namespace) in seen_namespaces:
|
|
253
|
+
continue
|
|
254
|
+
for key, value in namespace.items():
|
|
255
|
+
if not key.startswith("$") and value is not _sentinel:
|
|
256
|
+
all_attrs.add(key)
|
|
257
|
+
|
|
258
|
+
seen_namespaces.add(id(namespace))
|
|
259
|
+
all_attrs = (
|
|
260
|
+
attr
|
|
261
|
+
for attr in all_attrs
|
|
262
|
+
if getattr(self, attr, _sentinel) is not _sentinel
|
|
263
|
+
)
|
|
264
|
+
return sorted(all_attrs)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Super context wrapper -
|
|
3
|
+
|
|
4
|
+
Meant to have the same interface as the easy-to-use PyContextLocal,
|
|
5
|
+
implemented 100% in Python, but backed by PEP 567 stdlib contextvar.ContextVar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from contextvars import ContextVar, copy_context
|
|
17
|
+
|
|
18
|
+
from .base import ContextLocal
|
|
19
|
+
|
|
20
|
+
if sys.implementation.name == "pypy":
|
|
21
|
+
pypy = True
|
|
22
|
+
from __pypy__ import (
|
|
23
|
+
get_contextvar_context as _get_contextvar_context,
|
|
24
|
+
set_contextvar_context as _set_contextvar_context,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
else:
|
|
28
|
+
pypy = False
|
|
29
|
+
try:
|
|
30
|
+
import ctypes
|
|
31
|
+
except ImportError as error:
|
|
32
|
+
import warnings
|
|
33
|
+
|
|
34
|
+
warnings.warn(
|
|
35
|
+
f"Couldn't import ctypes! `with` context blocks for NativeContextLocal won't work:\n {error.msg}"
|
|
36
|
+
)
|
|
37
|
+
warnings.warn(
|
|
38
|
+
"\n\nIf you need this feature in subinterpreters, please open a project issue"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if sys.version_info < (3, 10):
|
|
42
|
+
from types import AsyncGeneratorType
|
|
43
|
+
|
|
44
|
+
anext = AsyncGeneratorType.__anext__
|
|
45
|
+
|
|
46
|
+
__author__ = "João S. O. Bueno"
|
|
47
|
+
__license__ = "LGPL v. 3.0+"
|
|
48
|
+
|
|
49
|
+
_sentinel = object()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NativeContextLocal(ContextLocal):
|
|
53
|
+
"""Uses th native contextvar module in the stdlib (PEP 567)
|
|
54
|
+
to provide a context-local namespace in the way
|
|
55
|
+
threading.local works for threads.
|
|
56
|
+
|
|
57
|
+
Assignements and reading from the namespace
|
|
58
|
+
should work naturally with no need to call `get` and `set` methods.
|
|
59
|
+
|
|
60
|
+
A new contextvar variable is created in the current (contextvars) context
|
|
61
|
+
for _each_ attribute acessed on this namespace.
|
|
62
|
+
|
|
63
|
+
Also, attributes prefixed with a single "_et_" are intended for internal
|
|
64
|
+
use and will not be namespaced contextvars.
|
|
65
|
+
|
|
66
|
+
# In contrast to the pure-Python implementation there are
|
|
67
|
+
# some limitations,such as the impossibility to work
|
|
68
|
+
# in as a contextmanager (Python `with` block),.
|
|
69
|
+
|
|
70
|
+
[Work In Progress]
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_backend_key = "native"
|
|
74
|
+
_ctypes_initialized = False
|
|
75
|
+
|
|
76
|
+
def __init__(self, **kwargs):
|
|
77
|
+
super().__init__(**kwargs)
|
|
78
|
+
self._et_registry = {}
|
|
79
|
+
self._et_stack = {}
|
|
80
|
+
self._et_lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def __getattr__(self, name):
|
|
83
|
+
var = self._et_registry.get(name, None)
|
|
84
|
+
if var is None:
|
|
85
|
+
raise AttributeError(f"Attribute not set: {name}")
|
|
86
|
+
try:
|
|
87
|
+
value = var.get()
|
|
88
|
+
except LookupError as error:
|
|
89
|
+
raise AttributeError from error
|
|
90
|
+
if value is _sentinel:
|
|
91
|
+
raise AttributeError(f"Attribute not set: {name}")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
def __setattr__(self, name, value):
|
|
95
|
+
if name.startswith("_et_"):
|
|
96
|
+
return super().__setattr__(name, value)
|
|
97
|
+
var = self._et_registry.get(name, _sentinel)
|
|
98
|
+
if var is _sentinel:
|
|
99
|
+
var = self._et_registry[name] = ContextVar(name)
|
|
100
|
+
var.set(value)
|
|
101
|
+
|
|
102
|
+
def __delattr__(self, name):
|
|
103
|
+
if getattr(self, name, _sentinel) is _sentinel:
|
|
104
|
+
raise AttributeError(f"Attribute not set: {name}")
|
|
105
|
+
setattr(self, name, _sentinel)
|
|
106
|
+
|
|
107
|
+
def __call__(self, callable_):
|
|
108
|
+
@wraps(callable_)
|
|
109
|
+
def wrapper(*args, **kw):
|
|
110
|
+
return self._run(callable_, *args, **kw)
|
|
111
|
+
|
|
112
|
+
return wrapper
|
|
113
|
+
|
|
114
|
+
def _ensure_api_ready(self):
|
|
115
|
+
if not self._ctypes_initialized:
|
|
116
|
+
ctypes.pythonapi.PyContext_Enter.argtypes = [ctypes.py_object]
|
|
117
|
+
ctypes.pythonapi.PyContext_Exit.argtypes = [ctypes.py_object]
|
|
118
|
+
ctypes.pythonapi.PyContext_Enter.restype = ctypes.c_int32
|
|
119
|
+
ctypes.pythonapi.PyContext_Exit.restype = ctypes.c_int32
|
|
120
|
+
self.__class__._ctypes_initialized = True
|
|
121
|
+
|
|
122
|
+
def _get_ctx_key(self):
|
|
123
|
+
key_thread = threading.current_thread()
|
|
124
|
+
try:
|
|
125
|
+
key_task = asyncio.current_task()
|
|
126
|
+
except RuntimeError:
|
|
127
|
+
key_task = None
|
|
128
|
+
return (key_thread, key_task)
|
|
129
|
+
|
|
130
|
+
def _enter_ctx(self, new_ctx):
|
|
131
|
+
if pypy:
|
|
132
|
+
prev_ctx = _get_contextvar_context()
|
|
133
|
+
_set_contextvar_context(new_ctx)
|
|
134
|
+
return prev_ctx
|
|
135
|
+
self._ensure_api_ready()
|
|
136
|
+
result = ctypes.pythonapi.PyContext_Enter(new_ctx)
|
|
137
|
+
if result != 0:
|
|
138
|
+
raise RuntimeError(f"Something went wrong entering context {new_ctx}")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def _exit_ctx(self, current_ctx, prev_ctx):
|
|
142
|
+
if pypy:
|
|
143
|
+
_set_contextvar_context(prev_ctx)
|
|
144
|
+
return
|
|
145
|
+
result = ctypes.pythonapi.PyContext_Exit(current_ctx)
|
|
146
|
+
if result != 0:
|
|
147
|
+
raise RuntimeError(f"Something went wrong exiting context {current_ctx}")
|
|
148
|
+
|
|
149
|
+
def __enter__(self):
|
|
150
|
+
new_ctx = copy_context()
|
|
151
|
+
prev_ctx = self._enter_ctx(new_ctx)
|
|
152
|
+
with self._et_lock:
|
|
153
|
+
self._et_stack.setdefault(self._get_ctx_key(), []).append(
|
|
154
|
+
(new_ctx, prev_ctx)
|
|
155
|
+
)
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
159
|
+
key = self._get_ctx_key()
|
|
160
|
+
with self._et_lock:
|
|
161
|
+
current_ctx, prev_ctx = self._et_stack[key].pop()
|
|
162
|
+
if not self._et_stack[key]:
|
|
163
|
+
self._et_stack.pop(key)
|
|
164
|
+
self._exit_ctx(current_ctx, prev_ctx)
|
|
165
|
+
|
|
166
|
+
def _run(self, callable_, *args, **kw):
|
|
167
|
+
"""Runs callable with an isolated context
|
|
168
|
+
no need to decorate the target callable
|
|
169
|
+
"""
|
|
170
|
+
new_context = copy_context()
|
|
171
|
+
result = new_context.run(callable_, *args, **kw)
|
|
172
|
+
if inspect.isawaitable(result):
|
|
173
|
+
result = self._awaitable_wrapper(result, new_context)
|
|
174
|
+
elif inspect.isgenerator(result):
|
|
175
|
+
result = self._generator_wrapper(result, new_context)
|
|
176
|
+
elif inspect.isasyncgen(result):
|
|
177
|
+
result = self._async_generator_wrapper(result, new_context)
|
|
178
|
+
# raise NotImplementedError("NativeContextLocal doesn't yet work with async generators")
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _generator_wrapper(generator, ctx_copy):
|
|
183
|
+
value = None
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
if value is None:
|
|
187
|
+
value = yield ctx_copy.run(next, generator)
|
|
188
|
+
else:
|
|
189
|
+
value = yield ctx_copy.run(generator.send, value)
|
|
190
|
+
except StopIteration as stop:
|
|
191
|
+
return stop.value
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
# for debugging times: this will be hard without a break here!
|
|
194
|
+
# print(exc)
|
|
195
|
+
try:
|
|
196
|
+
value = ctx_copy.run(generator.throw, exc)
|
|
197
|
+
except StopIteration as stop:
|
|
198
|
+
return stop.value
|
|
199
|
+
|
|
200
|
+
if sys.version_info >= (3, 11):
|
|
201
|
+
|
|
202
|
+
async def _awaitable_wrapper(self, coro, ctx_copy):
|
|
203
|
+
def trampoline():
|
|
204
|
+
return asyncio.create_task(coro, context=ctx_copy)
|
|
205
|
+
|
|
206
|
+
return await ctx_copy.run(trampoline)
|
|
207
|
+
|
|
208
|
+
else:
|
|
209
|
+
|
|
210
|
+
async def _awaitable_wrapper(self, coro, ctx_copy):
|
|
211
|
+
from ._future_task import FutureTask
|
|
212
|
+
|
|
213
|
+
loop = asyncio.get_running_loop()
|
|
214
|
+
|
|
215
|
+
def trampoline():
|
|
216
|
+
return FutureTask(coro, loop=loop, context=ctx_copy)
|
|
217
|
+
|
|
218
|
+
return await ctx_copy.run(trampoline)
|
|
219
|
+
|
|
220
|
+
## this fails in spetacular and inovative ways!
|
|
221
|
+
# async def _awaitable_wrapper(self, coro, ctx_copy, force_context=True):
|
|
222
|
+
# if force_context:
|
|
223
|
+
# try:
|
|
224
|
+
# self._enter_ctx(ctx_copy)
|
|
225
|
+
# result = await coro
|
|
226
|
+
# finally:
|
|
227
|
+
# self._exit_ctx(ctx_copy)
|
|
228
|
+
# return result
|
|
229
|
+
# else:
|
|
230
|
+
# return await coro
|
|
231
|
+
|
|
232
|
+
async def _awaitable_wrapper2(self, coro, ctx_copy):
|
|
233
|
+
raise NotImplementedError(
|
|
234
|
+
"""This code will only work with Python versions > 3.11. Please use `ContextLocal(backend="python")` for Python version 3.8 - 3.10"""
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def _async_generator_wrapper(self, generator, ctx_copy):
|
|
238
|
+
value = None
|
|
239
|
+
while True:
|
|
240
|
+
try:
|
|
241
|
+
if value is None:
|
|
242
|
+
async_res = ctx_copy.run(anext, generator)
|
|
243
|
+
else:
|
|
244
|
+
async_res = ctx_copy.run(generator.asend, value)
|
|
245
|
+
value = yield await self._awaitable_wrapper(async_res, ctx_copy)
|
|
246
|
+
except StopAsyncIteration:
|
|
247
|
+
break
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
# for debugging times: this will be hard without a break here!
|
|
250
|
+
# print("*" * 50 , exc)
|
|
251
|
+
try:
|
|
252
|
+
async_res = ctx_copy.run(generator.athrow, exc)
|
|
253
|
+
value = yield await self._awaitable_wrapper(async_res, ctx_copy)
|
|
254
|
+
except StopAsyncIteration:
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
def __dir__(self):
|
|
258
|
+
return list(
|
|
259
|
+
key
|
|
260
|
+
for key, value in self._et_registry.items()
|
|
261
|
+
if value.get() is not _sentinel
|
|
262
|
+
)
|
extracontext/mapping.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from collections.abc import Mapping, MutableMapping
|
|
2
|
+
|
|
3
|
+
from .base import ContextLocal
|
|
4
|
+
from .contextlocal import PyContextLocal
|
|
5
|
+
from .contextlocal_native import NativeContextLocal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContextMap(MutableMapping, ContextLocal):
|
|
9
|
+
"""Works the same as PyContextLocal,
|
|
10
|
+
but uses the mapping interface instead of dealing with instance attributes.
|
|
11
|
+
|
|
12
|
+
Ideal, as for most map uses, when the keys depend on data rather than
|
|
13
|
+
hardcoded state variables
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_backend_registry = {}
|
|
17
|
+
|
|
18
|
+
# def __init__(self, initial: None | Mapping = None, *, backend=None):
|
|
19
|
+
# super().__init__()
|
|
20
|
+
# if not initial:
|
|
21
|
+
# return
|
|
22
|
+
# for key, value in initial.items():
|
|
23
|
+
# self[key] = value
|
|
24
|
+
|
|
25
|
+
def __getitem__(self, name):
|
|
26
|
+
try:
|
|
27
|
+
return self.__getattr__(name)
|
|
28
|
+
except AttributeError:
|
|
29
|
+
raise KeyError(name)
|
|
30
|
+
|
|
31
|
+
def __setitem__(self, name, value):
|
|
32
|
+
setattr(self, name, value)
|
|
33
|
+
|
|
34
|
+
def __delitem__(self, name):
|
|
35
|
+
try:
|
|
36
|
+
delattr(self, name)
|
|
37
|
+
except AttributeError:
|
|
38
|
+
raise KeyError(name)
|
|
39
|
+
|
|
40
|
+
def __iter__(self):
|
|
41
|
+
return iter(dir(self))
|
|
42
|
+
|
|
43
|
+
def __len__(self):
|
|
44
|
+
return len(dir(self))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PyContextMap(ContextMap, PyContextLocal):
|
|
48
|
+
_backend_key = "python"
|
|
49
|
+
_BASEDIST = 1
|
|
50
|
+
|
|
51
|
+
def __init__(self, initial: None | Mapping = None, *, backend=None):
|
|
52
|
+
super().__init__()
|
|
53
|
+
if not initial:
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
self._BASEDIST = 2
|
|
57
|
+
for key, value in initial.items():
|
|
58
|
+
self[key] = value
|
|
59
|
+
finally:
|
|
60
|
+
del self._BASEDIST
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NativeContextMap(ContextMap, NativeContextLocal):
|
|
64
|
+
_backend_key = "native"
|
|
65
|
+
|
|
66
|
+
def __init__(self, initial: None | Mapping = None, *, backend=None):
|
|
67
|
+
super().__init__()
|
|
68
|
+
if not initial:
|
|
69
|
+
return
|
|
70
|
+
for key, value in initial.items():
|
|
71
|
+
self[key] = value
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: python-extracontext
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Context Variable namespaces supporting generators, asyncio and multi-threading
|
|
5
|
+
Author: Joao S. O. Bueno
|
|
6
|
+
Project-URL: repository, https://github.com/jsbueno/extracontext
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
16
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
17
|
+
Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: black; extra == "dev"
|
|
24
|
+
Requires-Dist: pyflakes; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-coverage; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# Extracontext: Context Local Variables for everyone
|
|
28
|
+
|
|
29
|
+
## Description
|
|
30
|
+
|
|
31
|
+
Provides [PEP 567](https://peps.python.org/pep-0567/)
|
|
32
|
+
compliant drop-in replacement for `threading.local`
|
|
33
|
+
namespaces.
|
|
34
|
+
|
|
35
|
+
The main goal of PEP 567, supersedding [PEP 550](https://peps.python.org/pep-0550/)
|
|
36
|
+
is to create a way to preserve information in
|
|
37
|
+
concurrent running contexts, including multithreading
|
|
38
|
+
and asynchronous (asyncio) tasks, allowing
|
|
39
|
+
each call stack to have its own versions of
|
|
40
|
+
variables containing settings, or request
|
|
41
|
+
parameters.
|
|
42
|
+
|
|
43
|
+
### Quoting from PEP 567 Rationalle:
|
|
44
|
+
> Thread-local variables are insufficient for asynchronous
|
|
45
|
+
> tasks that execute concurrently in the same OS thread.
|
|
46
|
+
> Any context manager that saves and restores a context
|
|
47
|
+
> value using threading.local() will have its context values
|
|
48
|
+
> bleed to other code unexpectedly when used in async/await code.
|
|
49
|
+
|
|
50
|
+
## Rationale for "extracontext"
|
|
51
|
+
|
|
52
|
+
Contextcars, introduced in Python 3.7, were
|
|
53
|
+
implemented following a design decision by the
|
|
54
|
+
which opted-out of the namespace approach
|
|
55
|
+
used by Python's own `threading.local`
|
|
56
|
+
implementation. It then requires an explicit top level
|
|
57
|
+
declaration of each context-local variable, and
|
|
58
|
+
the (rather "unpythonic") usage of an explicit
|
|
59
|
+
call to `get` and `set` methods to manipulate
|
|
60
|
+
those. Also, the only way to run some code in
|
|
61
|
+
an isolated context copy is to call a function
|
|
62
|
+
indirectly through means of the context object `.run` method.
|
|
63
|
+
This implies that:
|
|
64
|
+
|
|
65
|
+
1. Knowing when to run something in a different context is responsability of the caller code
|
|
66
|
+
2. Breaks the easy-to-use, easy-to-read, aesthetics, and overal complicates one of the most fundamental blocks of programming in inperative languages: calling functions.
|
|
67
|
+
|
|
68
|
+
This package does away with that, and brings simplicity
|
|
69
|
+
back, using dotted attributes to a namespace and `=`
|
|
70
|
+
for value assigment:
|
|
71
|
+
|
|
72
|
+
with stdlib native contexvars:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import contextvars
|
|
76
|
+
|
|
77
|
+
# Variable declaration: top level declaration and WET (write everything twice)
|
|
78
|
+
ctx_color = contextvars.ContextVar("ctx_color")
|
|
79
|
+
ctx_font = contextvars.ContextVar("ctx_font")
|
|
80
|
+
|
|
81
|
+
def blah():
|
|
82
|
+
...
|
|
83
|
+
# use a set method:
|
|
84
|
+
ctx_color.set("red")
|
|
85
|
+
ctx_font.set("arial")
|
|
86
|
+
|
|
87
|
+
...
|
|
88
|
+
myttext = ...
|
|
89
|
+
# call a markup render function,
|
|
90
|
+
# but take care it wont mix our attributes in temporary context changes
|
|
91
|
+
contextvars.context_copy().run(render_markup, mytext))
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def render_markup(text):
|
|
95
|
+
# markup function: knows it will mess up the context, but can't do
|
|
96
|
+
# a thing about it - the caller has to take care!
|
|
97
|
+
...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
with extracontext:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import extracontext
|
|
104
|
+
|
|
105
|
+
# the only declaration needed at top level code
|
|
106
|
+
ctx = extracontext.ContextLocal()
|
|
107
|
+
|
|
108
|
+
def blah():
|
|
109
|
+
ctx.color = "red"
|
|
110
|
+
ctx.font = "arial"
|
|
111
|
+
|
|
112
|
+
mytext = ...
|
|
113
|
+
# simply calls the function
|
|
114
|
+
render_markup(mytext)
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
@ctx
|
|
118
|
+
def render_markup(text):
|
|
119
|
+
# we will mess the context - but the decorator
|
|
120
|
+
# ensures no changes leak back to the caller
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Usage
|
|
126
|
+
simply instantiate a `ContextLocal` namespace,
|
|
127
|
+
and any attributes set in that namespace will be unique
|
|
128
|
+
per thread and per asynchronous call chain (i.e.
|
|
129
|
+
unique for each independent task).
|
|
130
|
+
|
|
131
|
+
In a sense, these are a drop-in replacement for
|
|
132
|
+
`threading.local`, which will also work for
|
|
133
|
+
asynchronous programming without any change in code.
|
|
134
|
+
|
|
135
|
+
One should just avoid creating the "ContextLocal" instance itself
|
|
136
|
+
in a non-setup function or method - as the implementation
|
|
137
|
+
uses Python contextvars in by default, those are not
|
|
138
|
+
cleaned-up along with the local scope where they are
|
|
139
|
+
created - check the docs on the contextvar module for more
|
|
140
|
+
details.
|
|
141
|
+
|
|
142
|
+
However, creating the actual variables to use inside this namespace
|
|
143
|
+
can be made local to functions or methods: the same inner
|
|
144
|
+
ContextVar instance will be re-used when re-entering the function
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
Create one or more project-wide instances of "extracontext.ContextLocal"
|
|
148
|
+
Decorate your functions, co-routines, worker-methods and generators
|
|
149
|
+
that should hold their own states with that instance itself, using it as a decorator
|
|
150
|
+
|
|
151
|
+
and use the instance as namespace for private variables that will be local
|
|
152
|
+
and non-local until entering another callable decorated
|
|
153
|
+
with the instance itself - that will create a new, separated scope
|
|
154
|
+
visible inside the decorated callable.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
|
|
158
|
+
from extracontext import ContextLocal
|
|
159
|
+
|
|
160
|
+
# global namespace, available in any thread or async task:
|
|
161
|
+
ctx = ContextLocal()
|
|
162
|
+
|
|
163
|
+
def myworker():
|
|
164
|
+
# value set only visible in the current thread or asyncio task:
|
|
165
|
+
ctx.value = "test"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## More Features:
|
|
171
|
+
|
|
172
|
+
### extracontext namespaces work for generators
|
|
173
|
+
|
|
174
|
+
Unlike PEP 567 contextvars, extracontext
|
|
175
|
+
will sucessfully isolate contexts whe used with
|
|
176
|
+
generator-functions - meaning,
|
|
177
|
+
the generator body is actually executed in
|
|
178
|
+
an isolated context:
|
|
179
|
+
|
|
180
|
+
Example showing context separation for concurrent generators:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from extracontext import ContextLocal
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
ctx = ContextLocal()
|
|
187
|
+
|
|
188
|
+
results = []
|
|
189
|
+
@ctx
|
|
190
|
+
def contexted_generator(value):
|
|
191
|
+
ctx.value = value
|
|
192
|
+
yield None
|
|
193
|
+
results.append(ctx.value)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def runner():
|
|
197
|
+
generators = [contexted_generator(i) for i in range(10)]
|
|
198
|
+
any(next(gen) for gen in generators)
|
|
199
|
+
any(next(gen, None) for gen in generators)
|
|
200
|
+
assert results == list(range(10))
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This is virtually impossible with contextvars. (Ok,
|
|
204
|
+
not impossible - the default extracontext backend
|
|
205
|
+
does that using contextvars after all - but it encapsulates
|
|
206
|
+
the complications for you)
|
|
207
|
+
|
|
208
|
+
This feature also works with async generators`
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
Another example of this feature:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
import extracontext
|
|
215
|
+
ctx = extracontext.ContextLocal()
|
|
216
|
+
@ctx
|
|
217
|
+
def isolatedgen(n):
|
|
218
|
+
for i in range(n):
|
|
219
|
+
ctx.myvar = i
|
|
220
|
+
yield i
|
|
221
|
+
print (ctx.myvar)
|
|
222
|
+
def test():
|
|
223
|
+
ctx.myvar = "lambs"
|
|
224
|
+
for j in isolatedgen(2):
|
|
225
|
+
print(ctx.myvar)
|
|
226
|
+
ctx.myvar = "wolves"
|
|
227
|
+
|
|
228
|
+
In [11]: test()
|
|
229
|
+
lambs
|
|
230
|
+
0
|
|
231
|
+
wolves
|
|
232
|
+
1
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
### Change context within a context-manager `with` block:
|
|
237
|
+
|
|
238
|
+
ContextLocal namespaces can also be isolated by context-manager blocks (`with` statement):
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from extracontext import ContextLocal
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def with_block_example():
|
|
245
|
+
|
|
246
|
+
ctx = ContextLocal()
|
|
247
|
+
ctx.value = 1
|
|
248
|
+
with ctx:
|
|
249
|
+
ctx.value = 2
|
|
250
|
+
assert ctx.value == 2
|
|
251
|
+
|
|
252
|
+
assert ctx.value == 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
### Map namespaces
|
|
259
|
+
|
|
260
|
+
Beyond namespace usages, `extracontext` offer ways
|
|
261
|
+
to have contexts working as mutable mappings,
|
|
262
|
+
using the `ContextMap` class.
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
|
|
267
|
+
from extracontext import ContextMap
|
|
268
|
+
|
|
269
|
+
# global namespace, available in any thread or async task:
|
|
270
|
+
ctx = ContextMap()
|
|
271
|
+
|
|
272
|
+
def myworker():
|
|
273
|
+
# value set only visible in the current thread or asyncio task:
|
|
274
|
+
ctx["value"] = "test"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### typing support
|
|
280
|
+
There is no explicit typing support yet - but note that through the use of
|
|
281
|
+
`ContextMap` it is possible to have declare some types, by
|
|
282
|
+
simple declaring `Mapping[type1:type2]` typing.
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
## Specification and Implementation
|
|
286
|
+
|
|
287
|
+
### ContextLocal
|
|
288
|
+
|
|
289
|
+
`ContextLocal` is the main class, and should suffice for most uses.
|
|
290
|
+
It only takes the `backend` keyword-only argument, which selects
|
|
291
|
+
the usage of the pure-Python backend (`"python"`) or using
|
|
292
|
+
a contextvars.ContextVar backend (`"native"`). The later is the default
|
|
293
|
+
behavior. Calling this class will actually create
|
|
294
|
+
an instance of the appropriate subclass, according to
|
|
295
|
+
the backend: either `PyContextLocal` or `NativeContextLocal` -
|
|
296
|
+
in the same way stdlib `pathlib.Path` creates
|
|
297
|
+
an instance of Path appropriate for Posix, or Windows style
|
|
298
|
+
paths. (This pattern probably have a name - help welcome).
|
|
299
|
+
|
|
300
|
+
An instance of it will create a new, fresh, namespace.
|
|
301
|
+
Use dotted attribute access to populate it - each variable set
|
|
302
|
+
in this way will persist through the context lifetime.
|
|
303
|
+
|
|
304
|
+
#### Usage as a decorator:
|
|
305
|
+
When used as a decorator for a function or method, that callable
|
|
306
|
+
will automatically be executed in a copy of the calling context -
|
|
307
|
+
meaning no changes it makes to any variable in the namespace
|
|
308
|
+
is visible outside of the call.
|
|
309
|
+
|
|
310
|
+
The decorator (and the isolation provided) works for
|
|
311
|
+
both plain functions, generator functions, co-routine functions
|
|
312
|
+
and async generator functions - meaning that whenever the
|
|
313
|
+
execution switches to the caller context
|
|
314
|
+
(in `yield` or `await` expression) the context is
|
|
315
|
+
restored to that of the caller, and when it
|
|
316
|
+
re-enters the paused code block, the isolated
|
|
317
|
+
context is restored.
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
from extracontext import ContextLocal
|
|
322
|
+
|
|
323
|
+
ctx = ContextLocal()
|
|
324
|
+
|
|
325
|
+
@ctx
|
|
326
|
+
def isolated_example():
|
|
327
|
+
|
|
328
|
+
ctx.value = 2
|
|
329
|
+
assert ctx.value = 2
|
|
330
|
+
|
|
331
|
+
ctx.value = 1
|
|
332
|
+
isolated_example()
|
|
333
|
+
assert ctx.value == 1
|
|
334
|
+
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
#### Usage as a context manager
|
|
338
|
+
|
|
339
|
+
A `ContextLocal` instance can simply be used in a
|
|
340
|
+
context manager `with` statement, and any variables
|
|
341
|
+
set or changed within the block will not be
|
|
342
|
+
persisted after the block is over.
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
from extracontext import ContextLocal
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def with_block_example():
|
|
349
|
+
|
|
350
|
+
ctx = ContextLocal()
|
|
351
|
+
ctx.value = 1
|
|
352
|
+
with ctx:
|
|
353
|
+
ctx.value = 2
|
|
354
|
+
assert ctx.value == 2
|
|
355
|
+
|
|
356
|
+
assert ctx.value == 1
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Also, they are re-entrant, so if in a function called
|
|
361
|
+
within the block, the context is used again
|
|
362
|
+
as a context manager, it will just work.
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
#### Semantic difference to contextvars.ContextVar
|
|
366
|
+
Note that a fresh `ContextLocal()` instance will
|
|
367
|
+
be empty, and have access to none of the values _or names_
|
|
368
|
+
set in another instance. This contrasts sharply with
|
|
369
|
+
`contextvars.Context`, for which each `contextvars.ContextVar`
|
|
370
|
+
created anywhere else in the program (even 3rd party
|
|
371
|
+
modules) is a valid key.
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
### PyContextLocal
|
|
375
|
+
ContextLocal implementation using pure Python code, and
|
|
376
|
+
reimplementing the functionalities of Contexts and ContextVars
|
|
377
|
+
as implemented by PEP 567 fro scratch.
|
|
378
|
+
|
|
379
|
+
It works by seeting, in a "hidden" way, values in the caller's
|
|
380
|
+
closure (the `locals()` namespace). Though writting
|
|
381
|
+
to this namespace has traditionally been a "grey area"
|
|
382
|
+
in Python, the way it makes use of this data is compliant
|
|
383
|
+
with the specs in [PEP-558](https://peps.python.org/pep-0558/)
|
|
384
|
+
which officializes this use for Python 3.13 and beyond
|
|
385
|
+
(and it has always worked since Python 3.0.
|
|
386
|
+
The first implementations of this code where
|
|
387
|
+
tested against Python 3.4 and forward)
|
|
388
|
+
|
|
389
|
+
It should be kept in place for the time being,
|
|
390
|
+
and could be useful to allow customizations,
|
|
391
|
+
workarounds, or buggy behavior bypassing
|
|
392
|
+
where the native implementation presents
|
|
393
|
+
any short-commings.
|
|
394
|
+
|
|
395
|
+
It is not an easy to follow code, as in
|
|
396
|
+
one hand there are introspection and meta-programming
|
|
397
|
+
patterns to handle access to the data in a containirized way.
|
|
398
|
+
|
|
399
|
+
Keep in mind that native contexvars use an
|
|
400
|
+
internal copy-on-write structure in native code
|
|
401
|
+
which should be much more performant than
|
|
402
|
+
the chain-mapping checks used in this backend.
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
It has been throughfully tested and should be bug free,
|
|
406
|
+
though less performant.
|
|
407
|
+
|
|
408
|
+
### NativeContextLocal
|
|
409
|
+
|
|
410
|
+
This leverages on PEP 567 Contexts and ContextVars
|
|
411
|
+
to perform all the isolation and setting mechanics,
|
|
412
|
+
and provides an convenient wrapper layer
|
|
413
|
+
which works as a namespace (and as mapping in NativeContextMap)
|
|
414
|
+
|
|
415
|
+
It was made the default mechanism due to obvious
|
|
416
|
+
performances and updates taking place in the
|
|
417
|
+
embedded implementation in the language.
|
|
418
|
+
|
|
419
|
+
The normal ContextVarsAPI exposed to Python
|
|
420
|
+
would not allow for changing context inside the
|
|
421
|
+
same function, requiring a `Context.run` call
|
|
422
|
+
as the only way to switch contexts. Instead of releasing this
|
|
423
|
+
backend without this mechanism, it has been opted
|
|
424
|
+
to call the native cAPI for changing
|
|
425
|
+
context (using `ctypes` in cPython, and the relevant internal
|
|
426
|
+
calls on pypy) so that the feature can work.
|
|
427
|
+
|
|
428
|
+
When this feature was implemented, `NativeContextLocal`
|
|
429
|
+
instances could then work as a context-manager using
|
|
430
|
+
the `with` statement, and there were no reasons why
|
|
431
|
+
they should not be the default backend. Some
|
|
432
|
+
coding effort were placed in the "Reverse subclass picking"
|
|
433
|
+
mechanism, and it was made te default in a backwards-
|
|
434
|
+
compatible way.
|
|
435
|
+
|
|
436
|
+
### ContextMap
|
|
437
|
+
|
|
438
|
+
`ContextMap` is a `ContextLocal` subclass which implements
|
|
439
|
+
[the `MutableMapping` interface](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping).
|
|
440
|
+
It is pretty straightforward in
|
|
441
|
+
that, so that assigments and retrievals using the `ctx["key"]`
|
|
442
|
+
syntax are made available, functionality with the
|
|
443
|
+
`in`, `==`, `!=` operators and the `keys`, `items`, `values`, `get`, `pop`, `popitem`, `clear`, `update`, and `setdefault` methods.
|
|
444
|
+
|
|
445
|
+
It supports loadding a mapping with the initial context contents, passed as
|
|
446
|
+
the `initial` positional argument - but not keyword-args mapping to initial
|
|
447
|
+
content (as in `dict(a=1)`).
|
|
448
|
+
|
|
449
|
+
Also, it is a subclass of ContextLocal - so it also allows access to the
|
|
450
|
+
keys with the dotted attribute syntax:
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
|
|
454
|
+
a = extracontext.ContextMap
|
|
455
|
+
|
|
456
|
+
a["b"] = 1
|
|
457
|
+
|
|
458
|
+
assert a.b == 1
|
|
459
|
+
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
And finally, it uses the same `backend` keyword-arg mechanism to switch between the default
|
|
463
|
+
native-context vars backend and the pure Python backend, which will yield either
|
|
464
|
+
a `PyContextMap` or a `NativeContextMap` instance, accordingly.
|
|
465
|
+
|
|
466
|
+
### PyContextMap
|
|
467
|
+
`ContextMap` implementation as a subclass of `PyContextLocal`
|
|
468
|
+
|
|
469
|
+
### NativeContextMap
|
|
470
|
+
`ContextMap` implementation as a subclass of `NativeContextLocal`
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
### History
|
|
475
|
+
The original implementation from 2019 re-creates
|
|
476
|
+
all the functionality provided by the PEP 567
|
|
477
|
+
contextvars using pure Python code and a lot
|
|
478
|
+
of introspection and meta-programming.
|
|
479
|
+
Not sure why it did that - but one thing is that
|
|
480
|
+
it coud provide the functionality for older
|
|
481
|
+
Pythons at the time, and possibly also because
|
|
482
|
+
I did not see, at the time, other ways
|
|
483
|
+
to workaround the need to call a function
|
|
484
|
+
in order to switch contexts.
|
|
485
|
+
|
|
486
|
+
At some revival sprint in 2021, a backend
|
|
487
|
+
using native contextvars was created -
|
|
488
|
+
and it just got to completion,
|
|
489
|
+
with all features and tests for the edge clases in
|
|
490
|
+
August 2024, after other periods of non-activity.
|
|
491
|
+
|
|
492
|
+
At this point, a mechanism for picking the
|
|
493
|
+
desired backend was implemented, and the native
|
|
494
|
+
`ContextLocal` class was switched to use the
|
|
495
|
+
native stdlib contextvars as backend by default.
|
|
496
|
+
(This should be much faster - benchmark
|
|
497
|
+
contributions are welcome, though :-) )
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
## New for 1.0
|
|
501
|
+
|
|
502
|
+
Switch the backend to use native Python contextvars (exposed in
|
|
503
|
+
the stdlib "contextvars" module by default.
|
|
504
|
+
|
|
505
|
+
Up to the update in July/Aug 2024 the core package functionality
|
|
506
|
+
was provided by a pure Python implementation which keeps context state
|
|
507
|
+
in a hidden frame-local variables - while that is throughfully tested
|
|
508
|
+
it performs a linear lookup in all the callchain for the context namespace.
|
|
509
|
+
|
|
510
|
+
For the 0.3 release, the "native" stdlib contextvars.ContextVar backed class,
|
|
511
|
+
has reached first class status, and is now the default method used.
|
|
512
|
+
|
|
513
|
+
The extracontext.NativeContextLocal class builds on Python's contextvars
|
|
514
|
+
instead of reimplementing all the functionality from scratch, and makes
|
|
515
|
+
simple namespaces and decorator-based scope isolation just work, with
|
|
516
|
+
all the safety and performance of the Python native implementation,
|
|
517
|
+
with none of the boilerplate or confuse API.
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
## Next Steps
|
|
521
|
+
|
|
522
|
+
1. Implementing more of the features possible with the contextvars semantics
|
|
523
|
+
- `.run` and `.copy` methods
|
|
524
|
+
- direct access to "`Token`"s as used by contextvars
|
|
525
|
+
- default value setting for variables
|
|
526
|
+
|
|
527
|
+
1. A feature allowing other threads to start from a copy of the current context, instead of an empty context. (asyncio independent tasks always see a copy)
|
|
528
|
+
|
|
529
|
+
1. Bringing in some more typing support
|
|
530
|
+
(not sure what will be possible, but I believe some
|
|
531
|
+
`typing.Protocol` templates at least. On an
|
|
532
|
+
initial search, typing for namespaces is not
|
|
533
|
+
a widelly known feature (if at all)
|
|
534
|
+
|
|
535
|
+
1. (maybe?) Proper multiprocessing support:
|
|
536
|
+
- ironing out probable serialization issues,
|
|
537
|
+
- allowing subprocess workers to start from a copy of the current context.
|
|
538
|
+
|
|
539
|
+
1. (maybe?) support for nested namespaces and maps.
|
|
540
|
+
|
|
541
|
+
### Old "Next Steps":
|
|
542
|
+
-----------
|
|
543
|
+
(not so sure about these - they are fruit of some 2019 brainstorming for
|
|
544
|
+
features in a project I am not coding for anymore)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
1. Add a way to chain-contexts, so, for example
|
|
548
|
+
and app can have a root context with default values
|
|
549
|
+
|
|
550
|
+
1. Describe the capabilities of each Context class clearly in a data-scheme,
|
|
551
|
+
so one gets to know, and how to retrieve classes that can behave like maps, or
|
|
552
|
+
allow/hide outter context values, work as a full stack, support the context protocol (`with` command),
|
|
553
|
+
etc... (this is more pressing since stlib contextvar backed Context classes will
|
|
554
|
+
not allow for some of the capabilities in the pure-Python reimplementation in "ContextLocal")
|
|
555
|
+
|
|
556
|
+
1. Add a way to merge wrappers for different ContextLocal instances on the same function
|
|
557
|
+
|
|
558
|
+
1. Add an "auto" flag - all called functions/generators/co-routines create a child context by default.
|
|
559
|
+
|
|
560
|
+
1. Add support for a descriptor-like variable slot - so that values can trigger code when set or retrieved
|
|
561
|
+
|
|
562
|
+
1. Shared values and locks: values that are guarranteed to be the same across tasks/threads, and a lock mechanism allowing atomic operations with these values.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
extracontext/__init__.py,sha256=ZzNamvA_xkIovBmRzh11yjjj9ReObwXTYUAVFtxadI8,314
|
|
2
|
+
extracontext/_future_task.py,sha256=PEPWIJDdpLvvu90r-rD5btHnnyVTJ1dghiggURIbTW8,1898
|
|
3
|
+
extracontext/base.py,sha256=cuAAD93HdzfQp5kVIsB_vzY74o4j603cXgtB_HaelYM,663
|
|
4
|
+
extracontext/contextlocal.py,sha256=xEjqgHJ5vs44geSWCsoHJZ4RkcPVU7ZngHLI_Gh-pvU,9896
|
|
5
|
+
extracontext/contextlocal_native.py,sha256=uE67YmO-i40vd89AWOR67_AnnHW1milTPycZuGrNmKM,8771
|
|
6
|
+
extracontext/mapping.py,sha256=IQvJreYKGujBIlcrq7GXVggopORaMT3SyIeLXNCkM1A,1888
|
|
7
|
+
python_extracontext-1.0.0.dist-info/METADATA,sha256=FKiYOthSEoTvug6FV2D04U9-3uWAHHSjG8q4ExQe0F4,17980
|
|
8
|
+
python_extracontext-1.0.0.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
|
|
9
|
+
python_extracontext-1.0.0.dist-info/top_level.txt,sha256=r0JvexrESeoDk32GNGKs1pSnl-SA_3YiMrDcGIT952A,13
|
|
10
|
+
python_extracontext-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
extracontext
|