Sometimes, in special cases, it is desired that code can pass information down the function call chain to the callees without having to explicitly pass the information as arguments to each function in the call chain. This proposal describes a construct which allows code to explicitly switch in and out of a context where a certain context variable has a given value assigned to it. This is a modern alternative to some uses of things like global variables in traditional single-threaded (or thread-unsafe) code and of thread-local storage in traditionalconcurrency-unsafe code (single- or multi-threaded). In particular, the proposed mechanism can also be used with more modern concurrent execution mechanisms such as asynchronously executed coroutines, without the concurrently executed call chains interfering with each other’s contexts.
The “call chain” can consist of normal functions, awaited coroutines, or generators. The semantics of context variable scope are equivalent in all cases, allowing code to be refactored freely intosubroutines (which here refers to functions, sub-generators or sub-coroutines) without affecting the semantics of context variables. Regarding implementation, this proposal aims at simplicity and minimum changes to the CPython interpreter and to other Python interpreters.
Consider a modern Pythoncall chain (or call tree), which in this proposal refers to any chained (nested) execution ofsubroutines, using any possible combinations of normal function calls, or expressions usingawait oryieldfrom. In some cases, passing necessaryinformation down the call chain as arguments can substantially complicate the required function signatures, or it can even be impossible to achieve in practice. In these cases, one may search for another place to store this information. Let us look at some historical examples.
The most naive option is to assign the value to a global variable or similar, where the code down the call chain can access it. However, this immediately makes the code thread-unsafe, because with multiple threads, all threads assign to the same global variable, and another thread can interfere at any point in the call chain. Sooner or later, someone will probably find a reason to run the same code in parallel threads.
A somewhat less naive option is to store the information as per-thread information in thread-local storage, where each thread has its own “copy” of the variable which other threads cannot interfere with. Although non-ideal, this has been the best solution in many cases. However, thanks to generators and coroutines, the execution of the call chain can be suspended and resumed, allowing code in other contexts to run concurrently. Therefore, using thread-local storage isconcurrency-unsafe, because other call chains in other contexts may interfere with the thread-local variable.
Note that in the above two historical approaches, the stored information has thewidest available scope without causing problems. For a third solution along the same path, one would first define an equivalent of a “thread” for asynchronous execution and concurrency. This could be seen as the largest amount of code and nested calls that is guaranteed to be executed sequentially without ambiguity in execution order. This might be referred to as concurrency-local or task-local storage. In this meaning of “task”, there is no ambiguity in the order of execution of the code within one task. (This concept of a task is close to equivalent to aTask inasyncio, but not exactly.) In such concurrency-locals, it is possible to pass information down the call chain to callees without another code path interfering with the value in the background.
Common to the above approaches is that they indeed use variables with a wide but just-narrow-enough scope. Thread-locals could also be called thread-wide globals—in single-threaded code, they are indeed truly global. And task-locals could be called task-wide globals, because tasks can be very big.
The issue here is that neither global variables, thread-locals nor task-locals are really meant to be used for this purpose of passing information of the execution context down the call chain. Instead of the widest possible variable scope, the scope of the variables should be controlled by the programmer, typically of a library, to have the desired scope—not wider. In other words, task-local variables (and globals and thread-locals) have nothing to do with the kind of context-bound information passing that this proposal intends to enable, even if task-locals can be used to emulate the desired semantics. Therefore, in the following, this proposal describes the semantics and the outlines of an implementation forcontext-local variables (or context variables, contextvars). In fact, as a side effect of this PEP, an async framework can use the proposed feature to implement task-local variables.
Because the proposed semantics are not a direct extension to anything already available in Python, this proposal is first described in terms of semantics and API at a fairly high level. In particular, Pythonwith statements are heavily used in the description, as they are a good match with the proposed semantics. However, the underlying__enter__ and__exit__ methods correspond to functions in the lower-level speed-optimized (C) API. For clarity of this document, the lower-level functions are not explicitly named in the definition of the semantics. After describing the semantics and high-level API, the implementation is described, going to a lower level.
A context-local variable is represented by a single instance ofcontextvars.Var, saycvar. Any code that has access to thecvar object can ask for its value with respect to the current context. In the high-level API, this value is given by thecvar.value property:
cvar=contextvars.Var(default="the default value",description="example context variable")assertcvar.value=="the default value"# default still applies# In code examples, all ``assert`` statements should# succeed according to the proposed semantics.
No assignments tocvar have been applied for this context, socvar.value gives the default value. Assigning new values to contextvars is done in a highly scope-aware manner:
withcvar.assign(new_value):assertcvar.valueisnew_value# Any code here, or down the call chain from here, sees:# cvar.value is new_value# unless another value has been assigned in a# nested contextassertcvar.valueisnew_value# the assignment of ``cvar`` to ``new_value`` is no longer visibleassertcvar.value=="the default value"
Here,cvar.assign(value) returns another object, namelycontextvars.Assignment(cvar,new_value). The essential part here is that applying a context variable assignment (Assignment.__enter__) is paired with a de-assignment (Assignment.__exit__). These operations set the bounds for the scope of the assigned value.
Assignments to the same context variable can be nested to override the outer assignment in a narrower context:
assertcvar.value=="the default value"withcvar.assign("outer"):assertcvar.value=="outer"withcvar.assign("inner"):assertcvar.value=="inner"assertcvar.value=="outer"assertcvar.value=="the default value"
Also multiple variables can be assigned to in a nested manner without affecting each other:
cvar1=contextvars.Var()cvar2=contextvars.Var()assertcvar1.valueisNone# default is None by defaultassertcvar2.valueisNonewithcvar1.assign(value1):assertcvar1.valueisvalue1assertcvar2.valueisNonewithcvar2.assign(value2):assertcvar1.valueisvalue1assertcvar2.valueisvalue2assertcvar1.valueisvalue1assertcvar2.valueisNoneassertcvar1.valueisNoneassertcvar2.valueisNone
Or with more convenient Python syntax:
withcvar1.assign(value1),cvar2.assign(value2):assertcvar1.valueisvalue1assertcvar2.valueisvalue2
In anothercontext, in another thread or otherwise concurrently executed task or code path, the context variables can have a completely different state. The programmer thus only needs to worry about the context at hand.
Code using contextvars can be refactored into subroutines without affecting the semantics. For instance:
assi=cvar.assign(new_value)defapply():assi.__enter__()assertcvar.value=="the default value"apply()assertcvar.valueisnew_valueassi.__exit__()assertcvar.value=="the default value"
Or similarly in an asynchronous context whereawait expressions are used. The subroutine can now be a coroutine:
assi=cvar.assign(new_value)asyncdefapply():assi.__enter__()assertcvar.value=="the default value"awaitapply()assertcvar.valueisnew_valueassi.__exit__()assertcvar.value=="the default value"
Or when the subroutine is a generator:
defapply():yieldassi.__enter__()
which is called usingyieldfromapply() or with calls tonext or.send. This is discussed further in later sections.
Generators, coroutines and async generators act as subroutines in much the same way that normal functions do. However, they have the additional possibility of being suspended byyield expressions. Assignment contexts entered inside a generator are normally preserved across yields:
defgenfunc():withcvar.assign(new_value):assertcvar.valueisnew_valueyieldassertcvar.valueisnew_valueg=genfunc()next(g)assertcvar.value=="the default value"withcvar.assign(another_value):next(g)
However, the outer context visible to the generator may change state across yields:
defgenfunc():assertcvar.valueisvalue2yieldassertcvar.valueisvalue1yieldwithcvar.assign(value3):assertcvar.valueisvalue3withcvar.assign(value1):g=genfunc()withcvar.assign(value2):next(g)next(g)next(g)assertcvar.valueisvalue1
Similar semantics apply to async generators defined byasyncdef...yield... ).
By default, values assigned inside a generator do not leak through yields to the code that drives the generator. However, the assignment contexts entered and left open inside the generatordo become visible outside the generator after the generator has finished with aStopIteration or another exception:
assi=cvar.assign(new_value)defgenfunc():yieldassi.__enter__():yieldg=genfunc()assertcvar.value=="the default value"next(g)assertcvar.value=="the default value"next(g)# assi.__enter__() is called hereassertcvar.value=="the default value"next(g)assertcvar.valueisnew_valueassi.__exit__()
Frameworks, such asasyncio or third-party libraries, can use additional functionality incontextvars to achieve the desired semantics in cases which are not determined by the Python interpreter. Some of the semantics described in this section are also afterwards used to describe the internal implementation.
Using thecontextvars.leaking_yields decorator, one can choose to leak the context throughyield expressions into the outer context that drives the generator:
@contextvars.leaking_yieldsdefgenfunc():assertcvar.value=="outer"withcvar.assign("inner"):yieldassertcvar.value=="inner"assertcvar.value=="outer"g=genfunc():withcvar.assign("outer"):assertcvar.value=="outer"next(g)assertcvar.value=="inner"next(g)assertcvar.value=="outer"
Usingcontextvars.capture(), one can capture the assignment contexts that are entered by a block of code. The changes applied by the block of code can then be reverted and subsequently reapplied, even in another context:
assertcvar1.valueisNone# defaultassertcvar2.valueisNone# defaultassi1=cvar1.assign(value1)assi2=cvar1.assign(value2)withcontextvars.capture()asdelta:assi1.__enter__()withcvar2.assign("not captured"):assertcvar2.valueis"not captured"assi2.__enter__()assertcvar1.valueisvalue2delta.revert()assertcvar1.valueisNoneassertcvar2.valueisNone...withcvar1.assign(1),cvar2.assign(2):delta.reapply()assertcvar1.valueisvalue2assertcvar2.value==2
However, reapplying the “delta” if its net contents include deassignments may not be possible (see also Implementation and Open Issues).
The functioncontextvars.get_local_state() returns an object representing the applied assignments to all context-local variables in the context where the function is called. This can be seen as equivalent to usingcontextvars.capture() to capture all context changes from the beginning of execution. The returned object supports methods.revert() andreapply() as above.
Although it is possible to revert all applied context changes using the above primitives, a more convenient way to run a block of code in a clean context is provided:
withcontext_vars.clean_context():# here, all context vars start off with their default values# here, the state is back to what it was before the with block.
This section describes to a variable level of detail how the described semantics can be implemented. At present, an implementation aimed at simplicity but sufficient features is described. More details will be added later.
Alternatively, a somewhat more complicated implementation offers minor additional features while adding some performance overhead and requiring more code in the implementation.
Each thread of the Python interpreter keeps its own stack ofcontextvars.Assignment objects, each having a pointer to the previous (outer) assignment like in a linked list. The local state (also returned bycontextvars.get_local_state()) then consists of a reference to the top of the stack and a pointer/weak reference to the bottom of the stack. This allows efficient stack manipulations. An object produced bycontextvars.capture() is similar, but refers to only a part of the stack with the bottom reference pointing to the top of the stack as it was in the beginning of the capture block.
Now, the stack evolves according to the assignment__enter__ and__exit__ methods. For example:
cvar1=contextvars.Var()cvar2=contextvars.Var()# stack: []assertcvar1.valueisNoneassertcvar2.valueisNonewithcvar1.assign("outer"):# stack: [Assignment(cvar1, "outer")]assertcvar1.value=="outer"withcvar1.assign("inner"):# stack: [Assignment(cvar1, "outer"),# Assignment(cvar1, "inner")]assertcvar1.value=="inner"withcvar2.assign("hello"):# stack: [Assignment(cvar1, "outer"),# Assignment(cvar1, "inner"),# Assignment(cvar2, "hello")]assertcvar2.value=="hello"# stack: [Assignment(cvar1, "outer"),# Assignment(cvar1, "inner")]assertcvar1.value=="inner"assertcvar2.valueisNone# stack: [Assignment(cvar1, "outer")]assertcvar1.value=="outer"# stack: []assertcvar1.valueisNoneassertcvar2.valueisNone
Getting a value from the context usingcvar1.value can be implemented as finding the topmost occurrence of acvar1 assignment on the stack and returning the value there, or the default value if no assignment is found on the stack. However, this can be optimized to instead be an O(1) operation in most cases. Still, even searching through the stack may be reasonably fast since these stacks are not intended to grow very large.
The above description is already sufficient for implementing the core concept. Suspendable frames require some additional attention, as explained in the following.
Within generators, coroutines and async generators, assignments and deassignments are handled in exactly the same way as anywhere else. However, some changes are needed in the builtin generator methodssend,__next__,throw andclose. Here is the Python equivalent of the changes needed insend for a generator (here_old_send refers to the behavior in Python 3.6):
defsend(self,value):ifself.gi_contextvarsisLEAK:# If decorated with contextvars.leaking_yields.# Nothing needs to be done to leak context through yields :)returnself._old_send(value)try:withcontextvars.capture()asdelta:ifself.gi_contextvars:# non-zero captured content from previous iterationself.gi_contextvars.reapply()ret=self._old_send(value)exceptException:raise# back to the calling frame (e.g. StopIteration)else:# suspending, revert context changes but save them for laterdelta.revert()self.gi_contextvars=deltareturnret
The corresponding modifications to the other methods is essentially identical. The same applies to coroutines and async generators.
For code that does not usecontextvars, the additions are O(1) and essentially reduce to a couple of pointer comparisons. For code that does usecontextvars, the additions are still O(1) in most cases.
The rest of the functionality, includingcontextvars.leaking_yields,contextvars.capture(),contextvars.get_local_state() andcontextvars.clean_context() are in fact quite straightforward to implement, but their implementation will be discussed further in later versions of this proposal. Caching of assigned values is somewhat more complicated, and will be discussed later, but it seems that most cases should achieve O(1) complexity.
There are nodirect backwards-compatibility concerns, since a completely new feature is proposed.
However, various traditional uses of thread-local storage may need a smooth transition tocontextvars so they can be concurrency-safe. There are several approaches to this, including emulating task-local storage with a little bit of help from async frameworks. A fully general implementation cannot be provided, because the desired semantics may depend on the design of the framework.
Another way to deal with the transition is for code to first look for a context created usingcontextvars. If that fails because a new-style context has not been set or because the code runs on an older Python version, a fallback to thread-local storage is used.
In this proposal, all variable deassignments are made in the opposite order compared to the preceding assignments. This has two useful properties: it encourages usingwith statements to define assignment scope and has a tendency to catch errors early (forgetting a.__exit__() call often results in a meaningful error. To have this as a requirement is beneficial also in terms of implementation simplicity and performance. Nevertheless, allowing out-of-order context exits is not completely out of the question, and reasonable implementation strategies for that do exist.
The scope of value visibility should not be determined by the way the code is refactored into subroutines. It is necessary to have per-variable control of the assignment scope.
To be added.
To be added.
This document has been placed in the public domain.
Source:https://github.com/python/peps/blob/main/peps/pep-0555.rst
Last modified:2025-02-01 08:59:27 GMT