diff --git a/doc/manpages/qvm-ls.rst b/doc/manpages/qvm-ls.rst index 7b5e215..02c4272 100644 --- a/doc/manpages/qvm-ls.rst +++ b/doc/manpages/qvm-ls.rst @@ -47,6 +47,14 @@ Options Decrease verbosity. +.. option:: --spinner + + Have a spinner spinning while the spinning mainloop spins new table cells. + +.. option:: --no-spinner + + No spinner today. + Authors ------- | Joanna Rutkowska diff --git a/qubesadmin/spinner.py b/qubesadmin/spinner.py new file mode 100644 index 0000000..bf0e844 --- /dev/null +++ b/qubesadmin/spinner.py @@ -0,0 +1,148 @@ +# vim: fileencoding=utf-8 + +# +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2017 Wojtek Porczyk +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +'''Qubes CLI spinner + +A novice asked the master: “In the east there is a great tree-structure that +men call `Corporate Headquarters'. It is bloated out of shape with vice +presidents and accountants. It issues a multitude of memos, each saying `Go, +Hence!' or `Go, Hither!' and nobody knows what is meant. Every year new names +are put onto the branches, but all to no avail. How can such an unnatural +entity be?" + +The master replied: “You perceive this immense structure and are disturbed that +it has no rational purpose. Can you not take amusement from its endless +gyrations? Do you not enjoy the untroubled ease of programming beneath its +sheltering branches? Why are you bothered by its uselessness?” + +(Geoffrey James, “The Tao of Programming”, 7.1) +''' + +import curses +import io +import itertools + +CHARSET = '-\\|/' +ENTERPRISE_CHARSET = CHARSET * 4 + '-._.-^' * 2 + +class AbstractSpinner(object): + '''The base class for all Spinners + + :param stream: file-like object with ``.write()`` method + :param str charset: the sequence of characters to display + + The spinner should be used as follows: + 1. exactly one call to :py:meth:`show()` + 2. zero or more calls to :py:meth:`update()` + 3. exactly one call to :py:meth:`hide()` + ''' + def __init__(self, stream, charset=CHARSET): + self.stream = stream + self.charset = itertools.cycle(charset) + + def show(self, prompt): + '''Show the spinner, with a prompt + + :param str prompt: prompt, like "please wait" + ''' + raise NotImplementedError() + + def hide(self): + '''Hide the spinner and the prompt''' + raise NotImplementedError() + + def update(self): + '''Show next spinner character''' + raise NotImplementedError() + + +class DummySpinner(AbstractSpinner): + '''Dummy spinner, does not do anything''' + def show(self, prompt): + pass + + def hide(self): + pass + + def update(self): + pass + + +class QubesSpinner(AbstractSpinner): + '''Basic spinner + + This spinner uses standard ASCII control characters''' + def __init__(self, *args, **kwargs): + super(QubesSpinner, self).__init__(*args, **kwargs) + self.hidelen = 0 + self.cub1 = '\b' + + def show(self, prompt): + self.hidelen = len(prompt) + 2 + self.stream.write('{} {}'.format(prompt, next(self.charset))) + self.stream.flush() + + def hide(self): + self.stream.write('\r' + ' ' * self.hidelen + '\r') + self.stream.flush() + + def update(self): + self.stream.write(self.cub1 + next(self.charset)) + self.stream.flush() + + +class QubesSpinnerEnterpriseEdition(QubesSpinner): + '''Enterprise spinner + + This is tty- and terminfo-aware spinner. Recommended. + ''' + def __init__(self, stream, charset=None): + # our Enterprise logic follows + self.stream_isatty = stream.isatty() + if charset is None: + charset = ENTERPRISE_CHARSET if self.stream_isatty else '.' + + super(QubesSpinnerEnterpriseEdition, self).__init__(stream, charset) + + if self.stream_isatty: + try: + curses.setupterm() + self.has_terminfo = True + self.cub1 = curses.tigetstr('cub1').decode() + except (curses.error, io.UnsupportedOperation): + # we are in very non-Enterprise environment + self.has_terminfo = False + else: + self.cub1 = '' + + def hide(self): + if self.stream_isatty: + hideseq = '\r' + ' ' * self.hidelen + '\r' + if self.has_terminfo: + hideseq_l = (curses.tigetstr('cr'), curses.tigetstr('clr_eol')) + if all(seq is not None for seq in hideseq_l): + hideseq = ''.join(seq.decode() for seq in hideseq_l) + else: + hideseq = '\n' + + self.stream.write(hideseq) + self.stream.flush() diff --git a/qubesadmin/tools/qvm_ls.py b/qubesadmin/tools/qvm_ls.py index 9e882eb..56f277f 100644 --- a/qubesadmin/tools/qvm_ls.py +++ b/qubesadmin/tools/qvm_ls.py @@ -34,6 +34,7 @@ import sys import textwrap import qubesadmin +import qubesadmin.spinner import qubesadmin.tools import qubesadmin.utils import qubesadmin.vm @@ -380,19 +381,23 @@ class Table(object): :param qubes.Qubes app: Qubes application object. :param list colnames: Names of the columns (need not to be uppercase). ''' - def __init__(self, app, colnames, raw_data=False): + def __init__(self, app, colnames, spinner, raw_data=False): self.app = app self.columns = tuple(Column.columns[col.upper()] for col in colnames) + self.spinner = spinner self.raw_data = raw_data - def get_head(self): '''Get table head data (all column heads).''' return [col.ls_head for col in self.columns] def get_row(self, vm): '''Get single table row data (all columns for one domain).''' - return [col.cell(vm) for col in self.columns] + ret = [] + for col in self.columns: + ret.append(col.cell(vm)) + self.spinner.update() + return ret def write_table(self, stream=sys.stdout): '''Write whole table to file-like object. @@ -402,9 +407,12 @@ class Table(object): table_data = [] if not self.raw_data: + self.spinner.show('please wait...') table_data.append(self.get_head()) + self.spinner.update() for vm in sorted(self.app.domains): table_data.append(self.get_row(vm)) + self.spinner.hide() qubesadmin.tools.print_table(table_data, stream=stream) else: for vm in sorted(self.app.domains): @@ -515,6 +523,16 @@ def get_parser(): help='Display specify data of specified VMs. Intended for ' 'bash-parsing.') + parser.add_argument('--spinner', + action='store_true', dest='spinner', + help='reenable spinner') + + parser.add_argument('--no-spinner', + action='store_false', dest='spinner', + help='disable spinner') + + parser.set_defaults(spinner=True) + # parser.add_argument('--conf', '-c', # action='store', metavar='CFGFILE', # help='Qubes config file') @@ -547,7 +565,14 @@ def main(args=None, app=None): if col.upper() not in Column.columns: PropertyColumn(col) - table = Table(args.app, columns) + if args.spinner: + # we need Enterprise Edition™, since it's the only one that detects TTY + # and uses dots if we are redirected somewhere else + spinner = qubesadmin.spinner.QubesSpinnerEnterpriseEdition(sys.stderr) + else: + spinner = qubesadmin.spinner.DummySpinner(sys.stderr) + + table = Table(args.app, columns, spinner) table.write_table(sys.stdout) return 0