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

Capture logs also from setup and teardown stages #7

Merged
merged 10 commits into from
Oct 17, 2015
27 changes: 16 additions & 11 deletions .travis.yml
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
24 changes: 12 additions & 12 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ Running without options::

Shows failed tests like so::

-------------------------- Captured log ---------------------------
----------------------- Captured stdlog call ----------------------
test_pytest_catchlog.py 26 INFO text going to logger
------------------------- Captured stdout -------------------------
----------------------- Captured stdout call ----------------------
text going to stdout
------------------------- Captured stderr -------------------------
----------------------- Captured stderr call ----------------------
text going to stderr
==================== 2 failed in 0.02 seconds =====================

Expand All @@ -50,11 +50,11 @@ Running pytest specifying formatting options::

Shows failed tests like so::

-------------------------- Captured log ---------------------------
----------------------- Captured stdlog call ----------------------
2010-04-10 14:48:44 INFO text going to logger
------------------------- Captured stdout -------------------------
----------------------- Captured stdout call ----------------------
text going to stdout
------------------------- Captured stderr -------------------------
----------------------- Captured stderr call ----------------------
text going to stderr
==================== 2 failed in 0.02 seconds =====================

Expand All @@ -77,39 +77,39 @@ completely with::

Shows failed tests in the normal manner as no logs were captured::

------------------------- Captured stdout -------------------------
----------------------- Captured stdout call ----------------------
text going to stdout
------------------------- Captured stderr -------------------------
----------------------- Captured stderr call ----------------------
text going to stderr
==================== 2 failed in 0.02 seconds =====================

Inside tests it is possible to change the log level for the captured
log messages. This is supported by the ``caplog`` funcarg::

def test_foo(caplog):
caplog.setLevel(logging.INFO)
caplog.set_level(logging.INFO)
Copy link
Collaborator

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.

Copy link
Collaborator Author

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. 🌵

pass

By default the level is set on the handler used to catch the log
messages, however as a convenience it is also possible to set the log
level of any logger::

def test_foo(caplog):
caplog.setLevel(logging.CRITICAL, logger='root.baz')
caplog.set_level(logging.CRITICAL, logger='root.baz')
pass

It is also possible to use a context manager to temporarily change the
log level::

def test_bar(caplog):
with caplog.atLevel(logging.INFO):
with caplog.at_level(logging.INFO):
pass

Again, by default the level of the handler is affected but the level
of any logger can be changed instead with::

def test_bar(caplog):
with caplog.atLevel(logging.CRITICAL, logger='root.baz'):
with caplog.at_level(logging.CRITICAL, logger='root.baz'):
pass

Lastly all the logs sent to the logger during the test run are made
Expand Down
186 changes: 106 additions & 80 deletions pytest_catchlog.py
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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -139,10 +180,13 @@ def emit(self, record):
class CatchLogFuncArg(object):
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 pytest_funcarg__capturelog (also see the docstring of __init__).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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? CatchLogFixture / CatchLog / Caplog / CaplogObject?

/cc @eisensheng

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd just call it CatchLog or maybe LogCatcher. Though pytest calls the object behind the capsys/capfd fixtures CaptureFixture, so CatchLogFixture or LogCaptureFixture would certainly work as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I decided to pick LogCaptureFixture since the word catch sounds a bit weird for me. ✈️ ⬅️ ➡️

"""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."""
Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ def _get_version():
author_email='eisensheng@mailbox.org',
url='https://github.com/eisensheng/pytest-catchlog',
py_modules=['pytest_catchlog', ],
install_requires=['py>=1.1.1', ],
install_requires=['py>=1.1.1', 'pytest>=2.6'],
entry_points={'pytest11': ['pytest_catchlog = pytest_catchlog']},
license='MIT License',
zip_safe=False,
keywords='py.test pytest',
keywords='py.test pytest logging',
classifiers=['Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
Expand All @@ -41,6 +41,7 @@ def _get_version():
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Software Development :: Testing'])
Loading