Add logging control (#1585)

* do we test this at all?

* start of logging improvements

* first pass at logging, worried about performance

* setup a base class + add type hints

* add placeholder url ref

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* update to new logging control

* Fix imports

* remove bit left over from being able to delete these log controlers

* looks like ... is used more than pass

* formatting fix

* Grab new format rule from main

* run ruff check --fix on conflicting files

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Added tests

* see if this fixes the doc build

* add back debugging code

* update openmm doc url

* add note about jax warning

* bump

* Remove debugging code to see if that fixes doc build

* ruff format

* Add url

* switch to using module instead of class w/ static methods

* Update docs/guide/troubleshooting.rst

Co-authored-by: Alyssa Travitz <31974495+atravitz@users.noreply.github.com>

* ruff fixes

* Ruff fix

* Added news entry

* Refactor to move things to private namespace

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Alyssa Travitz <31974495+atravitz@users.noreply.github.com>
This commit is contained in:
Mike Henry
2025-11-21 08:30:53 -07:00
committed by GitHub
parent 36481541e9
commit b0789ff0ae
10 changed files with 773 additions and 46 deletions

View File

@@ -61,7 +61,7 @@ intersphinx_mapping = {
"python": ("https://docs.python.org/3.9", None),
"numpy": ("https://numpy.org/doc/stable", None),
"scikit.learn": ("https://scikit-learn.org/stable", None),
"openmm": ("http://docs.openmm.org/latest/api-python/", None),
"openmm": ("https://docs.openmm.org/latest/api-python/", None),
"rdkit": ("https://www.rdkit.org/docs", None),
"openeye": ("https://docs.eyesopen.com/toolkits/python/", None),
"mdtraj": ("https://www.mdtraj.org/1.9.5/", None),

View File

@@ -58,3 +58,18 @@ Save this configuration file as ``debug_logging.conf`` and then run ``openfe qui
Note that the ``--log debug_logging.conf`` argument goes between ``openfe`` and ``quickrun`` on the command line.
This will cause every package to log at the debug level, which may be quite verbose and noisy but should aid in identify what is going on right before the exception is thrown.
JAX warnings
------------
We use ``pymbar`` to analyze the free energy of the system.
``pymbar`` uses JAX to accelerate computation.
The JAX library can utilize a GPU to further accelerate computation.
If the necessary libraries for GPU acceleration are not installed and JAX detects a GPU, JAX will print a warning like this:
.. code-block:: bash
WARNING:2025-06-10 09:01:40,857:jax._src.xla_bridge:966: An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.
This warning does not mean that the *molecular dynamics* simulation will fall back to using the CPU.
The simulation will still use the computing platform specified in the settings.

24
news/jax-warning.rst Normal file
View File

@@ -0,0 +1,24 @@
**Added:**
* Emit a clarifying log message when a user gets a warning from JAX (#1585).
Fixes #1499.
**Changed:**
* <news item>
**Deprecated:**
* <news item>
**Removed:**
* <news item>
**Fixed:**
* <news item>
**Security:**
* <news item>

View File

@@ -1,19 +1,30 @@
# silence pymbar logging warnings
import logging
# We need to do this first so that we can set up our
# log control since some modules have warnings on import
from openfe.utils import logging_control
logging_control._silence_message(
msg=[
"****** PyMBAR will use 64-bit JAX! *******",
],
logger_names=[
"pymbar.mbar_solvers",
],
)
def _mute_timeseries(record):
return "Warning on use of the timeseries module:" not in record.msg
logging_control._silence_message(
msg=[
"Warning on use of the timeseries module:",
],
logger_names=[
"pymbar.timeseries",
],
)
logging_control._append_logger(
suffix="\n \n[OPENFE]: The simulation is still using the compute platform specified in the settings \n See this URL for more information: https://docs.openfree.energy/en/latest/guide/troubleshooting.html#jax-warnings \n\n",
logger_names="jax._src.xla_bridge",
)
def _mute_jax(record):
return "****** PyMBAR will use 64-bit JAX! *******" not in record.msg
_mbar_log = logging.getLogger("pymbar.timeseries")
_mbar_log.addFilter(_mute_timeseries)
_mbar_log = logging.getLogger("pymbar.mbar_solvers")
_mbar_log.addFilter(_mute_jax)
from importlib.metadata import version

View File

@@ -14,8 +14,6 @@ import numpy.typing as npt
from openff.units import Quantity, unit
from openff.units.openmm import from_openmm
from openmmtools import multistate
from pymbar import MBAR
from pymbar.utils import ParameterError
from openfe.analysis import plotting
from openfe.due import Doi, due
@@ -236,8 +234,10 @@ class MultistateEquilFEAnalysis:
* Allow folks to pass in extra options for bootstrapping etc..
* Add standard test against analyzer.get_free_energy()
"""
# pymbar has some side effects when imported so we only import it right when we
# need it
from pymbar import MBAR
# pymbar 4
mbar = MBAR(
u_ln,
N_l,
@@ -312,6 +312,10 @@ class MultistateEquilFEAnalysis:
issues with the solver when using low amounts of data points. All
uncertainties are MBAR analytical errors.
"""
# pymbar has some side effects from being imported, so we only want to import
# it right when we need it
from pymbar.utils import ParameterError
try:
u_ln = self.analyzer._unbiased_decorrelated_u_ln
N_l = self.analyzer._unbiased_decorrelated_N_l

View File

@@ -0,0 +1,518 @@
import logging
import pytest
from openfe.utils import logging_control
from openfe.utils.logging_control import (
_AppendMsgFilter,
_BaseLogFilter,
_MsgIncludesStringFilter,
)
@pytest.fixture
def logger():
"""Create a test logger with a handler that captures log records."""
test_logger = logging.getLogger("test_logger")
test_logger.setLevel(logging.DEBUG)
test_logger.handlers = [] # Clear any existing handlers
# Create a handler that stores log records
class ListHandler(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
handler = ListHandler()
test_logger.addHandler(handler)
yield test_logger, handler
# Cleanup
test_logger.handlers = []
test_logger.filters = []
class Test_MsgIncludesStringFilter:
"""Tests for _MsgIncludesStringFilter."""
def test_single_string_blocks_matching_message(self):
"""Test that a single string blocks messages containing it."""
filter_obj = _MsgIncludesStringFilter("block this")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Please block this message",
args=(),
exc_info=None,
)
assert filter_obj.filter(record) is False
def test_single_string_allows_non_matching_message(self):
"""Test that messages not containing the string are allowed."""
filter_obj = _MsgIncludesStringFilter("block this")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="This is fine",
args=(),
exc_info=None,
)
assert filter_obj.filter(record) is True
def test_list_of_strings_blocks_any_match(self):
"""Test that any string in the list blocks the message."""
filter_obj = _MsgIncludesStringFilter(["warning1", "warning2", "warning3"])
record1 = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="This has warning1 in it",
args=(),
exc_info=None,
)
assert filter_obj.filter(record1) is False
record2 = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="This has warning3 in it",
args=(),
exc_info=None,
)
assert filter_obj.filter(record2) is False
def test_list_allows_non_matching_messages(self):
"""Test that messages not matching any string are allowed."""
filter_obj = _MsgIncludesStringFilter(["warning1", "warning2"])
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="This is completely different",
args=(),
exc_info=None,
)
assert filter_obj.filter(record) is True
def test_substring_matching(self):
"""Test that substring matching works correctly."""
filter_obj = _MsgIncludesStringFilter("JAX")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="****** PyMBAR will use 64-bit JAX! *******",
args=(),
exc_info=None,
)
assert filter_obj.filter(record) is False
def test_case_sensitive_matching(self):
"""Test that matching is case-sensitive."""
filter_obj = _MsgIncludesStringFilter("Error")
record1 = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="This has Error in it",
args=(),
exc_info=None,
)
assert filter_obj.filter(record1) is False
record2 = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="This has error in it",
args=(),
exc_info=None,
)
assert filter_obj.filter(record2) is True
class Test_AppendMsgFilter:
"""Tests for _AppendMsgFilter."""
def test_single_suffix_appends(self):
"""Test that a single suffix is appended to the message."""
filter_obj = _AppendMsgFilter(" [DEPRECATED]")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Original message",
args=(),
exc_info=None,
)
result = filter_obj.filter(record)
assert result is True
assert record.msg == "Original message [DEPRECATED]"
def test_multiple_suffixes_append_in_order(self):
"""Test that multiple suffixes are appended in order."""
filter_obj = _AppendMsgFilter([" [DEPRECATED]", " - see docs"])
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Original message",
args=(),
exc_info=None,
)
filter_obj.filter(record)
assert record.msg == "Original message [DEPRECATED] - see docs"
def test_idempotent_single_suffix(self):
"""Test that applying the same suffix twice is idempotent."""
filter_obj = _AppendMsgFilter(" [DEPRECATED]")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Original message [DEPRECATED]",
args=(),
exc_info=None,
)
filter_obj.filter(record)
assert record.msg == "Original message [DEPRECATED]"
def test_idempotent_multiple_suffixes(self):
"""Test idempotency with multiple suffixes."""
filter_obj = _AppendMsgFilter([" [DEPRECATED] - see docs", " [DEPRECATED] - see docs"])
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Original message [DEPRECATED] - see docs",
args=(),
exc_info=None,
)
filter_obj.filter(record)
assert record.msg == "Original message [DEPRECATED] - see docs"
def test_always_returns_true(self):
"""Test that the filter always returns True to allow logging."""
filter_obj = _AppendMsgFilter(" [INFO]")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Message",
args=(),
exc_info=None,
)
assert filter_obj.filter(record) is True
class Testlogging_control:
"""Tests for logging_control module."""
def test__silence_message_single_string_single_logger(self, logger):
"""Test silencing a single message from a single logger."""
test_logger, handler = logger
logging_control._silence_message(msg="block this", logger_names="test_logger")
test_logger.info("block this message")
test_logger.info("allow this message")
assert len(handler.records) == 1
assert handler.records[0].msg == "allow this message"
def test__silence_message_multiple_strings_single_logger(self, logger):
"""Test silencing multiple messages from a single logger."""
test_logger, handler = logger
logging_control._silence_message(msg=["warning1", "warning2"], logger_names="test_logger")
test_logger.info("This has warning1")
test_logger.info("This has warning2")
test_logger.info("This is fine")
assert len(handler.records) == 1
assert handler.records[0].msg == "This is fine"
def test__silence_message_single_string_multiple_loggers(self):
"""Test silencing a message from multiple loggers."""
logger1 = logging.getLogger("test_logger1")
logger2 = logging.getLogger("test_logger2")
# Clear any existing filters
logger1.filters = []
logger2.filters = []
logging_control._silence_message(
msg="block this", logger_names=["test_logger1", "test_logger2"]
)
assert len(logger1.filters) == 1
assert len(logger2.filters) == 1
# Cleanup
logger1.filters = []
logger2.filters = []
def test__silence_message_multiple_strings_multiple_loggers(self):
"""Test silencing multiple messages from multiple loggers."""
logger1 = logging.getLogger("test_logger1")
logger2 = logging.getLogger("test_logger2")
logger1.filters = []
logger2.filters = []
logging_control._silence_message(
msg=["warning1", "warning2"], logger_names=["test_logger1", "test_logger2"]
)
# Should have one filter per logger (not one per message)
assert len(logger1.filters) == 1
assert len(logger2.filters) == 1
# Cleanup
logger1.filters = []
logger2.filters = []
def test__silence_logger_single(self, logger):
"""Test completely silencing a single logger."""
test_logger, handler = logger
logging_control._silence_logger(logger_names="test_logger")
test_logger.debug("debug message")
test_logger.info("info message")
test_logger.warning("warning message")
test_logger.error("error message")
# All messages should be blocked
assert len(handler.records) == 0
def test__silence_logger_multiple(self):
"""Test silencing multiple loggers."""
logger1 = logging.getLogger("test_logger1")
logger2 = logging.getLogger("test_logger2")
original_level1 = logger1.level
original_level2 = logger2.level
logging_control._silence_logger(logger_names=["test_logger1", "test_logger2"])
assert logger1.level == logging.CRITICAL
assert logger2.level == logging.CRITICAL
# Cleanup
logger1.setLevel(original_level1)
logger2.setLevel(original_level2)
def test__silence_logger_custom_level(self, logger):
"""Test silencing logger with custom level."""
test_logger, handler = logger
logging_control._silence_logger(logger_names="test_logger", level=logging.ERROR)
test_logger.debug("debug message")
test_logger.info("info message")
test_logger.warning("warning message")
test_logger.error("error message")
# Only error and above should pass through
assert len(handler.records) == 1
assert handler.records[0].msg == "error message"
def test__append_logger_single_suffix_single_logger(self, logger):
"""Test appending a single suffix to a single logger."""
test_logger, handler = logger
logging_control._append_logger(suffix=" [DEPRECATED]", logger_names="test_logger")
test_logger.info("Original message")
assert len(handler.records) == 1
assert handler.records[0].msg == "Original message [DEPRECATED]"
def test__append_logger_multiple_suffixes(self, logger):
"""Test appending multiple suffixes."""
test_logger, handler = logger
logging_control._append_logger(
suffix=[" [DEPRECATED]", " - see docs"], logger_names="test_logger"
)
test_logger.info("Original message")
assert len(handler.records) == 1
assert handler.records[0].msg == "Original message [DEPRECATED] - see docs"
def test__append_logger_multiple_loggers(self):
"""Test appending to multiple loggers."""
logger1 = logging.getLogger("test_logger1")
logger2 = logging.getLogger("test_logger2")
logger1.filters = []
logger2.filters = []
logging_control._append_logger(
suffix=" [INFO]", logger_names=["test_logger1", "test_logger2"]
)
assert len(logger1.filters) == 1
assert len(logger2.filters) == 1
# Cleanup
logger1.filters = []
logger2.filters = []
def test_pymbar_example(self, logger):
"""Test the PyMBAR use case."""
test_logger, handler = logger
logging_control._silence_message(
msg="****** PyMBAR will use 64-bit JAX! *******", logger_names="test_logger"
)
test_logger.info("****** PyMBAR will use 64-bit JAX! *******")
test_logger.info("Other message")
assert len(handler.records) == 1
assert handler.records[0].msg == "Other message"
def test_combining_multiple_controls(self, logger):
"""Test combining silence and append on the same logger."""
test_logger, handler = logger
logging_control._silence_message(msg="block", logger_names="test_logger")
logging_control._append_logger(suffix=" [INFO]", logger_names="test_logger")
test_logger.info("block this")
test_logger.info("allow this")
assert len(handler.records) == 1
assert handler.records[0].msg == "allow this [INFO]"
class TestBaseLogFilter:
"""Tests for _BaseLogFilter base class."""
def test_cannot_instantiate_abstract_class(self):
"""Test that _BaseLogFilter cannot be instantiated directly."""
with pytest.raises(TypeError):
_BaseLogFilter("test")
def test_subclass_must_implement_filter(self):
"""Test that subclasses must implement the filter method."""
class IncompleteFilter(_BaseLogFilter):
pass
with pytest.raises(TypeError):
IncompleteFilter("test")
def test_subclass_with_filter_works(self):
"""Test that a proper subclass can be instantiated."""
class CompleteFilter(_BaseLogFilter):
def filter(self, record):
return True
filter_obj = CompleteFilter("test")
assert filter_obj.strings == ["test"]
def test_string_conversion_to_list(self):
"""Test that single strings are converted to lists."""
filter_obj = _MsgIncludesStringFilter("single")
assert isinstance(filter_obj.strings, list)
assert filter_obj.strings == ["single"]
def test_list_stays_as_list(self):
"""Test that lists remain as lists."""
filter_obj = _MsgIncludesStringFilter(["one", "two", "three"])
assert isinstance(filter_obj.strings, list)
assert filter_obj.strings == ["one", "two", "three"]
class TestEdgeCases:
"""Tests for edge cases and error conditions."""
def test_empty_string(self, logger):
"""Test behavior with empty string."""
test_logger, handler = logger
logging_control._silence_message(msg="", logger_names="test_logger")
test_logger.info("message")
# Empty string matches everything as substring
assert len(handler.records) == 0
def test_empty_list(self, logger):
"""Test behavior with empty list."""
test_logger, handler = logger
logging_control._silence_message(msg=[], logger_names="test_logger")
test_logger.info("message")
# Empty list should not block anything
assert len(handler.records) == 1
def test_special_characters_in_message(self, logger):
"""Test that special characters are handled correctly."""
test_logger, handler = logger
logging_control._silence_message(
msg="[WARNING] *special* $chars$", logger_names="test_logger"
)
test_logger.info("[WARNING] *special* $chars$ in message")
test_logger.info("normal message")
assert len(handler.records) == 1
assert handler.records[0].msg == "normal message"
def test_unicode_characters(self, logger):
"""Test that unicode characters work correctly."""
test_logger, handler = logger
logging_control._silence_message(msg="🚫 blocked", logger_names="test_logger")
logging_control._append_logger(suffix="", logger_names="test_logger")
test_logger.info("🚫 blocked message")
test_logger.info("allowed message")
assert len(handler.records) == 1
assert handler.records[0].msg == "allowed message ✅"
def test_very_long_message(self, logger):
"""Test handling of very long messages."""
test_logger, handler = logger
long_msg = "x" * 10000
logging_control._silence_message(msg="needle", logger_names="test_logger")
test_logger.info(long_msg + "needle" + long_msg)
test_logger.info("short message")
assert len(handler.records) == 1
assert handler.records[0].msg == "short message"

View File

@@ -1,7 +1,10 @@
# This code is part of OpenFE and is licensed under the MIT license.
# For details, see https://github.com/OpenFreeEnergy/openfe
from . import custom_typing
from . import (
custom_typing,
logging_control,
)
from .optional_imports import requires_package
from .remove_oechem import without_oechem_backend
from .system_probe import log_system_probe

View File

@@ -0,0 +1,171 @@
import logging
from abc import ABC, abstractmethod
class _BaseLogFilter(ABC):
"""Base class for log filters that handle string or list of strings.
Parameters
----------
strings : str or list of str
String(s) to use in the filter logic
"""
def __init__(self, strings: str | list[str]) -> None:
if isinstance(strings, str):
strings = [strings]
self.strings: list[str] = strings
@abstractmethod
def filter(self, record: logging.LogRecord) -> bool:
"""Filter method to be implemented by subclasses.
Parameters
----------
record : logging.LogRecord
Log record to filter/modify
Returns
-------
bool
True to allow the record, False to block it
"""
...
class _MsgIncludesStringFilter(_BaseLogFilter):
"""Logging filter to silence specific log messages.
See https://docs.python.org/3/library/logging.html#filter-objects
Parameters
----------
strings : str or list of str
If string(s) match in log messages (substring match) then the log record
is suppressed
"""
def filter(self, record: logging.LogRecord) -> bool:
"""Filter log records that contain any of the specified strings.
Parameters
----------
record : logging.LogRecord
Log record to filter
Returns
-------
bool
False if the record should be blocked, True if it should be logged
"""
for string in self.strings:
if string in record.msg:
return False
return True
class _AppendMsgFilter(_BaseLogFilter):
"""Logging filter to append a message to a specific log message.
See https://docs.python.org/3/library/logging.html#filter-objects
Parameters
----------
strings : str or list of str
Suffix text(s) to append to log messages
"""
def __init__(self, strings: str | list[str]) -> None:
super().__init__(strings)
# Rename for clarity in this context
self.suffixes = self.strings
def filter(self, record: logging.LogRecord) -> bool:
"""Append suffix to log record message.
Parameters
----------
record : logging.LogRecord
Log record to modify
Returns
-------
bool
Always True to allow the record to be logged
"""
for suffix in self.suffixes:
# Only modify if not already appended (idempotent)
if not record.msg.endswith(suffix):
record.msg = f"{record.msg}{suffix}"
return True
def _silence_message(msg: str | list[str], logger_names: str | list[str]) -> None:
"""Silence specific log messages from one or more loggers.
Parameters
----------
msg : str or list of str
String(s) to match in log messages (substring match)
logger_names : str or list of str
Logger name(s) to apply the filter to
Examples
--------
>>> _silence_message(
... msg="****** PyMBAR will use 64-bit JAX! *******",
... logger_names=["pymbar.timeseries", "pymbar.mbar_solvers"]
... )
"""
if isinstance(logger_names, str):
logger_names = [logger_names]
filter_obj = _MsgIncludesStringFilter(msg)
for name in logger_names:
logging.getLogger(name).addFilter(filter_obj)
def _silence_logger(logger_names: str | list[str], level: int = logging.CRITICAL) -> None:
"""Completely silence one or more loggers.
Parameters
----------
logger_names : str or list of str
Logger name(s) to silence
level : int
Set logger level (default: CRITICAL to silence everything)
Examples
--------
>>> _silence_logger(logger_names=["urllib3", "requests"])
"""
if isinstance(logger_names, str):
logger_names = [logger_names]
for name in logger_names:
logging.getLogger(name).setLevel(level)
def _append_logger(suffix: str | list[str], logger_names: str | list[str]) -> None:
"""Append text to logger messages.
Parameters
----------
suffix : str or list of str
Suffix text to append to log messages
logger_names : str or list of str
Logger name(s) to modify
Examples
--------
>>> _append_logger(
... suffix=" [DEPRECATED]",
... logger_names="myapp"
... )
"""
if isinstance(logger_names, str):
logger_names = [logger_names]
filter_obj = _AppendMsgFilter(suffix)
for name in logger_names:
logging.getLogger(name).addFilter(filter_obj)

View File

@@ -1,20 +0,0 @@
import logging
class MsgIncludesStringFilter:
"""Logging filter to silence specfic log messages.
See https://docs.python.org/3/library/logging.html#filter-objects
Parameters
----------
string : str
if an exact for this is included in the log message, the log record
is suppressed
"""
def __init__(self, string):
self.string = string
def filter(self, record):
return self.string not in record.msg

View File

@@ -56,7 +56,7 @@ def quickrun(transformation, work_dir, output):
from gufe.tokenization import JSON_HANDLER
from gufe.transformations.transformation import Transformation
from openfe.utils.logging_filter import MsgIncludesStringFilter
from openfe.utils import logging_control
# avoid problems with output not showing if queueing system kills a job
sys.stdout.reconfigure(line_buffering=True)
@@ -68,15 +68,16 @@ def quickrun(transformation, work_dir, output):
configure_logger("openfe", handler=stdout_handler)
# silence the openmmtools.multistate API warning
stfu = MsgIncludesStringFilter(
"The openmmtools.multistate API is experimental and may change in future releases"
logging_control._silence_message(
msg=[
"The openmmtools.multistate API is experimental and may change in future releases",
],
logger_names=[
"openmmtools.multistate.multistatereporter",
"openmmtools.multistate.multistateanalyzer",
"openmmtools.multistate.multistatesampler",
],
)
omm_multistate = "openmmtools.multistate"
modules = ["multistatereporter", "multistateanalyzer", "multistatesampler"]
for module in modules:
ms_log = logging.getLogger(omm_multistate + "." + module)
ms_log.addFilter(stfu)
# turn warnings into log message (don't show stack trace)
logging.captureWarnings(True)