Source code for supy._env
try:
from importlib.resources import files
except ImportError:
# backport for python < 3.9
from importlib_resources import files
import logging
import os
import sys
import tempfile
import warnings
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
########################################################################
# this file provides variable and functions useful for the whole module.
########################################################################
# get Traversable object for loading resources in this package
# this can be used similarly as `pathlib.Path` object
trv_supy_module = files("supy")
# set up logger format, note `u` to guarantee UTF-8 encoding
FORMATTER = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
# default log file name; only written when file logging is opted into
LOG_FILE = "SuPy.log"
# Environment variables to opt in to file logging. By default supy logs only to
# the console -- no SuPy.log is created -- unless one of these is set or
# `enable_file_logging()` is called explicitly. This avoids dropping a stray
# (often empty) SuPy.log into whatever directory a script happens to run from.
ENV_LOGFILE = "SUPY_LOGFILE" # explicit path to the log file
ENV_LOG_DIR = "SUPY_LOG_DIR" # directory; the file is <dir>/SuPy.log
# issue reporting URL
ISSUES_URL = "https://github.com/UMEP-dev/SUEWS/issues/new"
def get_console_handler():
# Check if stdout is available (can be None in GUI environments like QGIS)
if sys.stdout is not None:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(FORMATTER)
return console_handler
else:
# Return NullHandler if stdout is not available
return logging.NullHandler()
def _coerce_logfile_path(path):
"""Resolve a user-supplied location to a concrete log-file path.
A leading ``~`` is expanded to the user's home directory. The path is
treated as a directory (the default ``SuPy.log`` name is appended) only
when it already exists as a directory or is written with a trailing
separator; otherwise it is taken as the log-file path itself.
Parameters
----------
path : str or pathlib.Path
A log-file path, or a directory in which the default ``SuPy.log`` name
is used.
Returns
-------
pathlib.Path
The resolved log-file path.
"""
raw = str(path)
path_logfile = Path(path).expanduser()
if path_logfile.is_dir() or raw.endswith((os.sep, "/")):
return path_logfile / LOG_FILE
return path_logfile
def _resolve_env_logfile():
"""Return the opt-in log-file path requested via environment, or ``None``.
Returns
-------
pathlib.Path or None
Path from ``SUPY_LOGFILE`` (preferred) or ``SUPY_LOG_DIR``; ``None``
when neither is set.
"""
env_file = os.environ.get(ENV_LOGFILE)
if env_file:
return Path(env_file)
env_dir = os.environ.get(ENV_LOG_DIR)
if env_dir:
return Path(env_dir) / LOG_FILE
return None
def get_file_handler(path=None):
"""Build a rotating file handler that defers file creation.
``delay=True`` means the underlying file is opened only when the first
record is emitted, so merely constructing the handler -- or importing
supy -- never leaves a stray empty ``SuPy.log`` behind.
Parameters
----------
path : str or pathlib.Path, optional
Target log file or directory. Defaults to ``SuPy.log`` in the current
working directory.
Returns
-------
logging.handlers.TimedRotatingFileHandler
A handler configured for daily rotation at midnight, UTF-8 encoded.
"""
path_logfile = _coerce_logfile_path(path) if path is not None else Path(LOG_FILE)
log_dir = path_logfile.parent
# Best-effort: make sure the target directory exists when one was asked
# for. No log file is created here -- that is what `delay=True` guarantees.
try:
log_dir.mkdir(parents=True, exist_ok=True)
except OSError:
pass
if not os.access(log_dir, os.W_OK):
path_fallback = Path(tempfile.gettempdir()) / path_logfile.name
warnings.warn(
f"Log directory '{log_dir}' is not writable; "
f"writing SuPy log to '{path_fallback}' instead",
UserWarning,
stacklevel=2,
)
path_logfile = path_fallback
file_handler = TimedRotatingFileHandler(
path_logfile,
when="midnight",
encoding="utf-8",
delay=True,
)
file_handler.setFormatter(FORMATTER)
return file_handler
def get_logger(logger_name, level=logging.DEBUG):
logger = logging.getLogger(logger_name)
# better to have too much log than not enough
logger.setLevel(level)
logger.addHandler(get_console_handler())
# File logging is opt-in: no file handler is attached by default (see
# `enable_file_logging` and the SUPY_LOGFILE / SUPY_LOG_DIR env vars).
# with this pattern, it's rarely necessary to propagate the error up to parent
logger.propagate = False
return logger
logger_supy = get_logger("SuPy", logging.INFO)
logger_supy.debug("a debug message from SuPy")
# Reference to the active opt-in file handler (None until file logging is on).
_file_handler = None
[docs]
def enable_file_logging(path=None):
"""Write SuPy log messages to a file (opt-in).
Parameters
----------
path : str or pathlib.Path, optional
Target log file (a leading ``~`` is expanded to the home directory),
or an existing directory / a path with a trailing separator, in which
a ``SuPy.log`` file is written. Defaults to ``SuPy.log`` in the current
working directory. For a directory that may not exist yet, prefer the
``SUPY_LOG_DIR`` environment variable.
Returns
-------
pathlib.Path
The resolved log-file path. The file itself is not created until the
first log record is emitted (``delay=True``).
Notes
-----
Calling this again after file logging is already enabled is a no-op; the
existing handler's path is returned. Call :func:`disable_file_logging`
first to switch to a different file.
"""
global _file_handler
if _file_handler is not None:
return Path(_file_handler.baseFilename)
handler = get_file_handler(path)
logger_supy.addHandler(handler)
_file_handler = handler
return Path(handler.baseFilename)
[docs]
def disable_file_logging():
"""Detach and close the opt-in file handler, if one is active.
Returns
-------
None
"""
global _file_handler
if _file_handler is not None:
logger_supy.removeHandler(_file_handler)
_file_handler.close()
_file_handler = None
# Honour the opt-in environment variables at import time so users can enable
# file logging without touching code (e.g. ``export SUPY_LOG_DIR=~/supy-logs``).
_env_logfile = _resolve_env_logfile()
if _env_logfile is not None:
enable_file_logging(_env_logfile)
if sys.version_info >= (3, 8):
from importlib import metadata
else:
from importlib_metadata import metadata