Source code for reach_tools.utils.logging_utils
import logging
from pathlib import Path
from typing import Union, Optional
import pandas as pd
from .packages import has_arcpy
__all__ = ["configure_logging", "format_pandas_for_logging"]
class ArcpyHandler(logging.Handler):
"""
Logging message handler capable of routing logging through ArcPy AddMessage, AddWarning and AddError methods.
DEBUG and INFO logging messages are be handled by the AddMessage method. WARNING logging messages are handled
by the AddWarning method. ERROR and CRITICAL logging messages are handled by the AddError method.
Basic use consists of the following.
.. code-block:: python
logger = logging.getLogger('arcpy-logger')
logger.setLevel('INFO')
ah = ArcpyHandler()
logger.addHandler(ah)
logger.debug('nauseatingly detailed debugging message')
logger.info('something actually useful to know')
logger.warning('The sky may be falling')
logger.error('The sky is falling.)
logger.critical('The sky appears to be falling because a giant meteor is colliding with the earth.')
"""
# since everything goes through ArcPy methods, we do not need a message line terminator
terminator = ""
def __init__(self, level: Union[int, str] = 10):
# throw logical error if arcpy not available
if not has_arcpy:
raise EnvironmentError(
"The ArcPy handler requires an environment with ArcPy, a Python environment with "
"ArcGIS Pro or ArcGIS Enterprise."
)
# call the parent to cover rest of any potential setup
super().__init__(level=level)
def emit(self, record: logging.LogRecord) -> None:
"""
Args:
record: Record containing all information needed to emit a new logging event.
.. note::
This method should not be called directly, but rather enables the ``Logger`` methods to
be able to use this handler correctly.
"""
# run through the formatter to honor logging formatter settings
msg = self.format(record)
# late import to avoid issues in non-ArcPy environments
import arcpy
# route anything NOTSET (0), DEBUG (10) or INFO (20) through AddMessage
if record.levelno <= 20:
arcpy.AddMessage(msg)
# route all WARN (30) messages through AddWarning
elif record.levelno == 30:
arcpy.AddWarning(msg)
# everything else; ERROR (40), FATAL (50) and CRITICAL (50), route through AddError
else:
arcpy.AddError(msg)
# setup logging
[docs]
def configure_logging(
level: Optional[Union[str, int]] = "INFO",
logfile_path: Union[Path, str] = None,
propagate: bool = False,
) -> logging.Logger:
"""
Get Python :class:`Logger<logging.Logger>` configured to provide stream, file or, if available, ArcPy output.
The way the method is set up, logging will be routed through ArcPy messaging using :class:`ArcpyHandler` if
ArcPy is available. If ArcPy is *not* available, messages will be sent to the console using a
:class:`StreamHandler<logging.StreamHandler>`. Next, if the ``logfile_path`` is provided, log messages will also
be written to the provided path to a logfile using a :class:`FileHandler<logging.FileHandler>`.
Valid ``log_level`` inputs include:
* ``DEBUG`` - Detailed information, typically of interest only when diagnosing problems.
* ``INFO`` - Confirmation that things are working as expected.
* ``WARNING`` or ``WARN`` - An indication that something unexpected happened, or indicative of some problem in the
near future (e.g. ‘disk space low’). The software is still working as expected.
* ``ERROR`` - Due to a more serious problem, the software has not been able to perform some function.
* ``CRITICAL`` - A serious error, indicating that the program itself may be unable to continue running.
Args:
level: Logging level to use. Default is `'INFO'`.
logfile_path: Where to save the logfile if file output is desired.
.. code-block:: python
# only output to console and potentially Pro if ArcPy is available
configure_logging('DEBUG')
logging.debug('nauseatingly detailed debugging message')
logging.info('something actually useful to know')
logging.warning('The sky may be falling')
logging.error('The sky is falling.)
logging.critical('The sky appears to be falling because a giant meteor is colliding with the earth.')
"""
# ensure valid logging level
log_str_lst = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "WARN", "FATAL"]
log_int_lst = [0, 10, 20, 30, 40, 50]
if not isinstance(level, (str, int)):
raise ValueError(
"You must define a specific logging level for log_level as a string or integer."
)
elif isinstance(level, str) and level not in log_str_lst:
raise ValueError(
f'The log_level must be one of {log_str_lst}. You provided "{level}".'
)
elif isinstance(level, int) and level not in log_int_lst:
raise ValueError(
f"If providing an integer for log_level, it must be one of the following, {log_int_lst}."
)
# get default logger and set logging level at the same time
logger = logging.getLogger()
logger.setLevel(level=level)
# clear handlers
logger.handlers.clear()
# configure formatting
log_frmt = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
# make sure at least a stream handler is present
ch = logging.StreamHandler()
ch.setFormatter(log_frmt)
logger.addHandler(ch)
# if in an environment with ArcPy, add handler to bubble logging up to ArcGIS through ArcPy
if has_arcpy:
ah = ArcpyHandler()
ah.setFormatter(log_frmt)
logger.addHandler(ah)
# if a path for the logfile is provided, log results to the file
if logfile_path is not None:
# ensure the full path exists
if not logfile_path.parent.exists():
logfile_path.parent.mkdir(parents=True)
# create and add the file handler
fh = logging.FileHandler(str(logfile_path))
fh.setFormatter(log_frmt)
logger.addHandler(fh)
return logger
def format_pandas_for_logging(
pandas_df: pd.DataFrame, title: str, line_tab_prefix="\t\t"
) -> str:
"""
Helper function facilitating outputting a :class:`Pandas DataFrame<pandas.DataFrame>` into a logfile. This function only
formats the data frame into text for output. It should be used in conjunction with a logging method.
.. code-block:: python
logging.info(format_pandas_for_logging(df, title='Summary Statistics'))
Args:
pandas_df: Pandas ``DataFrame`` to be converted to a string and included in the logfile.
title: String title describing the data frame.
line_tab_prefix: Optional string comprised of tabs (``\\t\\t``) to prefix each line with providing indentation.
"""
log_str = line_tab_prefix.join(pandas_df.to_string(index=False).splitlines(True))
log_str = f"{title}:\n{line_tab_prefix}{log_str}"
return log_str