Skip to content

Commit fb864c7

Browse files
authored
gh-121723: Relax constraints on queue objects for logging.handlers.QueueHandler. (GH-122154)
1 parent addbb73 commit fb864c7

File tree

4 files changed

+124
-50
lines changed

4 files changed

+124
-50
lines changed

Doc/library/logging.config.rst

+6-3
Original file line numberDiff line numberDiff line change
@@ -753,9 +753,12 @@ The ``queue`` and ``listener`` keys are optional.
753753

754754
If the ``queue`` key is present, the corresponding value can be one of the following:
755755

756-
* An actual instance of :class:`queue.Queue` or a subclass thereof. This is of course
757-
only possible if you are constructing or modifying the configuration dictionary in
758-
code.
756+
* An object implementing the :class:`queue.Queue` public API. For instance,
757+
this may be an actual instance of :class:`queue.Queue` or a subclass thereof,
758+
or a proxy obtained by :meth:`multiprocessing.managers.SyncManager.Queue`.
759+
760+
This is of course only possible if you are constructing or modifying
761+
the configuration dictionary in code.
759762

760763
* A string that resolves to a callable which, when called with no arguments, returns
761764
the :class:`queue.Queue` instance to use. That callable could be a

Lib/logging/config.py

+29-26
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,33 @@ def as_tuple(self, value):
497497
value = tuple(value)
498498
return value
499499

500+
def _is_queue_like_object(obj):
501+
"""Check that *obj* implements the Queue API."""
502+
if isinstance(obj, queue.Queue):
503+
return True
504+
# defer importing multiprocessing as much as possible
505+
from multiprocessing.queues import Queue as MPQueue
506+
if isinstance(obj, MPQueue):
507+
return True
508+
# Depending on the multiprocessing start context, we cannot create
509+
# a multiprocessing.managers.BaseManager instance 'mm' to get the
510+
# runtime type of mm.Queue() or mm.JoinableQueue() (see gh-119819).
511+
#
512+
# Since we only need an object implementing the Queue API, we only
513+
# do a protocol check, but we do not use typing.runtime_checkable()
514+
# and typing.Protocol to reduce import time (see gh-121723).
515+
#
516+
# Ideally, we would have wanted to simply use strict type checking
517+
# instead of a protocol-based type checking since the latter does
518+
# not check the method signatures.
519+
queue_interface = [
520+
'empty', 'full', 'get', 'get_nowait',
521+
'put', 'put_nowait', 'join', 'qsize',
522+
'task_done',
523+
]
524+
return all(callable(getattr(obj, method, None))
525+
for method in queue_interface)
526+
500527
class DictConfigurator(BaseConfigurator):
501528
"""
502529
Configure logging using a dictionary-like object to describe the
@@ -791,32 +818,8 @@ def configure_handler(self, config):
791818
if '()' not in qspec:
792819
raise TypeError('Invalid queue specifier %r' % qspec)
793820
config['queue'] = self.configure_custom(dict(qspec))
794-
else:
795-
from multiprocessing.queues import Queue as MPQueue
796-
797-
if not isinstance(qspec, (queue.Queue, MPQueue)):
798-
# Safely check if 'qspec' is an instance of Manager.Queue
799-
# / Manager.JoinableQueue
800-
801-
from multiprocessing import Manager as MM
802-
from multiprocessing.managers import BaseProxy
803-
804-
# if it's not an instance of BaseProxy, it also can't be
805-
# an instance of Manager.Queue / Manager.JoinableQueue
806-
if isinstance(qspec, BaseProxy):
807-
# Sometimes manager or queue creation might fail
808-
# (e.g. see issue gh-120868). In that case, any
809-
# exception during the creation of these queues will
810-
# propagate up to the caller and be wrapped in a
811-
# `ValueError`, whose cause will indicate the details of
812-
# the failure.
813-
mm = MM()
814-
proxy_queue = mm.Queue()
815-
proxy_joinable_queue = mm.JoinableQueue()
816-
if not isinstance(qspec, (type(proxy_queue), type(proxy_joinable_queue))):
817-
raise TypeError('Invalid queue specifier %r' % qspec)
818-
else:
819-
raise TypeError('Invalid queue specifier %r' % qspec)
821+
elif not _is_queue_like_object(qspec):
822+
raise TypeError('Invalid queue specifier %r' % qspec)
820823

821824
if 'listener' in config:
822825
lspec = config['listener']

Lib/test/test_logging.py

+86-21
Original file line numberDiff line numberDiff line change
@@ -2368,6 +2368,26 @@ class CustomListener(logging.handlers.QueueListener):
23682368
class CustomQueue(queue.Queue):
23692369
pass
23702370

2371+
class CustomQueueProtocol:
2372+
def __init__(self, maxsize=0):
2373+
self.queue = queue.Queue(maxsize)
2374+
2375+
def __getattr__(self, attribute):
2376+
queue = object.__getattribute__(self, 'queue')
2377+
return getattr(queue, attribute)
2378+
2379+
class CustomQueueFakeProtocol(CustomQueueProtocol):
2380+
# An object implementing the Queue API (incorrect signatures).
2381+
# The object will be considered a valid queue class since we
2382+
# do not check the signatures (only callability of methods)
2383+
# but will NOT be usable in production since a TypeError will
2384+
# be raised due to a missing argument.
2385+
def empty(self, x):
2386+
pass
2387+
2388+
class CustomQueueWrongProtocol(CustomQueueProtocol):
2389+
empty = None
2390+
23712391
def queueMaker():
23722392
return queue.Queue()
23732393

@@ -3901,18 +3921,16 @@ def do_queuehandler_configuration(self, qspec, lspec):
39013921
@threading_helper.requires_working_threading()
39023922
@support.requires_subprocess()
39033923
def test_config_queue_handler(self):
3904-
q = CustomQueue()
3905-
dq = {
3906-
'()': __name__ + '.CustomQueue',
3907-
'maxsize': 10
3908-
}
3924+
qs = [CustomQueue(), CustomQueueProtocol()]
3925+
dqs = [{'()': f'{__name__}.{cls}', 'maxsize': 10}
3926+
for cls in ['CustomQueue', 'CustomQueueProtocol']]
39093927
dl = {
39103928
'()': __name__ + '.listenerMaker',
39113929
'arg1': None,
39123930
'arg2': None,
39133931
'respect_handler_level': True
39143932
}
3915-
qvalues = (None, __name__ + '.queueMaker', __name__ + '.CustomQueue', dq, q)
3933+
qvalues = (None, __name__ + '.queueMaker', __name__ + '.CustomQueue', *dqs, *qs)
39163934
lvalues = (None, __name__ + '.CustomListener', dl, CustomListener)
39173935
for qspec, lspec in itertools.product(qvalues, lvalues):
39183936
self.do_queuehandler_configuration(qspec, lspec)
@@ -3932,15 +3950,21 @@ def test_config_queue_handler(self):
39323950
@support.requires_subprocess()
39333951
@patch("multiprocessing.Manager")
39343952
def test_config_queue_handler_does_not_create_multiprocessing_manager(self, manager):
3935-
# gh-120868
3953+
# gh-120868, gh-121723
39363954

39373955
from multiprocessing import Queue as MQ
39383956

39393957
q1 = {"()": "queue.Queue", "maxsize": -1}
39403958
q2 = MQ()
39413959
q3 = queue.Queue()
3942-
3943-
for qspec in (q1, q2, q3):
3960+
# CustomQueueFakeProtocol passes the checks but will not be usable
3961+
# since the signatures are incompatible. Checking the Queue API
3962+
# without testing the type of the actual queue is a trade-off
3963+
# between usability and the work we need to do in order to safely
3964+
# check that the queue object correctly implements the API.
3965+
q4 = CustomQueueFakeProtocol()
3966+
3967+
for qspec in (q1, q2, q3, q4):
39443968
self.apply_config(
39453969
{
39463970
"version": 1,
@@ -3956,21 +3980,62 @@ def test_config_queue_handler_does_not_create_multiprocessing_manager(self, mana
39563980

39573981
@patch("multiprocessing.Manager")
39583982
def test_config_queue_handler_invalid_config_does_not_create_multiprocessing_manager(self, manager):
3959-
# gh-120868
3983+
# gh-120868, gh-121723
39603984

3961-
with self.assertRaises(ValueError):
3962-
self.apply_config(
3963-
{
3964-
"version": 1,
3965-
"handlers": {
3966-
"queue_listener": {
3967-
"class": "logging.handlers.QueueHandler",
3968-
"queue": object(),
3985+
for qspec in [object(), CustomQueueWrongProtocol()]:
3986+
with self.assertRaises(ValueError):
3987+
self.apply_config(
3988+
{
3989+
"version": 1,
3990+
"handlers": {
3991+
"queue_listener": {
3992+
"class": "logging.handlers.QueueHandler",
3993+
"queue": qspec,
3994+
},
39693995
},
3970-
},
3996+
}
3997+
)
3998+
manager.assert_not_called()
3999+
4000+
@skip_if_tsan_fork
4001+
@support.requires_subprocess()
4002+
@unittest.skipUnless(support.Py_DEBUG, "requires a debug build for testing"
4003+
"assertions in multiprocessing")
4004+
def test_config_queue_handler_multiprocessing_context(self):
4005+
# regression test for gh-121723
4006+
if support.MS_WINDOWS:
4007+
start_methods = ['spawn']
4008+
else:
4009+
start_methods = ['spawn', 'fork', 'forkserver']
4010+
for start_method in start_methods:
4011+
with self.subTest(start_method=start_method):
4012+
ctx = multiprocessing.get_context(start_method)
4013+
with ctx.Manager() as manager:
4014+
q = manager.Queue()
4015+
records = []
4016+
# use 1 process and 1 task per child to put 1 record
4017+
with ctx.Pool(1, initializer=self._mpinit_issue121723,
4018+
initargs=(q, "text"), maxtasksperchild=1):
4019+
records.append(q.get(timeout=60))
4020+
self.assertTrue(q.empty())
4021+
self.assertEqual(len(records), 1)
4022+
4023+
@staticmethod
4024+
def _mpinit_issue121723(qspec, message_to_log):
4025+
# static method for pickling support
4026+
logging.config.dictConfig({
4027+
'version': 1,
4028+
'disable_existing_loggers': True,
4029+
'handlers': {
4030+
'log_to_parent': {
4031+
'class': 'logging.handlers.QueueHandler',
4032+
'queue': qspec
39714033
}
3972-
)
3973-
manager.assert_not_called()
4034+
},
4035+
'root': {'handlers': ['log_to_parent'], 'level': 'DEBUG'}
4036+
})
4037+
# log a message (this creates a record put in the queue)
4038+
logging.getLogger().info(message_to_log)
39744039

39754040
@skip_if_tsan_fork
39764041
@support.requires_subprocess()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Make :func:`logging.config.dictConfig` accept any object implementing the
2+
Queue public API. See the :ref:`queue configuration <configure-queue>`
3+
section for details. Patch by Bénédikt Tran.

0 commit comments

Comments
 (0)