From 0a20e79268eafd2c3d3121e912ec8f165946f750 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 25 Mar 2026 17:17:24 -0700 Subject: [PATCH 1/3] Add PyContextVar_GetChanged() method. --- Doc/c-api/contextvars.rst | 24 ++++ Doc/data/refcounts.dat | 6 + Doc/library/contextvars.rst | 35 +++++ Include/cpython/context.h | 20 +++ Include/internal/pycore_context.h | 1 + Lib/test/test_context.py | 220 ++++++++++++++++++++++++++++++ Python/clinic/context.c.h | 48 ++++++- Python/context.c | 161 ++++++++++++++++++++++ 8 files changed, 514 insertions(+), 1 deletion(-) diff --git a/Doc/c-api/contextvars.rst b/Doc/c-api/contextvars.rst index b7c6550ff34aac..1a69888180b9a4 100644 --- a/Doc/c-api/contextvars.rst +++ b/Doc/c-api/contextvars.rst @@ -182,3 +182,27 @@ Context variable functions: Reset the state of the *var* context variable to that it was in before :c:func:`PyContextVar_Set` that returned the *token* was called. This function returns ``0`` on success and ``-1`` on error. + +.. c:function:: int PyContextVar_GetChanged(PyObject *var, PyObject *default_value, PyObject **value, int *changed) + + Like :c:func:`PyContextVar_Get`, but also reports whether the variable was + changed in the current context scope. This combines a value lookup with a + change check in a single HAMT lookup. + + Returns ``-1`` if an error has occurred during lookup, and ``0`` if no + error occurred, whether or not a value was found. + + On success, *\*value* is set following the same rules as + :c:func:`PyContextVar_Get`. *\*changed* is set to ``1`` if the variable + was changed (via :c:func:`PyContextVar_Set`) in the current context scope + (i.e. within the current :meth:`~contextvars.Context.run` call) with a + value that is a different object than the inherited one. Otherwise + *\*changed* is set to ``0``. If the value was not found, *\*changed* is + always ``0``. + + If the current context was never entered (no :meth:`~contextvars.Context.run` + is active), all existing bindings are considered "changed". + + Except for ``NULL``, the function returns a new reference via *\*value*. + + .. versionadded:: 3.15 diff --git a/Doc/data/refcounts.dat b/Doc/data/refcounts.dat index 01b064f3e617ff..f975a31ab2c3bf 100644 --- a/Doc/data/refcounts.dat +++ b/Doc/data/refcounts.dat @@ -400,6 +400,12 @@ PyContextVar_Reset:int::: PyContextVar_Reset:PyObject*:var:0: PyContextVar_Reset:PyObject*:token:-1: +PyContextVar_GetChanged:int::: +PyContextVar_GetChanged:PyObject*:var:0: +PyContextVar_GetChanged:PyObject*:default_value:0: +PyContextVar_GetChanged:PyObject**:value:+1:??? +PyContextVar_GetChanged:int*:changed:: + PyCFunction_New:PyObject*::+1: PyCFunction_New:PyMethodDef*:ml:: PyCFunction_New:PyObject*:self:+1: diff --git a/Doc/library/contextvars.rst b/Doc/library/contextvars.rst index 93d0c0d34bf039..ac4bfdf3361db3 100644 --- a/Doc/library/contextvars.rst +++ b/Doc/library/contextvars.rst @@ -119,6 +119,41 @@ Context Variables The same *token* cannot be used twice. + .. method:: get_changed([default]) + + Like :meth:`ContextVar.get`, but returns a tuple ``(value, changed)`` + where *changed* indicates whether the variable was changed in the + current context scope. + + A variable is considered "changed" if :meth:`ContextVar.set` has been + called on it within the current :meth:`Context.run` call with a value + that is a different object than the inherited one. Variables inherited + unchanged from a parent context scope are not considered "changed". + If no :meth:`Context.run` is active, all existing bindings are + considered "changed". When the value comes from a default, *changed* + is always ``False``. + + This is useful when a context variable holds a mutable object that + needs to be copied on first access in a new context scope to ensure + modifications are local to that scope:: + + _ctx_var = ContextVar('ctx_var') + + def get_ctx(): + try: + ctx, changed = _ctx_var.get_changed() + except LookupError: + ctx = default_context() + _ctx_var.set(ctx) + return ctx + + if not changed: + ctx = ctx.copy() + _ctx_var.set(ctx) + return ctx + + .. versionadded:: 3.15 + .. class:: Token diff --git a/Include/cpython/context.h b/Include/cpython/context.h index 3a7a4b459c09ad..c756e4e40d30f8 100644 --- a/Include/cpython/context.h +++ b/Include/cpython/context.h @@ -100,6 +100,26 @@ PyAPI_FUNC(PyObject *) PyContextVar_Set(PyObject *var, PyObject *value); PyAPI_FUNC(int) PyContextVar_Reset(PyObject *var, PyObject *token); +/* Get a value for the variable and check if it was changed. + + Like PyContextVar_Get, but also reports whether the variable was + changed in the current context scope via a single HAMT lookup. + + Returns -1 if an error occurred during lookup. + + Returns 0 if no error occurred. In this case: + + - *value will be set the same as for PyContextVar_Get. + - *changed will be set to 1 if the variable was changed in the + current context scope, 0 otherwise. If the variable was not + found, *changed is always 0. + + '*value' will be a new ref, if not NULL. +*/ +PyAPI_FUNC(int) PyContextVar_GetChanged( + PyObject *var, PyObject *default_value, PyObject **value, int *changed); + + #ifdef __cplusplus } #endif diff --git a/Include/internal/pycore_context.h b/Include/internal/pycore_context.h index a833f790a621b1..b8f4e59213d3da 100644 --- a/Include/internal/pycore_context.h +++ b/Include/internal/pycore_context.h @@ -24,6 +24,7 @@ struct _pycontextobject { PyObject_HEAD PyContext *ctx_prev; PyHamtObject *ctx_vars; + PyHamtObject *ctx_vars_origin; /* snapshot of ctx_vars at Enter time */ PyObject *ctx_weakreflist; int ctx_entered; }; diff --git a/Lib/test/test_context.py b/Lib/test/test_context.py index ef20495dcc01ea..13886bf2827f10 100644 --- a/Lib/test/test_context.py +++ b/Lib/test/test_context.py @@ -586,6 +586,226 @@ def __eq__(self, other): ctx2.run(var.set, ReentrantHash()) ctx1 == ctx2 + def test_get_changed_outside_run(self): + # Outside any Context.run(), bindings are considered "changed" + v = contextvars.ContextVar('v', default='dflt') + val, changed = v.get_changed() + self.assertEqual(val, 'dflt') + self.assertFalse(changed) # default value, not changed + v.set(42) + val, changed = v.get_changed() + self.assertEqual(val, 42) + self.assertTrue(changed) # set in base context + + def test_get_changed_inherited(self): + # Inherited bindings are not considered "changed" + v = contextvars.ContextVar('v') + v.set('parent') + ctx = contextvars.copy_context() + + def check(): + val, changed = v.get_changed() + self.assertEqual(val, 'parent') + self.assertFalse(changed) + ctx.run(check) + + def test_get_changed_after_set(self): + # After set() inside Context.run(), changed is True + v = contextvars.ContextVar('v') + v.set('parent') + ctx = contextvars.copy_context() + + def check(): + val, changed = v.get_changed() + self.assertFalse(changed) + v.set('child') + val, changed = v.get_changed() + self.assertEqual(val, 'child') + self.assertTrue(changed) + ctx.run(check) + + def test_get_changed_new_var_in_run(self): + # A variable set for the first time inside run() is "changed" + v = contextvars.ContextVar('v') + ctx = contextvars.copy_context() + + def check(): + with self.assertRaises(LookupError): + v.get_changed() + v.set('new') + val, changed = v.get_changed() + self.assertEqual(val, 'new') + self.assertTrue(changed) + ctx.run(check) + + def test_get_changed_not_set_with_default(self): + # A variable not set but with default: changed is False + v = contextvars.ContextVar('v', default='dflt') + ctx = contextvars.copy_context() + + def check(): + val, changed = v.get_changed() + self.assertEqual(val, 'dflt') + self.assertFalse(changed) + ctx.run(check) + + def test_get_changed_not_set_no_default(self): + # A variable that has never been set and has no default + v = contextvars.ContextVar('v') + ctx = contextvars.copy_context() + + def check(): + with self.assertRaises(LookupError): + v.get_changed() + ctx.run(check) + + def test_get_changed_explicit_default_arg(self): + # Passing a default argument to get_changed() + v = contextvars.ContextVar('v') + ctx = contextvars.copy_context() + + def check(): + val, changed = v.get_changed('fallback') + self.assertEqual(val, 'fallback') + self.assertFalse(changed) + ctx.run(check) + + def test_get_changed_set_same_object(self): + # Setting to the exact same object does not count as "changed" + # because the HAMT recognizes the identical key-value pair + obj = object() + v = contextvars.ContextVar('v') + v.set(obj) + ctx = contextvars.copy_context() + + def check(): + val, changed = v.get_changed() + self.assertIs(val, obj) + self.assertFalse(changed) + v.set(obj) # same object + val, changed = v.get_changed() + self.assertIs(val, obj) + self.assertFalse(changed) + ctx.run(check) + + def test_get_changed_set_different_object(self): + # Setting to a different object counts as "changed" + v = contextvars.ContextVar('v') + v.set([1, 2, 3]) + ctx = contextvars.copy_context() + + def check(): + val, changed = v.get_changed() + self.assertFalse(changed) + v.set([1, 2, 3]) # equal value, different object + val, changed = v.get_changed() + self.assertTrue(changed) + ctx.run(check) + + def test_get_changed_after_reset(self): + # After reset(), the variable reverts to its inherited state + v = contextvars.ContextVar('v') + v.set('original') + ctx = contextvars.copy_context() + + def check(): + val, changed = v.get_changed() + self.assertFalse(changed) + tok = v.set('modified') + val, changed = v.get_changed() + self.assertTrue(changed) + v.reset(tok) + val, changed = v.get_changed() + self.assertFalse(changed) + ctx.run(check) + + def test_get_changed_multiple_vars(self): + # Changing one variable does not affect get_changed() for others + v1 = contextvars.ContextVar('v1') + v2 = contextvars.ContextVar('v2') + v1.set('a') + v2.set('b') + ctx = contextvars.copy_context() + + def check(): + _, changed1 = v1.get_changed() + _, changed2 = v2.get_changed() + self.assertFalse(changed1) + self.assertFalse(changed2) + v1.set('a2') + _, changed1 = v1.get_changed() + _, changed2 = v2.get_changed() + self.assertTrue(changed1) + self.assertFalse(changed2) + ctx.run(check) + + def test_get_changed_nested_run(self): + # get_changed() reflects the innermost Context.run() scope + v = contextvars.ContextVar('v') + v.set('root') + ctx1 = contextvars.copy_context() + + def outer(): + _, changed = v.get_changed() + self.assertFalse(changed) + v.set('outer') + _, changed = v.get_changed() + self.assertTrue(changed) + ctx2 = contextvars.copy_context() + + def inner(): + # inherited 'outer' from ctx1, not changed in ctx2 + val, changed = v.get_changed() + self.assertEqual(val, 'outer') + self.assertFalse(changed) + v.set('inner') + val, changed = v.get_changed() + self.assertEqual(val, 'inner') + self.assertTrue(changed) + ctx2.run(inner) + + # after inner run exits, outer's state is restored + _, changed = v.get_changed() + self.assertTrue(changed) + ctx1.run(outer) + + def test_get_changed_with_threads(self): + # get_changed() works correctly in a thread with copied context + import threading + v = contextvars.ContextVar('v') + v.set('parent') + ctx = contextvars.copy_context() + results = {} + + def thread_func(): + val, changed = v.get_changed() + results['inherited'] = changed + results['value'] = val + v.set('thread') + val, changed = v.get_changed() + results['after_set'] = changed + + t = threading.Thread(target=ctx.run, args=(thread_func,)) + t.start() + t.join() + self.assertFalse(results['inherited']) + self.assertEqual(results['value'], 'parent') + self.assertTrue(results['after_set']) + + def test_get_changed_empty_context_run(self): + # Running in a brand new empty context + v = contextvars.ContextVar('v') + ctx = contextvars.Context() + + def check(): + with self.assertRaises(LookupError): + v.get_changed() + v.set('value') + val, changed = v.get_changed() + self.assertEqual(val, 'value') + self.assertTrue(changed) + ctx.run(check) + # HAMT Tests diff --git a/Python/clinic/context.c.h b/Python/clinic/context.c.h index 5ed74e6e6ddb6b..3fe45c45a64c44 100644 --- a/Python/clinic/context.c.h +++ b/Python/clinic/context.c.h @@ -206,6 +206,52 @@ _contextvars_ContextVar_reset(PyObject *self, PyObject *token) return return_value; } +PyDoc_STRVAR(_contextvars_ContextVar_get_changed__doc__, +"get_changed($self, default=, /)\n" +"--\n" +"\n" +"Return a tuple of (value, changed) for the context variable.\n" +"\n" +"Like ContextVar.get(), but additionally indicates whether the variable was\n" +"changed in the current context scope. *changed* is True if ContextVar.set()\n" +"has been called on the variable within the current Context.run() call with\n" +"a value that is a different object than the inherited one.\n" +"\n" +"If there is no value for the variable in the current context, the method will:\n" +" * return the value of the default argument of the method, if provided; or\n" +" * return the default value for the context variable, if it was created\n" +" with one; or\n" +" * raise a LookupError.\n" +"\n" +"When the value is found via a default, *changed* is always False."); + +#define _CONTEXTVARS_CONTEXTVAR_GET_CHANGED_METHODDEF \ + {"get_changed", _PyCFunction_CAST(_contextvars_ContextVar_get_changed), METH_FASTCALL, _contextvars_ContextVar_get_changed__doc__}, + +static PyObject * +_contextvars_ContextVar_get_changed_impl(PyContextVar *self, + PyObject *default_value); + +static PyObject * +_contextvars_ContextVar_get_changed(PyObject *self, PyObject *const *args, Py_ssize_t nargs) +{ + PyObject *return_value = NULL; + PyObject *default_value = NULL; + + if (!_PyArg_CheckPositional("get_changed", nargs, 0, 1)) { + goto exit; + } + if (nargs < 1) { + goto skip_optional; + } + default_value = args[0]; +skip_optional: + return_value = _contextvars_ContextVar_get_changed_impl((PyContextVar *)self, default_value); + +exit: + return return_value; +} + PyDoc_STRVAR(token_enter__doc__, "__enter__($self, /)\n" "--\n" @@ -256,4 +302,4 @@ token_exit(PyObject *self, PyObject *const *args, Py_ssize_t nargs) exit: return return_value; } -/*[clinic end generated code: output=3a04b2fddf24c3e9 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=0adcad33b8abcf29 input=a9049054013a1b77]*/ diff --git a/Python/context.c b/Python/context.c index 62b582f271ffe5..1490d60c46e9b2 100644 --- a/Python/context.c +++ b/Python/context.c @@ -209,6 +209,7 @@ _PyContext_Enter(PyThreadState *ts, PyObject *octx) } ctx->ctx_prev = (PyContext *)ts->context; /* borrow */ + ctx->ctx_vars_origin = (PyHamtObject *)Py_NewRef(ctx->ctx_vars); ts->context = Py_NewRef(ctx); context_switched(ts); return 0; @@ -248,6 +249,7 @@ _PyContext_Exit(PyThreadState *ts, PyObject *octx) Py_SETREF(ts->context, (PyObject *)ctx->ctx_prev); ctx->ctx_prev = NULL; + Py_CLEAR(ctx->ctx_vars_origin); FT_ATOMIC_STORE_INT(ctx->ctx_entered, 0); context_switched(ts); return 0; @@ -410,6 +412,115 @@ PyContextVar_Reset(PyObject *ovar, PyObject *otok) } +/* Check if var's current value (cur_val) differs from the origin snapshot. + ctx must be the current context and cur_val must be the value already + looked up in ctx->ctx_vars. Returns 1 if changed, 0 if not, -1 on error. */ +static int +contextvar_check_changed(PyContext *ctx, PyContextVar *var, PyObject *cur_val) +{ + /* No origin snapshot means this context was never entered via + Context.run(), so all bindings are considered "changed". */ + if (ctx->ctx_vars_origin == NULL) { + return 1; + } + + /* If the HAMT hasn't changed at all, no .set() calls have been made + in this context scope for any variable. */ + if (ctx->ctx_vars == ctx->ctx_vars_origin) { + return 0; + } + + /* Check if this specific variable had a different value (or was + absent) in the origin snapshot. */ + PyObject *orig_val = NULL; + int found_orig = _PyHamt_Find( + ctx->ctx_vars_origin, (PyObject *)var, &orig_val); + if (found_orig < 0) { + return -1; + } + if (found_orig == 0) { + return 1; + } + + return cur_val != orig_val; +} + + +int +PyContextVar_GetChanged(PyObject *ovar, PyObject *def, PyObject **val, + int *changed) +{ + ENSURE_ContextVar(ovar, -1) + PyContextVar *var = (PyContextVar *)ovar; + + *changed = 0; + + PyThreadState *ts = _PyThreadState_GET(); + assert(ts != NULL); + if (ts->context == NULL) { + goto not_found; + } + + PyContext *ctx = (PyContext *)ts->context; + assert(PyContext_CheckExact(ts->context)); + +#ifndef Py_GIL_DISABLED + /* Try the cache first. When we get a cache hit we still need to + check the origin HAMT, but we skip the main HAMT lookup. */ + if (var->var_cached != NULL && + var->var_cached_tsid == ts->id && + var->var_cached_tsver == ts->context_ver) + { + *val = Py_NewRef(var->var_cached); + int res = contextvar_check_changed(ctx, var, var->var_cached); + if (res < 0) { + Py_CLEAR(*val); + return -1; + } + *changed = res; + return 0; + } +#endif + + PyObject *found_val = NULL; + int res = _PyHamt_Find(ctx->ctx_vars, (PyObject *)var, &found_val); + if (res < 0) { + *val = NULL; + return -1; + } + if (res == 1) { + assert(found_val != NULL); +#ifndef Py_GIL_DISABLED + var->var_cached = found_val; /* borrow */ + var->var_cached_tsid = ts->id; + var->var_cached_tsver = ts->context_ver; +#endif + int chg = contextvar_check_changed(ctx, var, found_val); + if (chg < 0) { + *val = NULL; + return -1; + } + *changed = chg; + *val = Py_NewRef(found_val); + return 0; + } + +not_found: + if (def == NULL) { + if (var->var_default != NULL) { + *val = Py_NewRef(var->var_default); + return 0; + } + *val = NULL; + return 0; + } + else { + *val = Py_NewRef(def); + return 0; + } +} + + /////////////////////////// PyContext /*[clinic input] @@ -433,6 +544,7 @@ _context_alloc(void) } ctx->ctx_vars = NULL; + ctx->ctx_vars_origin = NULL; ctx->ctx_prev = NULL; ctx->ctx_entered = 0; ctx->ctx_weakreflist = NULL; @@ -520,6 +632,7 @@ context_tp_clear(PyObject *op) PyContext *self = _PyContext_CAST(op); Py_CLEAR(self->ctx_prev); Py_CLEAR(self->ctx_vars); + Py_CLEAR(self->ctx_vars_origin); return 0; } @@ -529,6 +642,7 @@ context_tp_traverse(PyObject *op, visitproc visit, void *arg) PyContext *self = _PyContext_CAST(op); Py_VISIT(self->ctx_prev); Py_VISIT(self->ctx_vars); + Py_VISIT(self->ctx_vars_origin); return 0; } @@ -1088,6 +1202,52 @@ _contextvars_ContextVar_reset_impl(PyContextVar *self, PyObject *token) } +/*[clinic input] +@permit_long_docstring_body +_contextvars.ContextVar.get_changed + default: object = NULL + / + +Return a tuple of (value, changed) for the context variable. + +Like ContextVar.get(), but additionally indicates whether the variable was +changed in the current context scope. *changed* is True if ContextVar.set() +has been called on the variable within the current Context.run() call with +a value that is a different object than the inherited one. + +If there is no value for the variable in the current context, the method will: + * return the value of the default argument of the method, if provided; or + * return the default value for the context variable, if it was created + with one; or + * raise a LookupError. + +When the value is found via a default, *changed* is always False. +[clinic start generated code]*/ + +static PyObject * +_contextvars_ContextVar_get_changed_impl(PyContextVar *self, + PyObject *default_value) +/*[clinic end generated code: output=2418683613ac96e7 input=2dacfcf7b43f9719]*/ +{ + PyObject *val; + int changed; + if (PyContextVar_GetChanged( + (PyObject *)self, default_value, &val, &changed) < 0) { + return NULL; + } + + if (val == NULL) { + PyErr_SetObject(PyExc_LookupError, (PyObject *)self); + return NULL; + } + + PyObject *changed_obj = changed ? Py_True : Py_False; + PyObject *result = PyTuple_Pack(2, val, changed_obj); + Py_DECREF(val); + return result; +} + + static PyMemberDef PyContextVar_members[] = { {"name", _Py_T_OBJECT, offsetof(PyContextVar, var_name), Py_READONLY}, {NULL} @@ -1097,6 +1257,7 @@ static PyMethodDef PyContextVar_methods[] = { _CONTEXTVARS_CONTEXTVAR_GET_METHODDEF _CONTEXTVARS_CONTEXTVAR_SET_METHODDEF _CONTEXTVARS_CONTEXTVAR_RESET_METHODDEF + _CONTEXTVARS_CONTEXTVAR_GET_CHANGED_METHODDEF {"__class_getitem__", Py_GenericAlias, METH_O|METH_CLASS, PyDoc_STR("See PEP 585")}, {NULL, NULL} From b33469eb37f0746916353b539b36c2a03ea14eca Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 25 Mar 2026 23:15:58 -0700 Subject: [PATCH 2/3] Use ContextVar.get_changed() for decimal context. Ensure that decimal.getcontext() returns a per-task copy of the decimal.Context() so that mutations are isolated between async tasks and threads using sys.flags.thread_inherit_context. --- Lib/_pydecimal.py | 10 +++- Lib/test/test_decimal.py | 53 +++++++++++++++++++ ...-03-26-10-27-07.gh-issue-141148._XpYnI.rst | 4 ++ Modules/_decimal/_decimal.c | 20 +++++-- 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-03-26-10-27-07.gh-issue-141148._XpYnI.rst diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index ef889ea0cc834c..902ca17e07483c 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -361,11 +361,19 @@ def getcontext(): New contexts are copies of DefaultContext. """ try: - return _current_context_var.get() + context, changed = _current_context_var.get_changed() except LookupError: context = Context() _current_context_var.set(context) return context + if not changed: + # The context value was inherited from another task/thread. Because + # the Context() instance is mutable, copy it to ensure that if it is + # changed, those changes are isolated from other tasks/threads. + context = context.copy() + _current_context_var.set(context) + return context + def setcontext(context): """Set this thread's context to context.""" diff --git a/Lib/test/test_decimal.py b/Lib/test/test_decimal.py index fe8c8ce12da0bf..dc365a7cd8228d 100644 --- a/Lib/test/test_decimal.py +++ b/Lib/test/test_decimal.py @@ -1770,6 +1770,59 @@ def test_threading(self): DefaultContext.Emax = save_emax DefaultContext.Emin = save_emin + @threading_helper.requires_working_threading() + def test_inherited_context_isolation(self): + # Test that when threads inherit contextvars (e.g. via + # sys.flags.thread_inherit_context), each thread gets its own + # copy of the decimal context so mutations don't leak between + # threads. Also verifies correct behavior with asyncio tasks. + Decimal = self.decimal.Decimal + getcontext = self.decimal.getcontext + setcontext = self.decimal.setcontext + Context = self.decimal.Context + Underflow = self.decimal.Underflow + + # Set up parent context with specific precision + parent_ctx = getcontext() + parent_ctx.prec = 20 + + barrier = threading.Barrier(2, timeout=2) + results = {} + + def child(name, prec_delta): + barrier.wait() + ctx = getcontext() + # Each child should see a context with the parent's precision + results[name + '_initial_prec'] = ctx.prec + results[name + '_ctx_id'] = id(ctx) + # Mutate this thread's context + ctx.prec += prec_delta + results[name + '_modified_prec'] = ctx.prec + + # Spawn threads that inherit the parent's contextvars. + t1 = threading.Thread(target=child, args=('t1', 5), + context=contextvars.copy_context()) + t2 = threading.Thread(target=child, args=('t2', 10), + context=contextvars.copy_context()) + t1.start() + t2.start() + t1.join() + t2.join() + + # Each thread should have started with the parent's precision + self.assertEqual(results['t1_initial_prec'], 20) + self.assertEqual(results['t2_initial_prec'], 20) + + # Each thread should have its own context (different id) + self.assertNotEqual(results['t1_ctx_id'], results['t2_ctx_id']) + + # Mutations should be independent + self.assertEqual(results['t1_modified_prec'], 25) + self.assertEqual(results['t2_modified_prec'], 30) + + # Parent context should be unaffected + self.assertEqual(getcontext().prec, 20) + @requires_cdecimal class CThreadingTest(ThreadingTest, unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-03-26-10-27-07.gh-issue-141148._XpYnI.rst b/Misc/NEWS.d/next/Library/2026-03-26-10-27-07.gh-issue-141148._XpYnI.rst new file mode 100644 index 00000000000000..8cc474526b8c4d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-26-10-27-07.gh-issue-141148._XpYnI.rst @@ -0,0 +1,4 @@ +Ensure that :func:`decimal.getcontext` returns a per-task copy of the +:class:`decimal.Context` so that mutations are isolated between asyncio +tasks and threads using :data:`sys.flags.thread_inherit_context`. Added +:meth:`contextvars.ContextVar.get_changed` to support this. diff --git a/Modules/_decimal/_decimal.c b/Modules/_decimal/_decimal.c index b47014c4e7466d..b361d61deb4020 100644 --- a/Modules/_decimal/_decimal.c +++ b/Modules/_decimal/_decimal.c @@ -1914,9 +1914,9 @@ PyDec_SetCurrentContext(PyObject *self, PyObject *v) } #else static PyObject * -init_current_context(decimal_state *state) +init_current_context(decimal_state *state, PyObject *prev_context) { - PyObject *tl_context = context_copy(state, state->default_context_template); + PyObject *tl_context = context_copy(state, prev_context); if (tl_context == NULL) { return NULL; } @@ -1936,15 +1936,25 @@ static inline PyObject * current_context(decimal_state *state) { PyObject *tl_context; - if (PyContextVar_Get(state->current_context_var, NULL, &tl_context) < 0) { + int changed; + if (PyContextVar_GetChanged(state->current_context_var, NULL, &tl_context, + &changed) < 0) { return NULL; } if (tl_context != NULL) { - return tl_context; + if (!changed) { + /* inherited context object from another thread for async task */ + PyObject *new_context = init_current_context(state, tl_context); + Py_DECREF(tl_context); + return new_context; + } + else { + return tl_context; + } } - return init_current_context(state); + return init_current_context(state, state->default_context_template); } /* ctxobj := borrowed reference to the current context */ From 74482e303587929b75143a6d4283a83073c88ff5 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 26 Mar 2026 11:49:20 -0700 Subject: [PATCH 3/3] Mark unit test that needs working threads. --- Lib/test/test_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_context.py b/Lib/test/test_context.py index 13886bf2827f10..56e6fd3591f4ed 100644 --- a/Lib/test/test_context.py +++ b/Lib/test/test_context.py @@ -769,6 +769,7 @@ def inner(): self.assertTrue(changed) ctx1.run(outer) + @threading_helper.requires_working_threading() def test_get_changed_with_threads(self): # get_changed() works correctly in a thread with copied context import threading