From 9577442a0ae74c639d335e7f36dff55a4d621549 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 28 Oct 2020 16:32:04 +0800 Subject: [PATCH 1/7] Support multiple spinners running in parellel --- halo/halo.py | 84 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/halo/halo.py b/halo/halo.py index 9e10b66..476de1b 100644 --- a/halo/halo.py +++ b/halo/halo.py @@ -35,11 +35,16 @@ class Halo(object): """ CLEAR_LINE = "\033[K" + CLEAR_REST = "\033[J" SPINNER_PLACEMENTS = ( "left", "right", ) + # a global list to keep all Halo instances + _instances = [] + _lock = threading.Lock() + def __init__( self, text="", @@ -96,6 +101,8 @@ def __init__( self._stop_spinner = None self._spinner_id = None self.enabled = enabled + self._stopped = False + self._content = "" environment = get_environment() @@ -294,6 +301,33 @@ def _check_stream(self): return True + def _pop_stream_content_until_self(self, clear_self=False): + """Move cursor to the end of this instance's content and erase all contents + following it. + Parameters + ---------- + clear_self: bool + If equals True, the content of current line will also get cleared + Returns + ------- + str + The content of stream following this instance. + """ + erased_content = [] + lines_to_erase = self._content.count("\n") if clear_self else 0 + for inst in Halo._instances[::-1]: + if inst is self: + break + erased_content.append(inst._content) + lines_to_erase += inst._content.count("\n") + + if lines_to_erase > 0: + # Move cursor up n lines + self._write("\033[{}A".format(lines_to_erase)) + # Erase rest content + self._write(self.CLEAR_REST) + return "".join(reversed(erased_content)) + def _write(self, s): """Write to the stream, if writable Parameters @@ -304,15 +338,27 @@ def _write(self, s): if self._check_stream(): self._stream.write(s) - def _hide_cursor(self): - """Disable the user's blinking cursor + def write(self, s): + """Write to the stream and keep following lines unchanged. + Parameters + ---------- + s : str + Characters to write to the stream """ + with Halo._lock: + erased_content = self._pop_stream_content_until_self() + self._write(s) + # Write back following lines + self._write(erased_content) + self._content += s + + def _hide_cursor(self): + """Disable the user's blinking cursor""" if self._check_stream() and self._stream.isatty(): cursor.hide(stream=self._stream) def _show_cursor(self): - """Re-enable the user's blinking cursor - """ + """Re-enable the user's blinking cursor""" if self._check_stream() and self._stream.isatty(): cursor.show(stream=self._stream) @@ -390,13 +436,14 @@ def clear(self): ------- self """ - self._write("\r") - self._write(self.CLEAR_LINE) + with Halo._lock: + erased_content = self._pop_stream_content_until_self(True) + self._content = "" + self._write(erased_content) return self def _render_frame(self): - """Renders the frame on the line after clearing it. - """ + """Renders the frame on the line after clearing it.""" if not self.enabled: # in case we're disabled or stream is closed while still rendering, # we render the frame and increment the frame index, so the proper @@ -405,11 +452,11 @@ def _render_frame(self): self.clear() frame = self.frame() - output = "\r{}".format(frame) + output = "\r{}\n".format(frame) try: - self._write(output) + self.write(output) except UnicodeEncodeError: - self._write(encode_utf_8_text(output)) + self.write(encode_utf_8_text(output)) def render(self): """Runs the render until thread flag is set. @@ -490,6 +537,12 @@ def start(self, text=None): if not (self.enabled and self._check_stream()): return self + # Clear all stale Halo instances created before + # Check against Halo._instances instead of self._instances + # to avoid possible overriding in subclasses. + if all(inst._stopped for inst in Halo._instances): + Halo._instances[:] = [] + Halo._instances.append(self) self._hide_cursor() self._stop_spinner = threading.Event() @@ -511,12 +564,17 @@ def stop(self): self._stop_spinner.set() self._spinner_thread.join() + if self._stopped: + return + if self.enabled: self.clear() self._frame_index = 0 self._spinner_id = None self._show_cursor() + self._stopped = True + return self def succeed(self, text=None): @@ -602,8 +660,8 @@ def stop_and_persist(self, symbol=" ", text=None): ) try: - self._write(output) + self.write(output) except UnicodeEncodeError: - self._write(encode_utf_8_text(output)) + self.write(encode_utf_8_text(output)) return self From fb8ce9ccdc64f125dd803575a98f69eeafc4d947 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 28 Oct 2020 17:24:04 +0800 Subject: [PATCH 2/7] Allow for calling start() multiple times --- halo/halo.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/halo/halo.py b/halo/halo.py index 476de1b..cd95816 100644 --- a/halo/halo.py +++ b/halo/halo.py @@ -542,7 +542,9 @@ def start(self, text=None): # to avoid possible overriding in subclasses. if all(inst._stopped for inst in Halo._instances): Halo._instances[:] = [] - Halo._instances.append(self) + if self not in Halo._instances: + # Allow for calling start() multiple times + Halo._instances.append(self) self._hide_cursor() self._stop_spinner = threading.Event() From 8803a33bd7c985a6a93cda0e9c90f5477e8fd0c8 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 28 Oct 2020 17:29:50 +0800 Subject: [PATCH 3/7] reduce function calls --- halo/halo.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/halo/halo.py b/halo/halo.py index cd95816..f7b942e 100644 --- a/halo/halo.py +++ b/halo/halo.py @@ -338,15 +338,17 @@ def _write(self, s): if self._check_stream(): self._stream.write(s) - def write(self, s): + def write(self, s, overwrite=False): """Write to the stream and keep following lines unchanged. Parameters ---------- s : str Characters to write to the stream + overwrite: bool + If set to True, overwrite the content of current instance. """ with Halo._lock: - erased_content = self._pop_stream_content_until_self() + erased_content = self._pop_stream_content_until_self(overwrite) self._write(s) # Write back following lines self._write(erased_content) @@ -450,13 +452,12 @@ def _render_frame(self): # frame is rendered if we're reenabled or the stream opens again. return - self.clear() frame = self.frame() output = "\r{}\n".format(frame) try: - self.write(output) + self.write(output, True) except UnicodeEncodeError: - self.write(encode_utf_8_text(output)) + self.write(encode_utf_8_text(output), True) def render(self): """Runs the render until thread flag is set. From dc26daff185f0bcb54ced6bcdb26a68d3193cc76 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 28 Oct 2020 17:40:45 +0800 Subject: [PATCH 4/7] Fix write method --- halo/halo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/halo/halo.py b/halo/halo.py index f7b942e..a051992 100644 --- a/halo/halo.py +++ b/halo/halo.py @@ -352,7 +352,7 @@ def write(self, s, overwrite=False): self._write(s) # Write back following lines self._write(erased_content) - self._content += s + self._content = s if overwrite else self._content + s def _hide_cursor(self): """Disable the user's blinking cursor""" From d666956c27c3468d9b552f2521b3021bad2f55fb Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 29 Oct 2020 18:07:41 +0800 Subject: [PATCH 5/7] Don't expose the internal method --- halo/halo.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/halo/halo.py b/halo/halo.py index a051992..d1786c6 100644 --- a/halo/halo.py +++ b/halo/halo.py @@ -323,12 +323,12 @@ def _pop_stream_content_until_self(self, clear_self=False): if lines_to_erase > 0: # Move cursor up n lines - self._write("\033[{}A".format(lines_to_erase)) + self._write_stream("\033[{}A".format(lines_to_erase)) # Erase rest content - self._write(self.CLEAR_REST) + self._write_stream(self.CLEAR_REST) return "".join(reversed(erased_content)) - def _write(self, s): + def _write_stream(self, s): """Write to the stream, if writable Parameters ---------- @@ -338,7 +338,7 @@ def _write(self, s): if self._check_stream(): self._stream.write(s) - def write(self, s, overwrite=False): + def _write(self, s, overwrite=False): """Write to the stream and keep following lines unchanged. Parameters ---------- @@ -349,9 +349,9 @@ def write(self, s, overwrite=False): """ with Halo._lock: erased_content = self._pop_stream_content_until_self(overwrite) - self._write(s) + self._write_stream(s) # Write back following lines - self._write(erased_content) + self._write_stream(erased_content) self._content = s if overwrite else self._content + s def _hide_cursor(self): @@ -441,7 +441,7 @@ def clear(self): with Halo._lock: erased_content = self._pop_stream_content_until_self(True) self._content = "" - self._write(erased_content) + self._write_stream(erased_content) return self def _render_frame(self): @@ -455,9 +455,9 @@ def _render_frame(self): frame = self.frame() output = "\r{}\n".format(frame) try: - self.write(output, True) + self._write(output, True) except UnicodeEncodeError: - self.write(encode_utf_8_text(output), True) + self._write(encode_utf_8_text(output), True) def render(self): """Runs the render until thread flag is set. @@ -663,8 +663,8 @@ def stop_and_persist(self, symbol=" ", text=None): ) try: - self.write(output) + self._write(output) except UnicodeEncodeError: - self.write(encode_utf_8_text(output)) + self._write(encode_utf_8_text(output)) return self From aaa19049ee93de2a89c22abae49d64d6c18b874b Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 30 Oct 2020 12:33:30 +0800 Subject: [PATCH 6/7] reset self._stopped when start() is called --- halo/halo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/halo/halo.py b/halo/halo.py index d1786c6..fecc883 100644 --- a/halo/halo.py +++ b/halo/halo.py @@ -543,8 +543,8 @@ def start(self, text=None): # to avoid possible overriding in subclasses. if all(inst._stopped for inst in Halo._instances): Halo._instances[:] = [] + # Allow for calling start() multiple times if self not in Halo._instances: - # Allow for calling start() multiple times Halo._instances.append(self) self._hide_cursor() @@ -554,6 +554,7 @@ def start(self, text=None): self._render_frame() self._spinner_id = self._spinner_thread.name self._spinner_thread.start() + self._stopped = False return self From fa4835f87aaea5143978479910a11fcc6034f400 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 30 Oct 2020 14:52:30 +0800 Subject: [PATCH 7/7] Add test case for multiple spinner --- tests/test_halo.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_halo.py b/tests/test_halo.py index 29918aa..17d5785 100644 --- a/tests/test_halo.py +++ b/tests/test_halo.py @@ -622,8 +622,26 @@ def test_redirect_stdout(self): self.assertIn('foo', output[0]) + def test_running_multiple_instances(self): + spinner = Halo("foo", stream=self._stream) + _instance = Halo() + # Pretend that another spinner is being displayed under spinner + _instance._content = "Some lines\n" + Halo._instances.extend([spinner, _instance]) + spinner.start() + time.sleep(1) + spinner.stop() + spinner.stop_and_persist(text="Done") + output = self._get_test_output()["text"] + self.assertEqual(output[0], "{} foo".format(frames[0])) + self.assertEqual(output[1], "Some lines") + self.assertEqual(output[2], "{} foo".format(frames[1])) + self.assertEqual(output[3], "Some lines") + self.assertEqual(output[-2], " Done") + self.assertEqual(output[-1], "Some lines") + def tearDown(self): - pass + self._stream.close() if __name__ == '__main__':