-
Notifications
You must be signed in to change notification settings - Fork 18
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
Capture logs also from setup and teardown stages #7
Changes from 9 commits
42397ad
3954634
bec9098
cfedc42
2e3aa80
361a331
904b138
83eb180
944b68b
8996725
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,19 @@ | ||
sudo: false | ||
language: python | ||
python: 2.7 | ||
env: | ||
- TOX_ENV=pypy3 | ||
- TOX_ENV=pypy | ||
- TOX_ENV=py34 | ||
- TOX_ENV=py33 | ||
- TOX_ENV=py32 | ||
- TOX_ENV=py27 | ||
python: | ||
- "2.7" | ||
- "3.2" | ||
- "3.3" | ||
- "3.4" | ||
- "3.5" | ||
- "pypy" | ||
- "pypy3" | ||
|
||
install: | ||
- pip install tox | ||
- pip install -e . | ||
script: | ||
- tox -e $TOX_ENV | ||
sudo: false | ||
- py.test | ||
|
||
cache: | ||
directories: | ||
- $HOME/.cache/pip/http |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,75 @@ | ||
# -*- coding: utf-8 -*- | ||
from __future__ import (absolute_import, print_function, | ||
unicode_literals, division) | ||
from __future__ import absolute_import, division, print_function | ||
|
||
import logging | ||
from contextlib import closing, contextmanager | ||
|
||
import pytest | ||
import py | ||
|
||
|
||
__version__ = '1.1' | ||
|
||
|
||
def get_logger_obj(logger=None): | ||
"""Get a logger object that can be specified by its name, or passed as is. | ||
|
||
Defaults to the root logger. | ||
""" | ||
if logger is None or isinstance(logger, py.builtin._basestring): | ||
logger = logging.getLogger(logger) | ||
return logger | ||
|
||
|
||
@contextmanager | ||
def logging_at_level(level, logger=None): | ||
"""Context manager that sets the level for capturing of logs.""" | ||
logger = get_logger_obj(logger) | ||
|
||
orig_level = logger.level | ||
logger.setLevel(level) | ||
try: | ||
yield | ||
finally: | ||
logger.setLevel(orig_level) | ||
|
||
|
||
@contextmanager | ||
def logging_using_handler(handler, logger=None): | ||
"""Context manager that safely register a given handler.""" | ||
logger = get_logger_obj(logger) | ||
|
||
if handler in logger.handlers: # reentrancy | ||
# Adding the same handler twice would confuse logging system. | ||
# Just don't do that. | ||
yield | ||
else: | ||
logger.addHandler(handler) | ||
try: | ||
yield | ||
finally: | ||
logger.removeHandler(handler) | ||
|
||
|
||
@contextmanager | ||
def catching_logs(handler, filter=None, formatter=None, | ||
level=logging.NOTSET, logger=None): | ||
"""Context manager that prepares the whole logging machinery properly.""" | ||
logger = get_logger_obj(logger) | ||
|
||
if filter is not None: | ||
handler.addFilter(filter) | ||
if formatter is not None: | ||
handler.setFormatter(formatter) | ||
handler.setLevel(level) | ||
|
||
with closing(handler), \ | ||
logging_using_handler(handler, logger), \ | ||
logging_at_level(min(handler.level, logger.level), logger): | ||
|
||
yield handler | ||
|
||
|
||
def add_option_ini(parser, option, dest, default=None, help=None): | ||
parser.addini(dest, default=default, | ||
help='default value for ' + option) | ||
|
@@ -62,55 +122,36 @@ def __init__(self, config): | |
get_option_ini(config, 'log_format'), | ||
get_option_ini(config, 'log_date_format')) | ||
|
||
@contextmanager | ||
def _runtest_for(self, item, when): | ||
"""Implements the internals of pytest_runtest_xxx() hook.""" | ||
with catching_logs(CatchLogHandler(), | ||
formatter=self.formatter) as log_handler: | ||
item.catch_log_handler = log_handler | ||
try: | ||
yield # run test | ||
finally: | ||
del item.catch_log_handler | ||
|
||
if self.print_logs: | ||
# Add a captured log section to the report. | ||
log = log_handler.stream.getvalue().strip() | ||
item.add_report_section(when, 'log', log) | ||
|
||
@pytest.mark.hookwrapper | ||
def pytest_runtest_setup(self, item): | ||
"""Start capturing log messages for this test. | ||
|
||
Creating a specific handler for each test ensures that we | ||
avoid multi threading issues. | ||
|
||
Attaching the handler and setting the level at the beginning | ||
of each test ensures that we are setup to capture log | ||
messages. | ||
""" | ||
|
||
# Create a handler for this test. | ||
item.catch_log_handler = CatchLogHandler() | ||
item.catch_log_handler.setFormatter(self.formatter) | ||
with self._runtest_for(item, 'setup'): | ||
yield | ||
|
||
# Attach the handler to the root logger and ensure that the | ||
# root logger is set to log all levels. | ||
root_logger = logging.getLogger() | ||
root_logger.addHandler(item.catch_log_handler) | ||
root_logger.setLevel(logging.NOTSET) | ||
@pytest.mark.hookwrapper | ||
def pytest_runtest_call(self, item): | ||
with self._runtest_for(item, 'call'): | ||
yield | ||
|
||
def pytest_runtest_makereport(self, __multicall__, item, call): | ||
"""Add captured log messages for this report.""" | ||
|
||
report = __multicall__.execute() | ||
|
||
# This fn called after setup, call and teardown. Only | ||
# interested in just after test call has finished. | ||
if call.when == 'call': | ||
|
||
# Detach the handler from the root logger to ensure no | ||
# further access to the handler. | ||
root_logger = logging.getLogger() | ||
root_logger.removeHandler(item.catch_log_handler) | ||
|
||
# For failed tests that have captured log messages add a | ||
# captured log section to the report if desired. | ||
if not report.passed and self.print_logs: | ||
long_repr = getattr(report, 'longrepr', None) | ||
if hasattr(long_repr, 'addsection'): | ||
log = item.catch_log_handler.stream.getvalue().strip() | ||
if log: | ||
long_repr.addsection('Captured log', log) | ||
|
||
# Release the handler resources. | ||
item.catch_log_handler.close() | ||
del item.catch_log_handler | ||
|
||
return report | ||
@pytest.mark.hookwrapper | ||
def pytest_runtest_teardown(self, item): | ||
with self._runtest_for(item, 'teardown'): | ||
yield | ||
|
||
|
||
class CatchLogHandler(logging.StreamHandler): | ||
|
@@ -139,10 +180,13 @@ def emit(self, record): | |
class CatchLogFuncArg(object): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "FuncArg" is basically the old concept of fixtures - see reasoning for fixture/funcarg evolution - so I don't think this should be called funcarg anymore now that we use fixtures instead of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @The-Compiler Thanks. I thought that "funcarg" and "fixtures" are roughly synonymous, the one just being a modern alternative to the other. How do I call this class? /cc @eisensheng There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd just call it There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I decided to pick |
||
"""Provides access and control of log capturing.""" | ||
|
||
def __init__(self, handler): | ||
"""Creates a new funcarg.""" | ||
@property | ||
def handler(self): | ||
return self._item.catch_log_handler | ||
|
||
self.handler = handler | ||
def __init__(self, item): | ||
"""Creates a new funcarg.""" | ||
self._item = item | ||
|
||
def text(self): | ||
"""Returns the log text.""" | ||
|
@@ -184,37 +228,19 @@ def at_level(self, level, logger=None): | |
""" | ||
|
||
obj = logger and logging.getLogger(logger) or self.handler | ||
return CatchLogLevel(obj, level) | ||
|
||
return logging_at_level(level, obj) | ||
|
||
class CatchLogLevel(object): | ||
"""Context manager that sets the logging level of a handler or logger.""" | ||
|
||
def __init__(self, obj, level): | ||
"""Creates a new log level context manager.""" | ||
@pytest.fixture | ||
def caplog(request): | ||
"""Access and control log capturing. | ||
|
||
self.obj = obj | ||
self.level = level | ||
Captured logs are available through the following methods:: | ||
|
||
def __enter__(self): | ||
"""Adjust the log level.""" | ||
|
||
self.orig_level = self.obj.level | ||
self.obj.setLevel(self.level) | ||
|
||
def __exit__(self, exc_type, exc_value, traceback): | ||
"""Restore the log level.""" | ||
|
||
self.obj.setLevel(self.orig_level) | ||
|
||
|
||
def pytest_funcarg__caplog(request): | ||
"""Returns a funcarg to access and control log capturing.""" | ||
|
||
return CatchLogFuncArg(request._pyfuncitem.catch_log_handler) | ||
|
||
|
||
def pytest_funcarg__capturelog(request): | ||
"""Returns a funcarg to access and control log capturing.""" | ||
* caplog.text() -> string containing formatted log output | ||
* caplog.records() -> list of logging.LogRecord instances | ||
* caplog.record_tuples() -> list of (logger_name, level, message) tuples | ||
""" | ||
return CatchLogFuncArg(request.node) | ||
|
||
return CatchLogFuncArg(request._pyfuncitem.catch_log_handler) | ||
capturelog = caplog |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Those documentation changes seem wrong as the API is still the old one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API was changed long ago: in f5c3537 (and tests in 948bfcf), I've only updated the docs to be in-sync with the code.
Let's consider restoring backward compatibility as a separate issue. 🌵