Skip to content

Commit fc9559f

Browse files
authored
Merge pull request #3537 from jsiirola/tee-capture-fd
Ensure `capture_output` does not output to captured file descriptors
2 parents a9d673d + eca3f3a commit fc9559f

File tree

4 files changed

+561
-123
lines changed

4 files changed

+561
-123
lines changed

pyomo/common/log.py

+112-7
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import inspect
2222
import io
2323
import logging
24+
import os
2425
import re
2526
import sys
2627
import textwrap
@@ -286,14 +287,22 @@ class LoggingIntercept(object):
286287
----------
287288
output: io.TextIOBase
288289
the file stream to send log messages to
290+
289291
module: str
290-
the target logger name to intercept
292+
the target logger name to intercept. `logger` and `module` are
293+
mutually exclusive.
294+
291295
level: int
292296
the logging level to intercept
297+
293298
formatter: logging.Formatter
294299
the formatter to use when rendering the log messages. If not
295300
specified, uses `'%(message)s'`
296301
302+
logger: logging.Logger
303+
the target logger to intercept. `logger` and `module` are
304+
mutually exclusive.
305+
297306
Examples
298307
--------
299308
>>> import io, logging
@@ -306,17 +315,36 @@ class LoggingIntercept(object):
306315
307316
"""
308317

309-
def __init__(self, output=None, module=None, level=logging.WARNING, formatter=None):
318+
def __init__(
319+
self,
320+
output=None,
321+
module=None,
322+
level=logging.WARNING,
323+
formatter=None,
324+
logger=None,
325+
):
310326
self.handler = None
311327
self.output = output
312-
self.module = module
328+
if logger is not None:
329+
if module is not None:
330+
raise ValueError(
331+
"LoggingIntercept: only one of 'module' and 'logger' is allowed"
332+
)
333+
self._logger = logger
334+
else:
335+
self._logger = logging.getLogger(module)
313336
self._level = level
314337
if formatter is None:
315338
formatter = logging.Formatter('%(message)s')
316339
self._formatter = formatter
317340
self._save = None
318341

319342
def __enter__(self):
343+
# Get the logger for the scope we will be overriding
344+
logger = self._logger
345+
self._save = logger.level, logger.propagate, logger.handlers
346+
if self._level is None:
347+
self._level = logger.getEffectiveLevel()
320348
# Set up the handler
321349
output = self.output
322350
if output is None:
@@ -326,23 +354,25 @@ def __enter__(self):
326354
self.handler.setFormatter(self._formatter)
327355
self.handler.setLevel(self._level)
328356
# Register the handler with the appropriate module scope
329-
logger = logging.getLogger(self.module)
330-
self._save = logger.level, logger.propagate, logger.handlers
331357
logger.handlers = []
332-
logger.propagate = 0
358+
logger.propagate = False
333359
logger.setLevel(self.handler.level)
334360
logger.addHandler(self.handler)
335361
return output
336362

337363
def __exit__(self, et, ev, tb):
338-
logger = logging.getLogger(self.module)
364+
logger = self._logger
339365
logger.removeHandler(self.handler)
340366
self.handler = None
341367
logger.setLevel(self._save[0])
342368
logger.propagate = self._save[1]
343369
assert not logger.handlers
344370
logger.handlers.extend(self._save[2])
345371

372+
@property
373+
def module(self):
374+
return self._logger.name
375+
346376

347377
class LogStream(io.TextIOBase):
348378
"""
@@ -357,6 +387,8 @@ def __init__(self, level, logger):
357387
self._buffer = ''
358388

359389
def write(self, s: str) -> int:
390+
if not s:
391+
return 0
360392
res = len(s)
361393
if self._buffer:
362394
s = self._buffer + s
@@ -369,3 +401,76 @@ def write(self, s: str) -> int:
369401
def flush(self):
370402
if self._buffer:
371403
self.write('\n')
404+
405+
def redirect_streams(self, redirects):
406+
"""Redirect StreamHandler objects to the original file descriptors
407+
408+
This utility method for py:class:`~pyomo.common.tee.capture_output`
409+
locates any StreamHandlers that would process messages from the
410+
logger assigned to this :py:class:`LogStream` that would write
411+
to the file descriptors redirected by `capture_output` and
412+
yields context managers that will redirect those StreamHandlers
413+
back to duplicates of the original file descriptors.
414+
415+
"""
416+
found = 0
417+
logger = self._logger
418+
while logger:
419+
for handler in logger.handlers:
420+
found += 1
421+
if not isinstance(handler, logging.StreamHandler):
422+
continue
423+
try:
424+
fd = handler.stream.fileno()
425+
except (AttributeError, OSError):
426+
fd = None
427+
if fd not in redirects:
428+
continue
429+
yield _StreamRedirector(handler, redirects[fd].original_fd)
430+
if not logger.propagate:
431+
break
432+
else:
433+
logger = logger.parent
434+
if not found:
435+
fd = logging.lastResort.stream.fileno()
436+
if not redirects:
437+
yield _LastResortRedirector(fd)
438+
elif fd in redirects:
439+
yield _LastResortRedirector(redirects[fd].original_fd)
440+
441+
442+
class _StreamRedirector(object):
443+
def __init__(self, handler, fd):
444+
self.handler = handler
445+
self.fd = fd
446+
self.orig_stream = None
447+
448+
def __enter__(self):
449+
self.orig_stream = self.handler.stream
450+
self.handler.stream = os.fdopen(
451+
os.dup(self.fd), mode="w", closefd=True
452+
).__enter__()
453+
454+
def __exit__(self, et, ev, tb):
455+
try:
456+
self.handler.stream.__exit__(et, ev, tb)
457+
finally:
458+
self.handler.stream = self.orig_stream
459+
460+
461+
class _LastResortRedirector(object):
462+
def __init__(self, fd):
463+
self.fd = fd
464+
self.orig_stream = None
465+
466+
def __enter__(self):
467+
self.orig = logging.lastResort
468+
logging.lastResort = logging.StreamHandler(
469+
os.fdopen(os.dup(self.fd), mode="w", closefd=True).__enter__()
470+
)
471+
472+
def __exit__(self, et, ev, tb):
473+
try:
474+
logging.lastResort.stream.close()
475+
finally:
476+
logging.lastResort = self.orig

0 commit comments

Comments
 (0)