Repository: biolab/orange3

Issue:
Warnings can get mixed up between widgets
I have a specific configuration where I get an unexpected read warning in the File widget.

To reproduce:
1. File (heart disease)
2. Test & Score, LR as learner
3. Set LOO in T&S.
4. Let T&S run a bit, then change the data set to iris.
5. Read warning occurs.

```
lbfgs failed to converge (status=1):
STOP: TOTAL NO. of ITERATIONS REACHED LIMIT.

Increase the number of iterations (max_iter) or scale the data as shown in:
    https://scikit-learn.org/stable/modules/preprocessing.html
Please also refer to the documentation for alternative solver options:
    https://scikit-learn.org/stable/modules/linear_model.html#logistic-regression
```

Probably something that should be handled more gracefully.


Discussion:
Seeing this bug was a consequence of two things:
- frequent warnings, generated by LOO and Logistic Regression, which, prior to #5027, showed warnings very frequently.
- a non-threadsafe warning handler (`catch_warnings`) in OWFIle

So a warning was shown by training within Test&Score was caught by OWFile. Which means that we probably need to handle warnings differently.

After #5027 is merged, this will be harder to reproduce. To reproduce it afterwards, set `max_iter=100` in `owlogisticregression.py`, make a workflow as instructed by @ajdapretnar and keep changing datasets until you get lucky.

Resolving patch:
diff --git a/Orange/util.py b/Orange/util.py
--- a/Orange/util.py
+++ b/Orange/util.py
@@ -2,6 +2,8 @@
 import logging
 import os
 import inspect
+from contextlib import contextmanager
+
 import pkg_resources
 from enum import Enum as _Enum
 from functools import wraps, partial
@@ -35,6 +37,69 @@ class OrangeDeprecationWarning(OrangeWarning, DeprecationWarning):
     warnings.simplefilter('error', OrangeDeprecationWarning)
 
 
+def _log_warning(msg):
+    """
+    Replacement for `warnings._showwarnmsg_impl` that logs the warning
+    Logs the warning in the appropriate list, or passes it to the original
+    function if the warning wasn't issued within the log_warnings context.
+    """
+    for frame in inspect.stack():
+        if frame.frame in warning_loggers:
+            warning_loggers[frame.frame].append(msg)
+            break
+    else:
+        __orig_showwarnmsg_impl(msg)
+
+
+@contextmanager
+def log_warnings():
+    """
+    logs all warnings that occur within context, including warnings from calls.
+
+    ```python
+    with log_warnings() as warnings:
+       ...
+    ```
+
+    Unlike `warnings.catch_warnings(record=True)`, this manager is thread-safe
+    and will only log warning from this thread. It does so by storing the
+    stack frame within which the context is created, and then checking the
+    stack when the warning is issued.
+
+    Nesting of `log_warnings` within the same function will raise an error.
+    If `log_wanings` are nested within function calls, the warning is logged
+    in the inner-most context.
+
+    If `catch_warnings` is used within the `log_warnings` context, logging is
+    disabled until the `catch_warnings` exits. This looks inevitable (without
+    patching `catch_warnings`, which I'd prefer not to do).
+
+    If `catch_warnings` is used outside this context, everything, including
+    warning filtering, should work as expected.
+
+    Note: the method imitates `catch_warnings` by patching the `warnings`
+    module's internal function `_showwarnmsg_impl`. Python (as of version 3.9)
+    doesn't seem to offer any other way of catching the warnings. This function
+    was introduced in Python 3.6, so we cover all supported versions. If it is
+    ever removed, unittests will crash, so we'll know. :)
+    """
+    # currentframe().f_back is `contextmanager`'s __enter__
+    frame = inspect.currentframe().f_back.f_back
+    if frame in warning_loggers:
+        raise ValueError("nested log_warnings")
+    try:
+        warning_loggers[frame] = []
+        yield warning_loggers[frame]
+    finally:
+        del warning_loggers[frame]
+
+
+# pylint: disable=protected-access
+warning_loggers = {}
+__orig_showwarnmsg_impl = warnings._showwarnmsg_impl
+warnings._showwarnmsg_impl = _log_warning
+
+
 def resource_filename(path):
     """
     Return the resource filename path relative to the Orange package.
diff --git a/Orange/widgets/data/owfile.py b/Orange/widgets/data/owfile.py
--- a/Orange/widgets/data/owfile.py
+++ b/Orange/widgets/data/owfile.py
@@ -1,7 +1,6 @@
 import os
 import logging
 from itertools import chain
-from warnings import catch_warnings
 from urllib.parse import urlparse
 from typing import List
 
@@ -13,6 +12,7 @@
 
 from Orange.data.table import Table, get_sample_datasets_dir
 from Orange.data.io import FileFormat, UrlReader, class_from_qualified_name
+from Orange.util import log_warnings
 from Orange.widgets import widget, gui
 from Orange.widgets.settings import Setting, ContextSetting, \
     PerfectDomainContextHandler, SettingProvider
@@ -366,7 +366,7 @@ def _try_load(self):
         except Exception:
             return self.Error.sheet_error
 
-        with catch_warnings(record=True) as warnings:
+        with log_warnings() as warnings:
             try:
                 data = self.reader.read()
             except Exception as ex:


Test patch:
diff --git a/Orange/widgets/data/tests/test_owfile.py b/Orange/widgets/data/tests/test_owfile.py
--- a/Orange/widgets/data/tests/test_owfile.py
+++ b/Orange/widgets/data/tests/test_owfile.py
@@ -1,16 +1,18 @@
 # Test methods with long descriptive names can omit docstrings
-# pylint: disable=missing-docstring
+# pylint: disable=missing-docstring,protected-access
 from os import path, remove, getcwd
 from os.path import dirname
+import unittest
 from unittest.mock import Mock, patch
 import pickle
 import tempfile
 import warnings
+import time
 
 import numpy as np
 import scipy.sparse as sp
 
-from AnyQt.QtCore import QMimeData, QPoint, Qt, QUrl
+from AnyQt.QtCore import QMimeData, QPoint, Qt, QUrl, QThread, QObject
 from AnyQt.QtGui import QDragEnterEvent, QDropEvent
 from AnyQt.QtWidgets import QComboBox
 
@@ -622,3 +624,56 @@ def test_sheets(self):
         widget.reader.sheet = "no such sheet"
         widget._select_active_sheet()
         self.assertEqual(combo.currentIndex(), 0)
+
+    @patch("os.path.exists", new=lambda _: True)
+    def test_warning_from_another_thread(self):
+        class AnotherWidget(QObject):
+            # This must be a method, not a staticmethod to run in the thread
+            def issue_warning(self):  # pylint: disable=no-self-use
+                time.sleep(0.1)
+                warnings.warn("warning from another thread")
+                warning_thread.quit()
+
+        def read():
+            warning_thread.start()
+            time.sleep(0.2)
+            return Table(TITANIC_PATH)
+
+        warning_thread = QThread()
+        another_widget = AnotherWidget()
+        another_widget.moveToThread(warning_thread)
+        warning_thread.started.connect(another_widget.issue_warning)
+
+        reader = Mock()
+        reader.read = read
+        self.widget._get_reader = lambda: reader
+        self.widget.last_path = lambda: "foo"
+        self.widget._update_sheet_combo = Mock()
+
+        # Warning must be caught by unit tests, but not the widget
+        with self.assertWarns(UserWarning):
+            self.widget._try_load()
+            self.assertFalse(self.widget.Warning.load_warning.is_shown())
+
+
+    @patch("os.path.exists", new=lambda _: True)
+    def test_warning_from_this_thread(self):
+        WARNING_MSG = "warning from this thread"
+
+        def read():
+            warnings.warn(WARNING_MSG)
+            return Table(TITANIC_PATH)
+
+        reader = Mock()
+        reader.read = read
+        self.widget._get_reader = lambda: reader
+        self.widget.last_path = lambda: "foo"
+        self.widget._update_sheet_combo = Mock()
+
+        self.widget._try_load()
+        self.assertTrue(self.widget.Warning.load_warning.is_shown())
+        self.assertIn(WARNING_MSG, str(self.widget.Warning.load_warning))
+
+
+if __name__ == "__main__":
+    unittest.main()