Repository: rsalmei/alive-progress

Issue:
Use with logging module
In my module, I use the python logging module to print out messages.

During the operation which I want to monitor using alive-progress it may happen that messages are sent out using the logging module. Then, artifacts of the bar remain in the console:

```
Loading the fmu took 0.34800148010253906 s.

Starting simulation at t=0.0 s.          | ▁▃▅ 0/1000 [0%] in 0s (0.0/s, eta: ?)
Simulation took 0.7570006847381592 s.███▌| ▅▇▇ 990/1000 [99%] in 0s (1126.2/s, eta: 0s)
Simulation finished at t=1000.0 s.
|████████████████████████████████████████| 1000/1000 [100%] in 0.9s (1096.43/s)
```


The first message ("loading ... took ...") was logged before i instantiated the alive bar. The other messages were emitted while the bar was active.
I am using a vanilla StreamHandler that sends everything to stdout.

By the way - dunno if that's possible - is it possible to have the alive bar redirected to a logging module, too? Might be interesting to have the complete (or not, in case of an error!) progress bar in a log file. Gives structure...

By the way 2: it's pretty awesome :) 

Cheers, Jan

Comments:

Patch:
diff --git a/alive_progress/__init__.py b/alive_progress/__init__.py
index 6e364dd..f0e16c0 100644
--- a/alive_progress/__init__.py
+++ b/alive_progress/__init__.py
@@ -12,7 +12,7 @@ from .core.progress import alive_bar
 from .styles.exhibit import print_chars, show_bars, show_spinners, showtime
 from .styles.internal import BARS, SPINNERS, THEMES
 
-VERSION = (1, 6, 0)
+VERSION = (1, 6, 1)
 
 __author__ = 'Rogério Sampaio de Almeida'
 __email__ = 'rsalmei@gmail.com'
diff --git a/alive_progress/core/logging_hook.py b/alive_progress/core/logging_hook.py
index 1ffa0b4..21570ea 100644
--- a/alive_progress/core/logging_hook.py
+++ b/alive_progress/core/logging_hook.py
@@ -1,12 +1,31 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
 import logging
 import sys
 
 
-def install_logging_hook():  # pragma: no cover
+def install_logging_hook():
     root = logging.root
-    return {h: h.setStream(sys.stdout) for h in root.handlers
-            if isinstance(h, logging.StreamHandler)}
+    return {h: set_stream(h, sys.stdout) for h in root.handlers  # noqa
+            if h.__class__ == logging.StreamHandler}  # want only this, not its subclasses.
+
+
+def uninstall_logging_hook(before):
+    [set_stream(h, s) for h, s in before.items()]
 
 
-def uninstall_logging_hook(before):  # pragma: no cover
-    [h.setStream(s) for h, s in before.items()]
+if sys.version_info >= (3, 7):
+    def set_stream(handler, stream):
+        return handler.setStream(stream)
+else:  # pragma: no cover
+    def set_stream(handler, stream):
+        # from python 3.7 implementation.
+        result = handler.stream
+        handler.acquire()
+        try:
+            handler.flush()
+            handler.stream = stream
+        finally:
+            handler.release()
+        return result
diff --git a/alive_progress/core/progress.py b/alive_progress/core/progress.py
index 3848d16..00f95b9 100644
--- a/alive_progress/core/progress.py
+++ b/alive_progress/core/progress.py
@@ -12,8 +12,8 @@ from itertools import chain, islice, repeat
 from .configuration import config_handler
 from .logging_hook import install_logging_hook, uninstall_logging_hook
 from .timing import gen_simple_exponential_smoothing_eta, to_elapsed_text, to_eta_text
-from .utils import clear_traces, hide_cursor, render_title, sanitize_text, show_cursor, \
-    terminal_columns
+from .utils import clear_traces, hide_cursor, render_title, sanitize_text_marking_wide_chars, \
+    show_cursor, terminal_columns
 from ..animations.utils import spinner_player
 
 
@@ -107,7 +107,7 @@ def alive_bar(total=None, title=None, calibrate=None, **options):
 
         line = ' '.join(filter(None, (
             title, bar_repr(run.percent, end), spin, monitor(), 'in',
-            to_elapsed_text(elapsed, end), run.stats(), run.text)))
+            to_elapsed_text(elapsed, end), stats(), run.text)))
 
         line_len, cols = len(line), terminal_columns()
         with print_lock:
@@ -123,7 +123,7 @@ def alive_bar(total=None, title=None, calibrate=None, **options):
             print()
 
     def set_text(message):
-        run.text = sanitize_text(message)
+        run.text = sanitize_text_marking_wide_chars(message)
 
     if config.manual:
         # FIXME update bar signatures and remove deprecated in v2.
@@ -219,7 +219,6 @@ def alive_bar(total=None, title=None, calibrate=None, **options):
         stats = lambda: spec.format(run.rate, to_eta_text(gen_eta.send((current(), run.rate))))
         bar_repr = config.bar(config.length)
     else:  # unknown progress.
-        eta_text = lambda: None  # noqa
         bar_repr = config.unknown(config.length, config.bar)
         stats = lambda: '({:.1f}/s)'.format(run.rate)  # noqa
     stats_end = lambda: '({:.2{}}/s)'.format(run.rate, rate_spec)  # noqa
@@ -245,9 +244,8 @@ def alive_bar(total=None, title=None, calibrate=None, **options):
             return math.log10((run.rate * adjust_log_curve) + 1.) * factor + min_fps
         return max_fps
 
-    end, run.text, run.eta_text, run.stats = False, '', '', stats
-    run.count, run.last_line_len = 0, 0
-    run.percent, run.rate, run.init = 0., 0., 0.
+    end, run.text, run.last_line_len = False, '', 0
+    run.count, run.percent, run.rate, run.init = 0, 0., 0., 0.
 
     if total:
         if config.manual:
@@ -282,5 +280,5 @@ def alive_bar(total=None, title=None, calibrate=None, **options):
             thread = None  # lets the internal thread terminate gracefully.
             local_copy.join()
 
-        end, run.text, run.stats = True, '', stats_end
+        end, run.text, stats = True, '', stats_end
         alive_repr()
diff --git a/alive_progress/core/utils.py b/alive_progress/core/utils.py
index 771c091..4f6b19e 100644
--- a/alive_progress/core/utils.py
+++ b/alive_progress/core/utils.py
@@ -3,6 +3,10 @@ from __future__ import absolute_import, division, print_function, unicode_litera
 
 import os
 import sys
+import unicodedata
+from itertools import chain
+
+ZWJ = '\u200d'  # zero-width joiner (it's the only one that actually worked on my terminal)
 
 
 def clear_traces():  # pragma: no cover
@@ -20,18 +24,23 @@ def show_cursor():  # pragma: no cover
     sys.__stdout__.write('\033[?25h')
 
 
-def sanitize_text(text):
-    return ' '.join(str(text).split())
+def sanitize_text_marking_wide_chars(text):
+    text = ' '.join((text or '').split())
+    return ''.join(chain.from_iterable(
+        (ZWJ, x) if unicodedata.east_asian_width(x) == 'W' else (x,)
+        for x in text))
 
 
 def render_title(title, length):
     if not title:
         return ''
-    elif not length:
-        return title
     elif length == 1:
         return '…'
 
+    title = sanitize_text_marking_wide_chars(title)
+    if not length:
+        return title
+
     # fixed size left align implementation.
     # there may be more in v2, like other alignments, variable with maximum size, and
     # even scrolling and bouncing.
diff --git a/noxfile.py b/noxfile.py
new file mode 100644
index 0000000..d366a5b
--- /dev/null
+++ b/noxfile.py
@@ -0,0 +1,6 @@
+import nox
+
+@nox.session(python=["2.7", "3.5", "3.6", "3.7", "3.8"])
+def tests(session):
+    session.install('-r', 'requirements/test.txt')
+    session.run('pytest')


Test patch:
diff --git a/requirements/test.txt b/requirements/test.txt
index f2fa609..c6cc811 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -1,5 +1,4 @@
 pytest
 pytest-cov
 pytest-sugar
-pytest-watch
 mock; python_version < '3'
diff --git a/tests/core/test_logging_hook.py b/tests/core/test_logging_hook.py
new file mode 100644
index 0000000..02ea23f
--- /dev/null
+++ b/tests/core/test_logging_hook.py
@@ -0,0 +1,32 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import logging
+import sys
+
+import pytest
+
+from alive_progress.core.logging_hook import install_logging_hook, uninstall_logging_hook
+
+
+@pytest.fixture
+def all_handlers():
+    handler = logging.StreamHandler(sys.stderr)
+    nope = logging.FileHandler('/dev/null', delay=True)
+    [logging.root.addHandler(h) for h in (handler, nope)]
+    yield handler, nope
+    [logging.root.removeHandler(h) for h in (handler, nope)]
+
+
+def test_install(all_handlers):
+    handler, nope = all_handlers
+    changed = install_logging_hook()
+    assert handler.stream == sys.stdout
+    assert nope.stream is None
+    assert changed == {handler: sys.stderr}
+
+
+def test_uninstall(all_handlers):
+    handler, _ = all_handlers
+    uninstall_logging_hook({handler: sys.stderr})
+    assert handler.stream == sys.stderr
diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py
index 07be92f..1620500 100644
--- a/tests/core/test_utils.py
+++ b/tests/core/test_utils.py
@@ -1,22 +1,48 @@
 # coding=utf-8
 from __future__ import absolute_import, division, print_function, unicode_literals
 
+import sys
+
 import pytest
 
-from alive_progress.core.utils import render_title, sanitize_text
+from alive_progress.core.utils import render_title, sanitize_text_marking_wide_chars, ZWJ
 
 
 @pytest.mark.parametrize('text, expected', [
     ('', ''),
-    (None, 'None'),
+    (None, ''),
     ('\n', ''),
+    (' \n ', ''),
+    ('\n \n', ''),
     ('asd\n', 'asd'),
     ('\nasd', 'asd'),
     ('asd1\nasd2', 'asd1 asd2'),
     ('\nasd1\n\n\nasd2\n', 'asd1 asd2'),
 ])
-def test_sanitize_text(text, expected):
-    assert sanitize_text(text) == expected
+def test_sanitize_text_normal_chars(text, expected):
+    result = sanitize_text_marking_wide_chars(text)
+    assert result.replace(ZWJ, 'X') == expected
+
+
+if sys.version_info >= (3, 6):
+    # 2.7 doesn't mark those chars as Wide (ucd 5.2), so this test fails.
+    #   -> but curiously it does work in terminal, since they are encoded with 2 bytes.
+    # 3.5 doesn't mark those chars as Wide either (ucd 8.0), so this test also fails.
+    #   -> and here it doesn't work in terminal, since they are encoded with 1 byte.
+    # more details in https://github.com/rsalmei/alive-progress/issues/19#issuecomment-657120695
+    @pytest.mark.parametrize('text, expected', [
+        ('😺', 'X😺'),
+        ('\n😺', 'X😺'),
+        ('😺 \n 😺', 'X😺 X😺'),
+        ('\n 😺\n😺', 'X😺 X😺'),
+        ('asd😺\n', 'asdX😺'),
+        ('😺\nasd', 'X😺 asd'),
+        ('asd1\nasd2😺', 'asd1 asd2X😺'),
+        ('\nasd1😺\n😺\n\nasd2\n', 'asd1X😺 X😺 asd2'),
+    ])
+    def test_sanitize_text_wide_chars(text, expected):
+        result = sanitize_text_marking_wide_chars(text)
+        assert result.replace(ZWJ, 'X') == expected
 
 
 @pytest.mark.parametrize('text, length, expected', [