From 68756265e2fb857df77850af6e839b6644a5f692 Mon Sep 17 00:00:00 2001 From: jungwinter Date: Fri, 29 Dec 2017 19:08:24 +0900 Subject: [PATCH 1/6] Implement halo_notebook prototype --- halo/__init__.py | 1 + halo/halo_notebook.py | 86 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 halo/halo_notebook.py diff --git a/halo/__init__.py b/halo/__init__.py index 7d8dd01..e9d67d9 100644 --- a/halo/__init__.py +++ b/halo/__init__.py @@ -5,5 +5,6 @@ import logging from .halo import Halo +from .halo_notebook import HaloNotebook logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/halo/halo_notebook.py b/halo/halo_notebook.py new file mode 100644 index 0000000..29dc9f0 --- /dev/null +++ b/halo/halo_notebook.py @@ -0,0 +1,86 @@ +from __future__ import unicode_literals, absolute_import, print_function + +import threading +import cursor +from halo import Halo +from halo._utils import decode_utf_8_text +from ipywidgets.widgets import Output +from IPython.display import display + + +class HaloNotebook(Halo): + CLEAR_LINE = '\033[K' + + def __init__(self, text='', color='cyan', spinner=None, animation=None, interval=-1, enabled=True, stream=None): + + super(HaloNotebook, self).__init__(text, color, spinner, animation, interval, enabled, stream) + self.output = self._make_output_widget() + + def _make_output_widget(self): + return Output() + + # TODO: using property and setter + def _output(self, text=''): + return ({'name': 'stdout', 'output_type': 'stream', 'text': text},) + + def clear(self): + if not self._enabled: + return self + + with self.output: + self.output.outputs += self._output('\r') + self.output.outputs += self._output(self.CLEAR_LINE) + + self.output.outputs = self._output() + return self + + def _render_frame(self): + frame = self.frame() + output = '\r{0}'.format(frame) + with self.output: + self.output.outputs += self._output(output) + + def start(self, text=None): + if text is not None: + self._text = self._get_text(text, animation=None) + + if not self._enabled or self._spinner_id is not None: + return self + + if self._stream.isatty(): + cursor.hide() + + display(self.output) + self._stop_spinner = threading.Event() + self._spinner_thread = threading.Thread(target=self.render) + self._spinner_thread.setDaemon(True) + self._render_frame() + self._spinner_id = self._spinner_thread.name + self._spinner_thread.start() + + return self + + def stop_and_persist(self, options={}): + if type(options) is not dict: + raise TypeError('Options passed must be a dictionary') + + if 'symbol' in options and options['symbol'] is not None: + symbol = decode_utf_8_text(options['symbol']) + else: + symbol = ' ' + + if 'text' in options and options['text'] is not None: + text = decode_utf_8_text(options['text']) + else: + text = self._text['original'] + + text = text.strip() + + self.stop() + + output = '\r{0} {1}\n'.format(symbol, text) + + with self.output: + self.output.outputs = self._output(output) + + self.output = self._make_output_widget() From b97539a9871ba069a07f4b8c8131c11e560e0069 Mon Sep 17 00:00:00 2001 From: jungwinter Date: Fri, 29 Dec 2017 19:09:05 +0900 Subject: [PATCH 2/6] Add notebook example about HaloNotebook --- examples/notebook.ipynb | 228 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 examples/notebook.ipynb diff --git a/examples/notebook.ipynb b/examples/notebook.ipynb new file mode 100644 index 0000000..00a6f92 --- /dev/null +++ b/examples/notebook.ipynb @@ -0,0 +1,228 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import time\n", + "\n", + "os.sys.path.append(os.path.dirname(os.path.abspath('./')))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# HaloNotebook" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from halo import HaloNotebook as Halo" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test example codes\n", + "\n", + "This frontend (for example, a static rendering on GitHub or NBViewer) doesn't currently support widgets. If you wonder results, run notebook manually.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `persist_spin.py`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "success_message = 'Loading success'\n", + "failed_message = 'Loading failed'\n", + "unicorn_message = 'Loading unicorn'\n", + "\n", + "spinner = Halo(text=success_message, spinner='dots')\n", + "\n", + "try:\n", + " spinner.start()\n", + " time.sleep(1)\n", + " spinner.succeed()\n", + " spinner.start(failed_message)\n", + " time.sleep(1)\n", + " spinner.fail()\n", + " spinner.start(unicorn_message)\n", + " time.sleep(1)\n", + " spinner.stop_and_persist({'symbol': '๐Ÿฆ„'.encode('utf-8'), 'text': unicorn_message})\n", + "except (KeyboardInterrupt, SystemExit):\n", + " spinner.stop()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `long_text.py`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spinner = Halo(text='This is a text that it is too long. In fact, it exceeds the eighty column standard '\n", + " 'terminal width, which forces the text frame renderer to add an ellipse at the end of the '\n", + " 'text. This should definitely make it more than 180!', spinner='dots', animation='marquee')\n", + "\n", + "try:\n", + " spinner.start()\n", + " time.sleep(5)\n", + " spinner.succeed('End!')\n", + "except (KeyboardInterrupt, SystemExit):\n", + " spinner.stop()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `loader_spin.py`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "spinner = Halo(text='Downloading dataset.zip', spinner='dots')\n", + "\n", + "try:\n", + " spinner.start()\n", + " for i in range(100):\n", + " spinner.text = '{0}% Downloaded dataset.zip'.format(i)\n", + " time.sleep(0.05)\n", + " spinner.succeed('Downloaded dataset.zip')\n", + "except (KeyboardInterrupt, SystemExit):\n", + " spinner.stop()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `doge_spin.py`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spinner = Halo(text='Such Spins', spinner='dots')\n", + "\n", + "try:\n", + " spinner.start()\n", + " time.sleep(1)\n", + " spinner.text = 'Much Colors'\n", + " spinner.color = 'magenta'\n", + " time.sleep(1)\n", + " spinner.text = 'Very emojis'\n", + " spinner.spinner = 'hearts'\n", + " time.sleep(1)\n", + " spinner.stop_and_persist({'symbol': '๐Ÿฆ„ '.encode('utf-8'), 'text': 'Wow!'})\n", + "except (KeyboardInterrupt, SystemExit):\n", + " spinner.stop()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `custom_spins.py`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spinner = Halo(\n", + " text='Custom Spins',\n", + " spinner={\n", + " 'interval': 100,\n", + " 'frames': ['-', '+', '*', '+', '-']\n", + " }\n", + ")\n", + "\n", + "try:\n", + " spinner.start()\n", + " time.sleep(2)\n", + " spinner.succeed('It works!')\n", + "except (KeyboardInterrupt, SystemExit):\n", + " spinner.stop()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `context_manager.py`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with Halo(text='Loading', spinner='dots'):\n", + " # Run time consuming work here\n", + " time.sleep(2)\n", + "\n", + "with Halo(text='Loading 2', spinner='dots') as spinner:\n", + " # Run time consuming work here\n", + " time.sleep(2)\n", + " spinner.succeed('Done!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.1" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From ce454962b9b4f18733374dd11fc205dfe79c3830 Mon Sep 17 00:00:00 2001 From: jungwinter Date: Fri, 5 Jan 2018 22:30:59 +0900 Subject: [PATCH 3/6] Exclude notebook from coverage Signed-off-by: jungwinter --- .coveragerc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.coveragerc b/.coveragerc index 013dd20..55714f5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,5 @@ +[run] +omit = + halo/halo_notebook.py [report] show_missing = True From 2ee52b8b6a9d8bfb61f5a09bf94c7af2d24e8268 Mon Sep 17 00:00:00 2001 From: jungwinter Date: Fri, 5 Jan 2018 22:32:04 +0900 Subject: [PATCH 4/6] Requirements: Add ipywidgets library Signed-off-by: jungwinter --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 7122831..819b0c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ cursor==1.2.0 termcolor==1.1.0 colorama==0.3.9 six==1.11.0 +ipywidgets==7.1.0 From d72779a3d412eabffc5fc7f65b16e3338eb3e635 Mon Sep 17 00:00:00 2001 From: jungwinter Date: Tue, 16 Jan 2018 13:55:03 +0900 Subject: [PATCH 5/6] Change widget initializing position Signed-off-by: jungwinter Before, the initializing position was in the `stop_and_persist` function. So we could not initialize `Output` widget with `stop` function. After Changing position to `start` function, we can expect more consistent behavior. --- halo/halo_notebook.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/halo/halo_notebook.py b/halo/halo_notebook.py index 29dc9f0..46f36b9 100644 --- a/halo/halo_notebook.py +++ b/halo/halo_notebook.py @@ -50,6 +50,7 @@ def start(self, text=None): if self._stream.isatty(): cursor.hide() + self.output = self._make_output_widget() display(self.output) self._stop_spinner = threading.Event() self._spinner_thread = threading.Thread(target=self.render) @@ -82,5 +83,3 @@ def stop_and_persist(self, options={}): with self.output: self.output.outputs = self._output(output) - - self.output = self._make_output_widget() From 7c95ad693b330e1fbde35134a04ec1d5e917162c Mon Sep 17 00:00:00 2001 From: jungwinter Date: Tue, 16 Jan 2018 14:04:39 +0900 Subject: [PATCH 6/6] Add testcase about HaloNotebook --- .coveragerc | 3 - tests/test_halo_notebook.py | 353 ++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 tests/test_halo_notebook.py diff --git a/.coveragerc b/.coveragerc index 55714f5..013dd20 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,2 @@ -[run] -omit = - halo/halo_notebook.py [report] show_missing = True diff --git a/tests/test_halo_notebook.py b/tests/test_halo_notebook.py new file mode 100644 index 0000000..a7ee0d3 --- /dev/null +++ b/tests/test_halo_notebook.py @@ -0,0 +1,353 @@ +# -*- coding: utf-8 -*- +"""This module tests HaloNotebook spinners. +""" +import re +import unittest +import time +import sys +import logging +import os + +from spinners.spinners import Spinners + +from tests._utils import strip_ansi, encode_utf_8_text, decode_utf_8_text +from halo import HaloNotebook +from halo._utils import is_supported, get_terminal_columns + +if sys.version_info.major == 2: + get_coded_text = encode_utf_8_text +else: + get_coded_text = decode_utf_8_text + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s:%(levelname)s:%(message)s" +) + +if is_supported(): + frames = [get_coded_text(frame) for frame in Spinners['dots'].value['frames']] + default_spinner = Spinners['dots'].value +else: + frames = [get_coded_text(frame) for frame in Spinners['line'].value['frames']] + default_spinner = Spinners['line'].value + + +class TestHaloNotebook(unittest.TestCase): + """Test HaloNotebook enum for attribute values. + """ + TEST_FOLDER = os.path.dirname(os.path.abspath(__file__)) + + def setUp(self): + """Set up things before beginning of each test. + """ + pass + + def _get_test_output(self, spinner): + """Clean the output from Output widget and return it in list form. + + Returns + ------- + list + Clean output from Output widget + """ + output = [] + + for line in spinner.output.outputs: + clean_line = strip_ansi(line['text'].strip('\r')) + if clean_line != '': + output.append(get_coded_text(clean_line)) + + return output + + def test_basic_spinner(self): + """Test the basic of basic spinners. + """ + spinner = HaloNotebook(text='foo', spinner='dots') + + spinner.start() + time.sleep(1) + output = self._get_test_output(spinner) + spinner.stop() + + self.assertEqual(output[0], '{0} foo'.format(frames[0])) + self.assertEqual(output[1], '{0} foo'.format(frames[1])) + self.assertEqual(output[2], '{0} foo'.format(frames[2])) + self.assertEqual(spinner.output.outputs, spinner._output('')) + + def test_text_stripping(self): + """Test the text being stripped before output. + """ + spinner = HaloNotebook(text='foo\n', spinner='dots') + + spinner.start() + time.sleep(1) + output = self._get_test_output(spinner) + + self.assertEqual(output[0], '{0} foo'.format(frames[0])) + self.assertEqual(output[1], '{0} foo'.format(frames[1])) + self.assertEqual(output[2], '{0} foo'.format(frames[2])) + + spinner.succeed('foo\n') + output = self._get_test_output(spinner) + + pattern = re.compile(r'(โœ”|v) foo', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + + def test_text_ellipsing(self): + """Test the text gets ellipsed if it's too long + """ + text = 'This is a text that it is too long. In fact, it exceeds the eighty column standard ' \ + 'terminal width, which forces the text frame renderer to add an ellipse at the end of the ' \ + 'text. ' * 6 + spinner = HaloNotebook(text=text, spinner='dots') + + spinner.start() + time.sleep(1) + output = self._get_test_output(spinner) + + terminal_width = get_terminal_columns() + + # -6 of the ' (...)' ellipsis, -2 of the spinner and space + self.assertEqual(output[0], '{0} {1} (...)'.format(frames[0], text[:terminal_width - 6 - 2])) + self.assertEqual(output[1], '{0} {1} (...)'.format(frames[1], text[:terminal_width - 6 - 2])) + self.assertEqual(output[2], '{0} {1} (...)'.format(frames[2], text[:terminal_width - 6 - 2])) + + spinner.succeed('End!') + output = self._get_test_output(spinner) + + pattern = re.compile(r'(โœ”|v) End!', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + + def test_text_animation(self): + """Test the text gets animated when it is too long + """ + text = 'This is a text that it is too long. In fact, it exceeds the eighty column standard ' \ + 'terminal width, which forces the text frame renderer to add an ellipse at the end of the ' \ + 'text. ' * 6 + spinner = HaloNotebook(text=text, spinner='dots', animation='marquee') + + spinner.start() + time.sleep(1) + output = self._get_test_output(spinner) + + terminal_width = get_terminal_columns() + + self.assertEqual(output[0], '{0} {1}'.format(frames[0], text[:terminal_width - 2])) + self.assertEqual(output[1], '{0} {1}'.format(frames[1], text[1:terminal_width - 1])) + self.assertEqual(output[2], '{0} {1}'.format(frames[2], text[2:terminal_width])) + + spinner.succeed('End!') + output = self._get_test_output(spinner) + + pattern = re.compile(r'(โœ”|v) End!', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + + def test_context_manager(self): + """Test the basic of basic spinners used through the with statement. + """ + with HaloNotebook(text='foo', spinner='dots') as spinner: + time.sleep(1) + output = self._get_test_output(spinner) + + self.assertEqual(output[0], '{0} foo'.format(frames[0])) + self.assertEqual(output[1], '{0} foo'.format(frames[1])) + self.assertEqual(output[2], '{0} foo'.format(frames[2])) + self.assertEqual(spinner.output.outputs, spinner._output('')) + + def test_decorator_spinner(self): + """Test basic usage of spinners with the decorator syntax.""" + + @HaloNotebook(text="foo", spinner="dots") + def decorated_function(): + time.sleep(1) + + spinner = decorated_function.__closure__[1].cell_contents + output = self._get_test_output(spinner) + return output + + output = decorated_function() + + self.assertEqual(output[0], '{0} foo'.format(frames[0])) + self.assertEqual(output[1], '{0} foo'.format(frames[1])) + self.assertEqual(output[2], '{0} foo'.format(frames[2])) + + def test_initial_title_spinner(self): + """Test Halo with initial title. + """ + spinner = HaloNotebook('bar') + + spinner.start() + time.sleep(1) + output = self._get_test_output(spinner) + spinner.stop() + + self.assertEqual(output[0], '{0} bar'.format(frames[0])) + self.assertEqual(output[1], '{0} bar'.format(frames[1])) + self.assertEqual(output[2], '{0} bar'.format(frames[2])) + self.assertEqual(spinner.output.outputs, spinner._output('')) + + def test_id_not_created_before_start(self): + """Test Spinner ID not created before start. + """ + spinner = HaloNotebook() + self.assertEqual(spinner.spinner_id, None) + + def test_ignore_multiple_start_calls(self): + """Test ignoring of multiple start calls. + """ + spinner = HaloNotebook() + spinner.start() + spinner_id = spinner.spinner_id + spinner.start() + self.assertEqual(spinner.spinner_id, spinner_id) + spinner.stop() + + def test_chaining_start(self): + """Test chaining start with constructor + """ + spinner = HaloNotebook().start() + spinner_id = spinner.spinner_id + self.assertIsNotNone(spinner_id) + spinner.stop() + + def test_succeed(self): + """Test succeed method + """ + spinner = HaloNotebook() + spinner.start('foo') + spinner.succeed('foo') + + output = self._get_test_output(spinner) + pattern = re.compile(r'(โœ”|v) foo', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + spinner.stop() + + def test_succeed_with_new_text(self): + """Test succeed method with new text + """ + spinner = HaloNotebook() + spinner.start('foo') + spinner.succeed('bar') + + output = self._get_test_output(spinner) + pattern = re.compile(r'(โœ”|v) bar', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + spinner.stop() + + def test_info(self): + """Test info method + """ + spinner = HaloNotebook() + spinner.start('foo') + spinner.info() + + output = self._get_test_output(spinner) + pattern = re.compile(r'(โ„น|ยก) foo', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + spinner.stop() + + def test_fail(self): + """Test fail method + """ + spinner = HaloNotebook() + spinner.start('foo') + spinner.fail() + + output = self._get_test_output(spinner) + pattern = re.compile(r'(โœ–|ร—) foo', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + spinner.stop() + + def test_warning(self): + """Test warn method + """ + spinner = HaloNotebook() + spinner.start('foo') + spinner.warn('Warning!') + + output = self._get_test_output(spinner) + pattern = re.compile(r'(โš |!!) Warning!', re.UNICODE) + + self.assertRegexpMatches(output[-1], pattern) + spinner.stop() + + def test_spinner_getters_setters(self): + """Test spinner getters and setters. + """ + spinner = HaloNotebook() + self.assertEqual(spinner.text, '') + self.assertEqual(spinner.color, 'cyan') + self.assertIsNone(spinner.spinner_id) + + spinner.spinner = 'dots12' + spinner.text = 'bar' + spinner.color = 'red' + + self.assertEqual(spinner.text, 'bar') + self.assertEqual(spinner.color, 'red') + + if is_supported(): + self.assertEqual(spinner.spinner, Spinners['dots12'].value) + else: + self.assertEqual(spinner.spinner, default_spinner) + + spinner.spinner = 'dots11' + if is_supported(): + self.assertEqual(spinner.spinner, Spinners['dots11'].value) + else: + self.assertEqual(spinner.spinner, default_spinner) + + spinner.spinner = 'foo_bar' + self.assertEqual(spinner.spinner, default_spinner) + + # Color is None + spinner.color = None + spinner.start() + spinner.stop() + self.assertIsNone(spinner.color) + + def test_unavailable_spinner_defaults(self): + """Test unavailable spinner defaults. + """ + spinner = HaloNotebook('dot') + + self.assertEqual(spinner.text, 'dot') + self.assertEqual(spinner.spinner, default_spinner) + + def test_if_enabled(self): + """Test if spinner is enabled + """ + spinner = HaloNotebook(text="foo", enabled=False) + spinner.start() + time.sleep(1) + output = self._get_test_output(spinner) + spinner.clear() + spinner.stop() + + self.assertEqual(len(output), 0) + self.assertEqual(output, []) + + def test_stop_and_persist_no_dict_or_options(self): + """Test if options is not dict or required options in stop_and_persist. + """ + with self.assertRaises(TypeError): + spinner = HaloNotebook() + spinner.start() + spinner.stop_and_persist('not dict') + + def tearDown(self): + """Clean up things after every test. + """ + pass + + +if __name__ == '__main__': + SUITE = unittest.TestLoader().loadTestsFromTestCase(TestHaloNotebook) + unittest.TextTestRunner(verbosity=2).run(SUITE)