# Copyright (c) 2023, Teriks
#
# dgenerate is distributed under the following BSD 3-Clause License
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
# from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import contextlib
import os
import sys
import typing
import dgenerate.textprocessing as _textprocessing
__doc__ = """
Library logging / informational output.
"""
LEVEL = 1
"""
Current Log Level (set-able)
Setting to :py:attr:`INFO` means print all messages except :py:attr:`DEBUG` messages.
Setting to :py:attr:`ERROR` means only print :py:attr:`ERROR` messages.
Setting to :py:attr:`WARNING` means only print :py:attr:`WARNING` messages.
Setting to :py:attr:`DEBUG` means print every message.
Levels are a bitfield, so you can set: ``LEVEL = WARNING | ERROR`` etc.
"""
INFO = 1
"""Log level ``INFO``"""
WARNING = 2
"""Log Level ``WARNING``"""
ERROR = 4
"""Log Level ``ERROR``"""
DEBUG = 8
"""Log Level ``DEBUG``"""
_ERROR_FILE = sys.stderr
_MESSAGE_FILE = sys.stdout
AUTO_FLUSH_MESSAGES = True
"""
Whether to auto flush the output stream when printing to ``stdout``
or the output file assigned with :py:func:`.set_message_file`.
Errors are printed to ``stderr`` which is unbuffered by default.
"""
_handlers = []
_level_stack = []
[docs]
def push_level(level):
"""
Set :py:attr:`dgenerate.messages.LEVEL` and save the previous value to a stack.
:param level: one of :py:attr:`.INFO`, :py:attr:`.WARNING`, :py:attr:`.ERROR`, , :py:attr:`.DEBUG`
"""
global LEVEL
_level_stack.append(LEVEL)
LEVEL = level
[docs]
def pop_level():
"""
Pop ``dgenerate.messages.LEVEL`` value last saved by :py:func:`.push_level` and assign it to :py:attr:`.LEVEL`.
If no previous level was saved, no-op.
"""
global LEVEL, _level_stack
if _level_stack:
LEVEL = _level_stack.pop()
[docs]
@contextlib.contextmanager
def with_level(level):
"""
Context manager which pushes a ``dgenerate.messages.LEVEL`` to
the stack and pops it when the ``with`` context ends.
This affects logging output level within the context.
:param level: log level
"""
try:
push_level(level)
yield
finally:
pop_level()
[docs]
def set_error_file(file: typing.TextIO):
"""
Set a file stream or file like object for dgenerate's error output.
:param file: The file stream
"""
global _ERROR_FILE
_ERROR_FILE = file
[docs]
def set_message_file(file: typing.TextIO):
"""
Set a file stream or file like object for dgenerate's normal (non error) output.
:param file: The file stream
"""
global _MESSAGE_FILE
_MESSAGE_FILE = file
[docs]
def get_error_file():
"""
Get the file stream or file like object for dgenerate's error output.
"""
global _ERROR_FILE
return _ERROR_FILE
[docs]
def get_message_file():
"""
Get the file stream or file like object for dgenerate's normal (non error) output.
"""
global _MESSAGE_FILE
return _MESSAGE_FILE
[docs]
def messages_to_null():
"""
Force dgenerate's normal output to a null file.
"""
global _MESSAGE_FILE
_MESSAGE_FILE = open(os.devnull, "w")
[docs]
def errors_to_null():
"""
Force dgenerate's error output to a null file.
"""
global _ERROR_FILE
_ERROR_FILE = open(os.devnull, "w")
[docs]
@contextlib.contextmanager
def silence():
"""
Context manager to silence all dgenerate logging / messages.
This will redirect all messages to a null file temporarily.
"""
global _MESSAGE_FILE, _ERROR_FILE
old_message_file = _MESSAGE_FILE
old_error_file = _ERROR_FILE
messages_to_null()
errors_to_null()
try:
yield
finally:
_MESSAGE_FILE = old_message_file
_ERROR_FILE = old_error_file
[docs]
def add_logging_handler(callback: typing.Callable[[typing.ParamSpecArgs, int, bool, str], None]):
"""
Add your own logging handler callback.
:param callback: Callback accepting ``(*args, LEVEL, underline (bool), underline_char)``
"""
_handlers.append(callback)
[docs]
def remove_logging_handler(callback: typing.Callable[[typing.ParamSpecArgs, int, bool, str], None]):
"""
Remove a logging handler callback by reference.
:param callback: The previously registered callback
"""
_handlers.remove(callback)
[docs]
def log(*args: typing.Any, level=INFO, underline=False, underline_char='='):
"""
Write a message to dgenerate's log
:param args: args, objects that will be stringified and joined with a space
:param level: Log level, one of:
:py:attr:`.INFO`, :py:attr:`.WARNING`, :py:attr:`.ERROR`, :py:attr:`.DEBUG`
:param underline: Underline this message?
:param underline_char: Underline character
"""
global _MESSAGE_FILE, _ERROR_FILE
file = _ERROR_FILE if level == ERROR else _MESSAGE_FILE
allowed = set()
if LEVEL & INFO:
allowed.update({INFO, ERROR, WARNING})
if LEVEL & ERROR:
allowed.update({ERROR})
if LEVEL & WARNING:
allowed.update({WARNING})
if LEVEL & DEBUG:
allowed.update({INFO, ERROR, WARNING, DEBUG})
if level not in allowed:
return
prefix = ''
if level == DEBUG:
prefix = 'DEBUG: '
if level == WARNING:
prefix = 'WARNING: '
if underline:
print(_textprocessing.underline(prefix + ' '.join(str(a) for a in args),
underline_char=underline_char), file=file, flush=AUTO_FLUSH_MESSAGES)
else:
print(prefix + ' '.join(str(a) for a in args), file=file, flush=AUTO_FLUSH_MESSAGES)
for handler in _handlers:
handler(*args,
level=level,
underline=underline,
underline_char=underline_char)
[docs]
def error(*args: typing.Any, underline=False, underline_char='='):
"""
Write an error message to dgenerate's log
:param args: args, objects that will be stringified and joined with a space
:param underline: Underline this message?
:param underline_char: Underline character
"""
log(*args, level=ERROR, underline=underline, underline_char=underline_char)
[docs]
def warning(*args: typing.Any, underline=False, underline_char='='):
"""
Write a warning message to dgenerate's log
:param args: args, objects that will be stringified and joined with a space
:param underline: Underline this message?
:param underline_char: Underline character
"""
log(*args, level=WARNING, underline=underline, underline_char=underline_char)
[docs]
def debug_log(*func_or_str: typing.Callable[[], typing.Any] | typing.Any,
underline=False, underline_char='='):
"""
Conditionally log strings or possibly expensive functions if :py:attr:`.LEVEL` is
set to :py:attr:`.DEBUG`.
:param func_or_str: objects to be stringified and printed or callables that return said objects
:param underline: Underline this message?
:param underline_char: Underline character.
"""
if LEVEL == DEBUG:
vals = []
for val in func_or_str:
if callable(val):
vals.append(val())
else:
vals.append(val)
log(*vals, level=DEBUG, underline=underline, underline_char=underline_char)