Repository: ManageIQ/integration_tests

Issue:
Logging improvements
- You can now (finally!) override the log source and lineno.
  - Implemented in `fixtures/log.py`, inspired by @psav's #433
- Explained the relationship between logger names and logger confs,
  as discussed when the perflog was introduced.

closes #436 



Discussion:

Resolving patch:
diff --git a/conftest.py b/conftest.py
--- a/conftest.py
+++ b/conftest.py
@@ -5,6 +5,7 @@
 2) Load a number of plugins and fixtures automatically
 '''
 from pkgutil import iter_modules
+from utils.log import logger, format_marker
 
 import pytest
 import cfme.fixtures
@@ -23,3 +24,11 @@ def _pytest_plugins_generator(*extension_pkgs):
 
 pytest_plugins = tuple(_pytest_plugins_generator(fixtures, markers, cfme.fixtures))
 collect_ignore = ["tests/scenarios"]
+
+
+def pytest_collection_modifyitems(session, config, items):
+    logger.info(format_marker('Starting new test run', mark="="))
+    expression = config.getvalue('keyword') or False
+    expr_string = ', will filter with "%s"' % expression if expression else ''
+    logger.info('Collected %i items%s' % (len(items), expr_string))
+
diff --git a/fixtures/log.py b/fixtures/log.py
--- a/fixtures/log.py
+++ b/fixtures/log.py
@@ -1,6 +1,7 @@
 import pytest
 
 from utils.log import logger as cfme_logger
+from utils.log import format_marker
 
 
 # Expose the cfme logger as a fixture for convenience
@@ -11,18 +12,25 @@ def logger():
 
 @pytest.mark.tryfirst
 def pytest_runtest_setup(item):
-    cfme_logger.info('py.test starting %s' % _format_nodeid(item.nodeid))
+    cfme_logger.info(format_marker(_format_nodeid(item.nodeid), mark="-"))
+    cfme_logger.info('py.test starting %s' % _format_nodeid(item.nodeid),
+        extra={'source_file': item.fspath, 'source_lineno': None})
 
 
 @pytest.mark.trylast
 def pytest_runtest_logreport(report):
     if report.when == 'teardown':
-        cfme_logger.info('py.test finished %s' % _format_nodeid(report.nodeid))
+        # items don't have line numbers, so leave it out here to be consistent with
+        # the pytest_runtest_setup hook
+        path, lineno, domaininfo = report.location
+        cfme_logger.info('py.test finished %s' % _format_nodeid(report.nodeid),
+            extra={'source_file': path, 'source_lineno': None})
 
 
 def _format_nodeid(nodeid):
-    # Remove test class instances, replace with a dot to impersonate a method call
+    # Remove test class instances and filenames, replace with a dot to impersonate a method call
     nodeid = nodeid.replace('::()::', '.')
     # Trim double-colons to single
     nodeid = nodeid.replace('::', ':')
-    return nodeid
+    # Strip filename (everything before and including the first colon)
+    return nodeid.split(':', 1)[1]
diff --git a/utils/log.py b/utils/log.py
--- a/utils/log.py
+++ b/utils/log.py
@@ -31,6 +31,24 @@
     [E] error (filename.py:6)
     [C] fatal (filename.py:7)
 
+Log Message Source
+^^^^^^^^^^^^^^^^^^
+
+We have added a custom log record attribute that can be used in log messages: ``%(source)s`` This
+attribute is included in the default 'cfme' logger configuration.
+
+This attribute will be generated by default and include the filename and line number from where the
+log message was emitted. It will attempt to convert file paths to be relative to cfme_tests, but use
+the absolute file path if a relative path can't be determined.
+
+When writting generic logging facilities, it is sometimes helpful to override
+those source locations to make the resultant log message more useful. To do so, pass the extra
+``source_file`` (str) and ``source_lineno`` (int) to the log emission::
+
+    logger.info('info log message', extra={'source_file': 'somefilename.py', 'source_lineno': 7})
+
+If ``source_lineno`` is ``None`` and ``source_file`` is included, the line number will be omitted.
+This is useful in cases where the line number can't be determined or isn't necessary.
 
 Configuration
 ^^^^^^^^^^^^^
@@ -50,28 +68,48 @@
         # If True, messages of level ERROR and CRITICAL are also written to stderr
         errors_to_console: False
         # Default file format
-        file_format: "%(asctime)-15s [%(levelname).1s] %(message)s (%(relpath)s:%(lineno)d)"
+        file_format: "%(asctime)-15s [%(levelname).1s] %(message)s (%(source)s)"
         # Default format to console if errors_to_console is True
-        stream_format: "[%(levelname)s] %(message)s (%(relpath)s:%(lineno)d)"
+        stream_format: "[%(levelname)s] %(message)s (%(source)s)"
 
 Additionally, individual logger configurations can be overridden by defining nested configuration
-values using the logger name as the configuration key.
+values using the logger name as the configuration key. Note that the name of the logger objects
+exposed by this module don't obviously line up with their key in ``cfme_data``. The 'name' attribute
+of loggers can be inspected to get this value::
+
+    >>> utils.log.logger.name
+    'cfme'
+    >>> utils.log.perflog.logger.name
+    'perf'
+
+Here's an example of those names being used in ``env.local.yaml`` to configure loggers
+individually:
 
 .. code-block:: yaml
 
-    # in env.yaml
     logging:
         cfme:
             # set the cfme log level to debug
             level: DEBUG
-        perflog:
+        perf:
             # make the perflog a little more "to the point"
             file_format: "%(message)s"
 
+Notes:
+
+* The ``cfme`` and ``perf`` loggers are guaranteed to exist when using this module.
+* The name of a logger is used to generate its filename, and will usually not have the word
+  "log" in it.
+
+  * ``perflog``'s logger name is ``perf`` for this reason, resulting in ``log/perf.log``
+    instead of ``log/perflog.log``.
+  * Similarly, ``logger``'s' name is ``cfme``, to prevent having ``log/logger.log``.
+
 .. warning::
 
     Creating a logger with the same name as one of the default configuration keys,
     e.g. ``create_logger('level')`` will cause a rift in space-time (or a ValueError).
+
     Do not attempt.
 
 Message Format
@@ -106,8 +144,8 @@
     'max_file_size': 0,
     'max_file_backups': 0,
     'errors_to_console': False,
-    'file_format': '%(asctime)-15s [%(levelname).1s] %(message)s (%(relpath)s:%(lineno)d)',
-    'stream_format': '[%(levelname)s] %(message)s (%(relpath)s:%(lineno)d)'
+    'file_format': '%(asctime)-15s [%(levelname).1s] %(message)s (%(source)s)',
+    'stream_format': '[%(levelname)s] %(message)s (%(source)s)'
 }
 
 
@@ -135,11 +173,24 @@ class _RelpathFilter(logging.Filter):
     """Adds the relpath attr to records
 
     Not actually a filter, this was the least ridiculous way to add custom dynamic
-    record attributes.
+    record attributes and reduce it all down to the ``source`` record attr.
+
+    looks for 'source_file' and 'source_lineno' on the log record, falls back to builtin
+    record attributes if they aren't found.
 
     """
     def filter(self, record):
-        record.relpath = get_rel_path(record.pathname)
+        try:
+            relpath = get_rel_path(record.source_file) or record.source_file
+            lineno = record.source_lineno
+        except AttributeError:
+            relpath = get_rel_path(record.pathname) or record.pathname
+            lineno = record.lineno
+        if lineno:
+            record.source = "%s:%d" % (relpath, lineno)
+        else:
+            record.source = relpath
+
         return True
 
 
@@ -245,6 +296,33 @@ def _showwarning(message, category, filename, lineno, file=None, line=None):
     message = "%s from %s:%d: %s" % (category.__name__, _from, lineno, message)
     logger.warning(message)
 
+MARKER_LEN = 80
+
+
+def format_marker(mstring, mark="-"):
+    """ Creates a marker in log files using a string and leader mark.
+
+    This function uses the constant ``MARKER_LEN`` to determine the length of the marker,
+    and then sandwiches the message string between multiple ``leader_mark``s.
+
+    Args:
+        mstring: The message string to be placed in the marker.
+        leader_mark: The marker character to use for leading and trailing.
+
+    Returns: The formatted marker string.
+
+    Note: If the message string is too long to fit one character of leader/trailer and
+        a space, then the message is returned as is.
+    """
+    leader_req = MARKER_LEN - len(mstring)
+    if leader_req < 4:
+        return mstring
+    leader = mark * ((leader_req / 2) - 1)
+    marker = '%s %s %s' % (leader, mstring, leader)
+    if (MARKER_LEN - len(mstring)) % 2:
+        marker += mark
+    return marker
+
 logger = create_logger('cfme')
 # Capture warnings to the cfme logger using the warnings.showwarning hook
 warnings.showwarning = _showwarning


Test patch: