Skip to content

Commit 0a20e79

Browse files
committed
Add PyContextVar_GetChanged() method.
1 parent 0055140 commit 0a20e79

File tree

8 files changed

+514
-1
lines changed

8 files changed

+514
-1
lines changed

Doc/c-api/contextvars.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,27 @@ Context variable functions:
182182
Reset the state of the *var* context variable to that it was in before
183183
:c:func:`PyContextVar_Set` that returned the *token* was called.
184184
This function returns ``0`` on success and ``-1`` on error.
185+
186+
.. c:function:: int PyContextVar_GetChanged(PyObject *var, PyObject *default_value, PyObject **value, int *changed)
187+
188+
Like :c:func:`PyContextVar_Get`, but also reports whether the variable was
189+
changed in the current context scope. This combines a value lookup with a
190+
change check in a single HAMT lookup.
191+
192+
Returns ``-1`` if an error has occurred during lookup, and ``0`` if no
193+
error occurred, whether or not a value was found.
194+
195+
On success, *\*value* is set following the same rules as
196+
:c:func:`PyContextVar_Get`. *\*changed* is set to ``1`` if the variable
197+
was changed (via :c:func:`PyContextVar_Set`) in the current context scope
198+
(i.e. within the current :meth:`~contextvars.Context.run` call) with a
199+
value that is a different object than the inherited one. Otherwise
200+
*\*changed* is set to ``0``. If the value was not found, *\*changed* is
201+
always ``0``.
202+
203+
If the current context was never entered (no :meth:`~contextvars.Context.run`
204+
is active), all existing bindings are considered "changed".
205+
206+
Except for ``NULL``, the function returns a new reference via *\*value*.
207+
208+
.. versionadded:: 3.15

Doc/data/refcounts.dat

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,12 @@ PyContextVar_Reset:int:::
400400
PyContextVar_Reset:PyObject*:var:0:
401401
PyContextVar_Reset:PyObject*:token:-1:
402402

403+
PyContextVar_GetChanged:int:::
404+
PyContextVar_GetChanged:PyObject*:var:0:
405+
PyContextVar_GetChanged:PyObject*:default_value:0:
406+
PyContextVar_GetChanged:PyObject**:value:+1:???
407+
PyContextVar_GetChanged:int*:changed::
408+
403409
PyCFunction_New:PyObject*::+1:
404410
PyCFunction_New:PyMethodDef*:ml::
405411
PyCFunction_New:PyObject*:self:+1:

Doc/library/contextvars.rst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,41 @@ Context Variables
119119

120120
The same *token* cannot be used twice.
121121

122+
.. method:: get_changed([default])
123+
124+
Like :meth:`ContextVar.get`, but returns a tuple ``(value, changed)``
125+
where *changed* indicates whether the variable was changed in the
126+
current context scope.
127+
128+
A variable is considered "changed" if :meth:`ContextVar.set` has been
129+
called on it within the current :meth:`Context.run` call with a value
130+
that is a different object than the inherited one. Variables inherited
131+
unchanged from a parent context scope are not considered "changed".
132+
If no :meth:`Context.run` is active, all existing bindings are
133+
considered "changed". When the value comes from a default, *changed*
134+
is always ``False``.
135+
136+
This is useful when a context variable holds a mutable object that
137+
needs to be copied on first access in a new context scope to ensure
138+
modifications are local to that scope::
139+
140+
_ctx_var = ContextVar('ctx_var')
141+
142+
def get_ctx():
143+
try:
144+
ctx, changed = _ctx_var.get_changed()
145+
except LookupError:
146+
ctx = default_context()
147+
_ctx_var.set(ctx)
148+
return ctx
149+
150+
if not changed:
151+
ctx = ctx.copy()
152+
_ctx_var.set(ctx)
153+
return ctx
154+
155+
.. versionadded:: 3.15
156+
122157

123158
.. class:: Token
124159

Include/cpython/context.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ PyAPI_FUNC(PyObject *) PyContextVar_Set(PyObject *var, PyObject *value);
100100
PyAPI_FUNC(int) PyContextVar_Reset(PyObject *var, PyObject *token);
101101

102102

103+
/* Get a value for the variable and check if it was changed.
104+
105+
Like PyContextVar_Get, but also reports whether the variable was
106+
changed in the current context scope via a single HAMT lookup.
107+
108+
Returns -1 if an error occurred during lookup.
109+
110+
Returns 0 if no error occurred. In this case:
111+
112+
- *value will be set the same as for PyContextVar_Get.
113+
- *changed will be set to 1 if the variable was changed in the
114+
current context scope, 0 otherwise. If the variable was not
115+
found, *changed is always 0.
116+
117+
'*value' will be a new ref, if not NULL.
118+
*/
119+
PyAPI_FUNC(int) PyContextVar_GetChanged(
120+
PyObject *var, PyObject *default_value, PyObject **value, int *changed);
121+
122+
103123
#ifdef __cplusplus
104124
}
105125
#endif

Include/internal/pycore_context.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ struct _pycontextobject {
2424
PyObject_HEAD
2525
PyContext *ctx_prev;
2626
PyHamtObject *ctx_vars;
27+
PyHamtObject *ctx_vars_origin; /* snapshot of ctx_vars at Enter time */
2728
PyObject *ctx_weakreflist;
2829
int ctx_entered;
2930
};

Lib/test/test_context.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,226 @@ def __eq__(self, other):
586586
ctx2.run(var.set, ReentrantHash())
587587
ctx1 == ctx2
588588

589+
def test_get_changed_outside_run(self):
590+
# Outside any Context.run(), bindings are considered "changed"
591+
v = contextvars.ContextVar('v', default='dflt')
592+
val, changed = v.get_changed()
593+
self.assertEqual(val, 'dflt')
594+
self.assertFalse(changed) # default value, not changed
595+
v.set(42)
596+
val, changed = v.get_changed()
597+
self.assertEqual(val, 42)
598+
self.assertTrue(changed) # set in base context
599+
600+
def test_get_changed_inherited(self):
601+
# Inherited bindings are not considered "changed"
602+
v = contextvars.ContextVar('v')
603+
v.set('parent')
604+
ctx = contextvars.copy_context()
605+
606+
def check():
607+
val, changed = v.get_changed()
608+
self.assertEqual(val, 'parent')
609+
self.assertFalse(changed)
610+
ctx.run(check)
611+
612+
def test_get_changed_after_set(self):
613+
# After set() inside Context.run(), changed is True
614+
v = contextvars.ContextVar('v')
615+
v.set('parent')
616+
ctx = contextvars.copy_context()
617+
618+
def check():
619+
val, changed = v.get_changed()
620+
self.assertFalse(changed)
621+
v.set('child')
622+
val, changed = v.get_changed()
623+
self.assertEqual(val, 'child')
624+
self.assertTrue(changed)
625+
ctx.run(check)
626+
627+
def test_get_changed_new_var_in_run(self):
628+
# A variable set for the first time inside run() is "changed"
629+
v = contextvars.ContextVar('v')
630+
ctx = contextvars.copy_context()
631+
632+
def check():
633+
with self.assertRaises(LookupError):
634+
v.get_changed()
635+
v.set('new')
636+
val, changed = v.get_changed()
637+
self.assertEqual(val, 'new')
638+
self.assertTrue(changed)
639+
ctx.run(check)
640+
641+
def test_get_changed_not_set_with_default(self):
642+
# A variable not set but with default: changed is False
643+
v = contextvars.ContextVar('v', default='dflt')
644+
ctx = contextvars.copy_context()
645+
646+
def check():
647+
val, changed = v.get_changed()
648+
self.assertEqual(val, 'dflt')
649+
self.assertFalse(changed)
650+
ctx.run(check)
651+
652+
def test_get_changed_not_set_no_default(self):
653+
# A variable that has never been set and has no default
654+
v = contextvars.ContextVar('v')
655+
ctx = contextvars.copy_context()
656+
657+
def check():
658+
with self.assertRaises(LookupError):
659+
v.get_changed()
660+
ctx.run(check)
661+
662+
def test_get_changed_explicit_default_arg(self):
663+
# Passing a default argument to get_changed()
664+
v = contextvars.ContextVar('v')
665+
ctx = contextvars.copy_context()
666+
667+
def check():
668+
val, changed = v.get_changed('fallback')
669+
self.assertEqual(val, 'fallback')
670+
self.assertFalse(changed)
671+
ctx.run(check)
672+
673+
def test_get_changed_set_same_object(self):
674+
# Setting to the exact same object does not count as "changed"
675+
# because the HAMT recognizes the identical key-value pair
676+
obj = object()
677+
v = contextvars.ContextVar('v')
678+
v.set(obj)
679+
ctx = contextvars.copy_context()
680+
681+
def check():
682+
val, changed = v.get_changed()
683+
self.assertIs(val, obj)
684+
self.assertFalse(changed)
685+
v.set(obj) # same object
686+
val, changed = v.get_changed()
687+
self.assertIs(val, obj)
688+
self.assertFalse(changed)
689+
ctx.run(check)
690+
691+
def test_get_changed_set_different_object(self):
692+
# Setting to a different object counts as "changed"
693+
v = contextvars.ContextVar('v')
694+
v.set([1, 2, 3])
695+
ctx = contextvars.copy_context()
696+
697+
def check():
698+
val, changed = v.get_changed()
699+
self.assertFalse(changed)
700+
v.set([1, 2, 3]) # equal value, different object
701+
val, changed = v.get_changed()
702+
self.assertTrue(changed)
703+
ctx.run(check)
704+
705+
def test_get_changed_after_reset(self):
706+
# After reset(), the variable reverts to its inherited state
707+
v = contextvars.ContextVar('v')
708+
v.set('original')
709+
ctx = contextvars.copy_context()
710+
711+
def check():
712+
val, changed = v.get_changed()
713+
self.assertFalse(changed)
714+
tok = v.set('modified')
715+
val, changed = v.get_changed()
716+
self.assertTrue(changed)
717+
v.reset(tok)
718+
val, changed = v.get_changed()
719+
self.assertFalse(changed)
720+
ctx.run(check)
721+
722+
def test_get_changed_multiple_vars(self):
723+
# Changing one variable does not affect get_changed() for others
724+
v1 = contextvars.ContextVar('v1')
725+
v2 = contextvars.ContextVar('v2')
726+
v1.set('a')
727+
v2.set('b')
728+
ctx = contextvars.copy_context()
729+
730+
def check():
731+
_, changed1 = v1.get_changed()
732+
_, changed2 = v2.get_changed()
733+
self.assertFalse(changed1)
734+
self.assertFalse(changed2)
735+
v1.set('a2')
736+
_, changed1 = v1.get_changed()
737+
_, changed2 = v2.get_changed()
738+
self.assertTrue(changed1)
739+
self.assertFalse(changed2)
740+
ctx.run(check)
741+
742+
def test_get_changed_nested_run(self):
743+
# get_changed() reflects the innermost Context.run() scope
744+
v = contextvars.ContextVar('v')
745+
v.set('root')
746+
ctx1 = contextvars.copy_context()
747+
748+
def outer():
749+
_, changed = v.get_changed()
750+
self.assertFalse(changed)
751+
v.set('outer')
752+
_, changed = v.get_changed()
753+
self.assertTrue(changed)
754+
ctx2 = contextvars.copy_context()
755+
756+
def inner():
757+
# inherited 'outer' from ctx1, not changed in ctx2
758+
val, changed = v.get_changed()
759+
self.assertEqual(val, 'outer')
760+
self.assertFalse(changed)
761+
v.set('inner')
762+
val, changed = v.get_changed()
763+
self.assertEqual(val, 'inner')
764+
self.assertTrue(changed)
765+
ctx2.run(inner)
766+
767+
# after inner run exits, outer's state is restored
768+
_, changed = v.get_changed()
769+
self.assertTrue(changed)
770+
ctx1.run(outer)
771+
772+
def test_get_changed_with_threads(self):
773+
# get_changed() works correctly in a thread with copied context
774+
import threading
775+
v = contextvars.ContextVar('v')
776+
v.set('parent')
777+
ctx = contextvars.copy_context()
778+
results = {}
779+
780+
def thread_func():
781+
val, changed = v.get_changed()
782+
results['inherited'] = changed
783+
results['value'] = val
784+
v.set('thread')
785+
val, changed = v.get_changed()
786+
results['after_set'] = changed
787+
788+
t = threading.Thread(target=ctx.run, args=(thread_func,))
789+
t.start()
790+
t.join()
791+
self.assertFalse(results['inherited'])
792+
self.assertEqual(results['value'], 'parent')
793+
self.assertTrue(results['after_set'])
794+
795+
def test_get_changed_empty_context_run(self):
796+
# Running in a brand new empty context
797+
v = contextvars.ContextVar('v')
798+
ctx = contextvars.Context()
799+
800+
def check():
801+
with self.assertRaises(LookupError):
802+
v.get_changed()
803+
v.set('value')
804+
val, changed = v.get_changed()
805+
self.assertEqual(val, 'value')
806+
self.assertTrue(changed)
807+
ctx.run(check)
808+
589809

590810
# HAMT Tests
591811

0 commit comments

Comments
 (0)