Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-128384: Add thread-safe context manager to "warnings" module #128300

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Doc/library/threading.rst
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ since it is impossible to detect the termination of alien threads.


.. class:: Thread(group=None, target=None, name=None, args=(), kwargs={}, *, \
daemon=None)
daemon=None, context="inherit")

This constructor should always be called with keyword arguments. Arguments
are:
Expand All @@ -359,6 +359,10 @@ since it is impossible to detect the termination of alien threads.
If ``None`` (the default), the daemonic property is inherited from the
current thread.

*context* is the `contextvars.Context` value to use while running the thread.
The default is to inherit the context of the caller of :meth:`~Thread.start`.
If set to ``None``, the context will be empty.

If the subclass overrides the constructor, it must make sure to invoke the
base class constructor (``Thread.__init__()``) before doing anything else to
the thread.
Expand All @@ -369,6 +373,10 @@ since it is impossible to detect the termination of alien threads.
.. versionchanged:: 3.10
Use the *target* name if *name* argument is omitted.

.. versionchanged:: 3.14
Added the *context* parameter. Previously threads always ran with an empty
context.

.. method:: start()

Start the thread's activity.
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_global_objects_fini_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Include/internal/pycore_global_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(_type_)
STRUCT_FOR_ID(_uninitialized_submodules)
STRUCT_FOR_ID(_warn_unawaited_coroutine)
STRUCT_FOR_ID(_warnings_context)
STRUCT_FOR_ID(_xoptions)
STRUCT_FOR_ID(abs_tol)
STRUCT_FOR_ID(access)
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_runtime_init_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Include/internal/pycore_unicodeobject_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Include/internal/pycore_warnings.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct _warnings_runtime_state {
PyObject *filters; /* List */
PyObject *once_registry; /* Dict */
PyObject *default_action; /* String */
PyMutex mutex;
_PyRecursiveMutex lock;
long filters_version;
};

Expand Down
43 changes: 43 additions & 0 deletions Lib/test/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,49 @@ def sub(num):
tp.shutdown()
self.assertEqual(results, list(range(10)))

@isolated_context
@threading_helper.requires_working_threading()
def test_context_thread_inherit(self):
import threading

cvar = contextvars.ContextVar('cvar')

# By default, the context of the caller is inheritied
def run_inherit():
self.assertEqual(cvar.get(), 1)

cvar.set(1)
thread = threading.Thread(target=run_inherit)
thread.start()
thread.join()

# If context=None is passed, the thread has an empty context
def run_empty():
with self.assertRaises(LookupError):
cvar.get()

thread = threading.Thread(target=run_empty, context=None)
thread.start()
thread.join()

# An explicit Context value can also be passed
custom_ctx = contextvars.Context()
custom_var = None

def setup_context():
nonlocal custom_var
custom_var = contextvars.ContextVar('custom')
custom_var.set(2)

custom_ctx.run(setup_context)

def run_custom():
self.assertEqual(custom_var.get(), 2)

thread = threading.Thread(target=run_custom, context=custom_ctx)
thread.start()
thread.join()


# HAMT Tests

Expand Down
4 changes: 2 additions & 2 deletions Lib/test/test_decimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1725,8 +1725,8 @@ def test_threading(self):
self.finish1 = threading.Event()
self.finish2 = threading.Event()

th1 = threading.Thread(target=thfunc1, args=(self,))
th2 = threading.Thread(target=thfunc2, args=(self,))
th1 = threading.Thread(target=thfunc1, args=(self,), context=None)
th2 = threading.Thread(target=thfunc2, args=(self,), context=None)

th1.start()
th2.start()
Expand Down
3 changes: 2 additions & 1 deletion Lib/test/test_faulthandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,8 @@ def run(self):
self.assertRegex(output, regex)
self.assertEqual(exitcode, 0)

def test_dump_traceback_threads(self):
# FIXME: gh-128400, re-enable when bug fixed
def DISABLED_test_dump_traceback_threads(self):
self.check_dump_traceback_threads(None)

def test_dump_traceback_threads_file(self):
Expand Down
56 changes: 55 additions & 1 deletion Lib/test/test_warnings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1521,7 +1521,7 @@ def test_late_resource_warning(self):
self.assertTrue(err.startswith(expected), ascii(err))


class DeprecatedTests(unittest.TestCase):
class DeprecatedTests(PyPublicAPITests):
def test_dunder_deprecated(self):
@deprecated("A will go away soon")
class A:
Expand Down Expand Up @@ -1821,6 +1821,60 @@ async def coro(self):
self.assertFalse(inspect.iscoroutinefunction(Cls.sync))
self.assertTrue(inspect.iscoroutinefunction(Cls.coro))


class ContextTests(BaseTest):
def test_error(self):
with self.module.local_context() as ctx:
ctx.filterwarnings("error", category=UserWarning)
self.assertRaises(UserWarning, self.module.warn,
"should raise error")

def test_record(self):
with self.module.local_context(record=True) as ctx:
ctx.resetwarnings()
self.module.warn("test message", UserWarning)
self.assertEqual(len(ctx.log), 1)

def test_ignore(self):
with self.module.local_context(record=True) as ctx:
# if ignore filter is active, nothing logged
ctx.filterwarnings("ignore", category=UserWarning)
self.module.warn("should be ignored", UserWarning)
self.assertEqual(len(ctx.log), 0)
# after resetting filters, warning should be logged
ctx.resetwarnings()
self.module.warn("should not be ignored", UserWarning)
self.assertEqual(len(ctx.log), 1)

def test_get_context(self):
def warn_ctx():
return self.module.get_context()
with warn_ctx().catch_warnings():
warn_ctx().filterwarnings("error", category=UserWarning)
self.assertRaises(UserWarning, self.module.warn,
"should be an error")

def test_catch_warnings(self):
def warn_ctx():
return self.module.get_context()
with warn_ctx().catch_warnings():
warn_ctx().filterwarnings("error", category=UserWarning)
self.assertRaises(UserWarning, self.module.warn,
"should be an error")
# test recording
with warn_ctx().catch_warnings(record=True) as w:
self.module.warn("test message", UserWarning)
self.assertEqual(len(w), 1)


class CContextTests(ContextTests, unittest.TestCase):
module = c_warnings


class PyContextTests(ContextTests, unittest.TestCase):
module = c_warnings
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't you want the other one ?

Copy link
Member Author

@nascheme nascheme Apr 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, you're right.



def setUpModule():
py_warnings.onceregistry.clear()
c_warnings.onceregistry.clear()
Expand Down
28 changes: 25 additions & 3 deletions Lib/threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import sys as _sys
import _thread
import warnings
import contextvars as _contextvars


from time import monotonic as _time
from _weakrefset import WeakSet
Expand Down Expand Up @@ -871,7 +873,7 @@ class Thread:
_initialized = False

def __init__(self, group=None, target=None, name=None,
args=(), kwargs=None, *, daemon=None):
args=(), kwargs=None, *, daemon=None, context='inherit'):
"""This constructor should always be called with keyword arguments. Arguments are:

*group* should be None; reserved for future extension when a ThreadGroup
Expand All @@ -888,6 +890,10 @@ class is implemented.
*kwargs* is a dictionary of keyword arguments for the target
invocation. Defaults to {}.

*context* is the contextvars.Context value to use for the thread. The default
is to inherit the context of the caller. Set to None to start with an empty
context.

If a subclass overrides the constructor, it must make sure to invoke
the base class constructor (Thread.__init__()) before doing anything
else to the thread.
Expand Down Expand Up @@ -917,6 +923,7 @@ class is implemented.
self._daemonic = daemon
else:
self._daemonic = current_thread().daemon
self._context = context
self._ident = None
if _HAVE_THREAD_NATIVE_ID:
self._native_id = None
Expand Down Expand Up @@ -972,9 +979,15 @@ def start(self):

with _active_limbo_lock:
_limbo[self] = self

if self._context == 'inherit':
# No context provided, inherit the context of the caller.
self._context = _contextvars.copy_context()

try:
# Start joinable thread
_start_joinable_thread(self._bootstrap, handle=self._handle,
_start_joinable_thread(self._bootstrap,
handle=self._handle,
daemon=self.daemon)
except Exception:
with _active_limbo_lock:
Expand Down Expand Up @@ -1050,8 +1063,17 @@ def _bootstrap_inner(self):
if _profile_hook:
_sys.setprofile(_profile_hook)

if self._context is None:
# Run with empty context, matching behaviour of
# threading.local and older versions of Python.
run = self.run
else:
# Run with the provided or the inherited context.
def run():
self._context.run(self.run)

try:
self.run()
run()
except:
self._invoke_excepthook(self)
finally:
Expand Down
Loading
Loading